Let users copy the current chat session as markdown

The conversation header menu gets a Copy chat row that puts the active
session on the clipboard as one markdown document: a summary, then each
message under its author and time, with quotes, attachments and
reactions. Bot and workspace refs read as names, message headings drop
below the message headings and code blocks stay untouched. The clipboard
write starts on the click with a pending ClipboardItem so Safari allows
it after the fetch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 11:49:46 +01:00
co-authored by Claude Opus 5.5
parent d7f88d8dd3
commit 84f8c0cac8
12 changed files with 641 additions and 2 deletions
+87
View File
@@ -0,0 +1,87 @@
type Params = {
text: string;
/**
* Names by id; a ref to anything missing reads "a bot" / "a workspace".
*/
bot_names: Record<number, string>;
workspace_names: Record<number, string>;
/**
* How many levels the message's own headings drop, so they sit under
* the heading of the message they are in. Capped at `######`.
*/
heading_offset: number;
};
/**
* A message's markdown as it reads outside the app: its refs in words
* (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as
* "computer 3", a folder as its path) and its headings dropped by
* `heading_offset`. Code blocks are left exactly as they are.
*/
export default function exportMessageText({
text,
bot_names,
workspace_names,
heading_offset,
}: Params) {
let fence: string | null = null;
return text
.split("\n")
.map((line) => {
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/)?.[1];
if (fence) {
if (marker && marker[0] == fence[0] && marker.length >= fence.length) {
fence = null;
}
return line;
}
if (marker) {
fence = marker;
return line;
}
return wordRefs({ line, bot_names, workspace_names }).replace(
/^(\s{0,3})(#{1,6})(?=\s|$)/,
(_, indent: string, hashes: string) =>
`${indent}${"#".repeat(Math.min(6, hashes.length + heading_offset))}`,
);
})
.join("\n")
.trim();
}
/**
* One line's refs in words. Refs that are already markdown links
* (`[bot:3](...)`) are left alone, as the chat leaves them.
*/
function wordRefs({
line,
bot_names,
workspace_names,
}: {
line: string;
bot_names: Record<number, string>;
workspace_names: Record<number, string>;
}) {
return line
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => {
const name = bot_names[Number(id)];
return name ? `@${name}` : "a bot";
})
.replace(/\[workspace:(\d+)\](?!\()/g, (_, id: string) => {
const name = workspace_names[Number(id)];
return name ? `#${name}` : "a workspace";
})
.replace(/\[computer:(\d+)\](?!\()/g, "computer $1")
.replace(/\[take-over:(\d+):([a-z]+)\](?!\()/g, "(take over computer $1, $2)")
.replace(/\[proposal:(\d+)\](?!\()/g, "proposal $1")
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder");
}