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:
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ConversationExport } from "@/src/types";
|
||||
import conversationMarkdown from "./conversation-markdown";
|
||||
|
||||
const format_time = (time: number) => `t${time}`;
|
||||
|
||||
const conversation: ConversationExport = {
|
||||
bot_name: "Ada",
|
||||
workspace_name: "Launch",
|
||||
user_name: "Ben",
|
||||
session: { id: 1, title: "Kickoff", created_at: 10 },
|
||||
messages: [
|
||||
{ id: 1, session_id: 1, author: "user", text: "Hi [bot:3]", created_at: 11 },
|
||||
{
|
||||
id: 2,
|
||||
session_id: 1,
|
||||
author: "bot",
|
||||
text: "## Plan\n\nDone.",
|
||||
created_at: 12,
|
||||
attachments: [{ id: 5, name: "plan.pdf", media_type: "document", url: "" }],
|
||||
reactions: [{ emoji: "👍", count: 2, mine: true }],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
session_id: 1,
|
||||
author: "user",
|
||||
text: "Thanks",
|
||||
created_at: 13,
|
||||
reply_to: { id: 2, author: "bot", text: "## Plan\n\nDone." },
|
||||
},
|
||||
],
|
||||
ref_names: { bots: { 3: "Ada" }, workspaces: {} },
|
||||
};
|
||||
|
||||
describe("conversationMarkdown", () => {
|
||||
test("lays out the session's messages", () => {
|
||||
expect(conversationMarkdown({ conversation, now: 100, format_time })).toBe(
|
||||
[
|
||||
"# Kickoff",
|
||||
"",
|
||||
"- **Bot:** Ada",
|
||||
"- **Workspace:** Launch",
|
||||
"- **Participants:** Ben, Ada",
|
||||
"- **Started:** t10",
|
||||
"- **Messages:** 3",
|
||||
"- **Copied:** t100",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Ben · t11",
|
||||
"",
|
||||
"Hi @Ada",
|
||||
"",
|
||||
"## Ada · t12",
|
||||
"",
|
||||
"#### Plan",
|
||||
"",
|
||||
"Done.",
|
||||
"",
|
||||
"*Attached: plan.pdf*",
|
||||
"",
|
||||
"*Reactions: 👍 2*",
|
||||
"",
|
||||
"## Ben · t13",
|
||||
"",
|
||||
"> Replying to Ada: Plan Done.",
|
||||
"",
|
||||
"Thanks",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("titles an untitled or empty session after the bot", () => {
|
||||
const markdown = conversationMarkdown({
|
||||
conversation: { ...conversation, session: null, messages: [] },
|
||||
now: 1,
|
||||
format_time,
|
||||
});
|
||||
|
||||
expect(markdown).toStartWith("# Chat with Ada\n");
|
||||
expect(markdown).toContain("*No messages yet.*");
|
||||
expect(markdown).not.toContain("**Started:**");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { BotMessageWithAttachments, ConversationExport } from "@/src/types";
|
||||
import exportMessageText from "./export-message-text";
|
||||
import messagePreview from "./message-preview";
|
||||
|
||||
type Params = {
|
||||
conversation: ConversationExport;
|
||||
/**
|
||||
* When the copy was made.
|
||||
*/
|
||||
now: number;
|
||||
/**
|
||||
* A timestamp as the reader sees it; local time by default.
|
||||
*/
|
||||
format_time?: (time: number) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Longest a quoted message runs in a reply's "Replying to" line.
|
||||
*/
|
||||
const QuoteMaxChars = 120;
|
||||
|
||||
/**
|
||||
* A conversation's session (`grabConversationExport`) as one markdown
|
||||
* document: a title and summary, then every message under its author and
|
||||
* time. Messages' own headings drop below those (`exportMessageText`) and
|
||||
* their refs read as names.
|
||||
*/
|
||||
export default function conversationMarkdown({
|
||||
conversation,
|
||||
now,
|
||||
format_time = (time) =>
|
||||
new Date(time).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}),
|
||||
}: Params) {
|
||||
const { bot_name, workspace_name, user_name, session, messages, ref_names } = conversation;
|
||||
|
||||
const authorName = (author?: string) => (author == "bot" ? bot_name : user_name);
|
||||
|
||||
const messageBlock = (message: BotMessageWithAttachments) => {
|
||||
const lines = [`## ${authorName(message.author)} · ${format_time(Number(message.created_at))}`, ""];
|
||||
|
||||
if (message.reply_to) {
|
||||
const quote = messagePreview({ text: message.reply_to.text || "" });
|
||||
|
||||
lines.push(
|
||||
`> Replying to ${authorName(message.reply_to.author)}: ${
|
||||
quote.length > QuoteMaxChars ? `${quote.slice(0, QuoteMaxChars).trimEnd()}...` : quote
|
||||
}`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
const text = exportMessageText({
|
||||
text: message.text || "",
|
||||
bot_names: ref_names.bots,
|
||||
workspace_names: ref_names.workspaces,
|
||||
heading_offset: 2,
|
||||
});
|
||||
|
||||
if (text) lines.push(text, "");
|
||||
|
||||
if (message.attachments?.length) {
|
||||
lines.push(`*Attached: ${message.attachments.map((file) => file.name).join(", ")}*`, "");
|
||||
}
|
||||
|
||||
if (message.reactions?.length) {
|
||||
lines.push(
|
||||
`*Reactions: ${message.reactions
|
||||
.map((reaction) => (reaction.count > 1 ? `${reaction.emoji} ${reaction.count}` : reaction.emoji))
|
||||
.join(" ")}*`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const out: string[] = [
|
||||
`# ${session?.title?.trim() || `Chat with ${bot_name}`}`,
|
||||
"",
|
||||
`- **Bot:** ${bot_name}`,
|
||||
];
|
||||
|
||||
if (workspace_name) out.push(`- **Workspace:** ${workspace_name}`);
|
||||
|
||||
out.push(`- **Participants:** ${user_name}, ${bot_name}`);
|
||||
|
||||
if (session) out.push(`- **Started:** ${format_time(session.created_at)}`);
|
||||
|
||||
out.push(
|
||||
`- **Messages:** ${messages.length}`,
|
||||
`- **Copied:** ${format_time(now)}`,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
);
|
||||
|
||||
if (!messages.length) out.push("*No messages yet.*", "");
|
||||
|
||||
out.push(...messages.map(messageBlock));
|
||||
|
||||
return `${out.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import copyText from "./copy-text";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The text, still on its way (e.g. from a fetch).
|
||||
*/
|
||||
text: Promise<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copies text that is still loading and says whether it worked. Safari
|
||||
* only lets a click write to the clipboard while it is still handling
|
||||
* the click, so the write starts at once with a `ClipboardItem` that
|
||||
* waits for the text. Where that isn't supported it waits and falls back
|
||||
* to `copyText`. Throws when `text` does.
|
||||
*/
|
||||
export default async function copyPendingText({ text }: Params): Promise<boolean> {
|
||||
if (typeof ClipboardItem != "undefined" && navigator.clipboard?.write) {
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/plain": text.then((value) => new Blob([value], { type: "text/plain" })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through: `text` failing rethrows below.
|
||||
}
|
||||
}
|
||||
|
||||
return copyText({ text: await text });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import exportMessageText from "./export-message-text";
|
||||
|
||||
const names = { bot_names: { 3: "Ada" }, workspace_names: { 2: "Launch" } };
|
||||
|
||||
describe("exportMessageText", () => {
|
||||
test("words refs, naming what is known", () => {
|
||||
expect(
|
||||
exportMessageText({
|
||||
text: "[bot:3] and [bot:9] in [workspace:2] on [computer:4], see [file:12] and [folder:computer:4:seo/raw]. [take-over:4:browser]",
|
||||
...names,
|
||||
heading_offset: 0,
|
||||
}),
|
||||
).toBe("@Ada and a bot in #Launch on computer 4, see file 12 and `seo/raw`. (take over computer 4, browser)");
|
||||
});
|
||||
|
||||
test("leaves refs that are already links alone", () => {
|
||||
expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)");
|
||||
});
|
||||
|
||||
test("drops headings, capped at six", () => {
|
||||
expect(
|
||||
exportMessageText({ text: "# Top\n## Next\n##### Deep\n#nottag", ...names, heading_offset: 3 }),
|
||||
).toBe("#### Top\n##### Next\n###### Deep\n#nottag");
|
||||
});
|
||||
|
||||
test("leaves code blocks exactly as they are", () => {
|
||||
const text = "```bash\n# comment [bot:3]\n```\n# Title\n~~~~\n## also code\n~~~\n~~~~";
|
||||
|
||||
expect(exportMessageText({ text, ...names, heading_offset: 3 })).toBe(
|
||||
"```bash\n# comment [bot:3]\n```\n#### Title\n~~~~\n## also code\n~~~\n~~~~",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user