Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions src/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1053,8 +1053,8 @@ export const ThreadView = memo(function ThreadView({
);
/** Bumps after a successful push so the sync pill refetches. */
const [syncRefreshNonce, setSyncRefreshNonce] = useState(0);
/** Brief inline confirmation after copying the thread id. */
const [copied, setCopied] = useState<"id" | "link" | null>(null);
/** Brief inline confirmation after copying the thread id or `/copy`. */
const [copied, setCopied] = useState<"id" | "link" | "reply" | null>(null);
/** Header quick action currently in flight (issue #153). */
const [commandRunningId, setCommandRunningId] = useState<string | null>(null);
const [commandError, setCommandError] = useState<string | null>(null);
Expand Down Expand Up @@ -1706,8 +1706,39 @@ export const ThreadView = memo(function ThreadView({
onNewThread?.();
}, [isWorking, onSettleThread, onNewThread]);

const flashCopied = useCallback((what: "id" | "link" | "reply") => {
setCopied(what);
if (copyFlashTimer.current != null) {
clearTimeout(copyFlashTimer.current);
}
copyFlashTimer.current = setTimeout(() => {
setCopied(null);
copyFlashTimer.current = null;
}, COPY_FLASH_MS);
}, []);

// `/copy` (#493): the latest finished assistant reply, as Markdown source.
// A still-streaming reply is skipped; no reply at all is a no-op.
const handleCopyLastReply = useCallback(() => {
const reply = [...(detail?.messages ?? [])].reverse().find(
(m) =>
m.role === "assistant" &&
!m.thinking &&
m.id !== streamingMessageId &&
m.text.trim() !== "",
Comment on lines +1725 to +1728

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude every message from the active run

When a working turn emits assistant text and then starts a tool, the runners append the tool after that assistant message, so useTranscriptAnnotations sets streamingMessageId to null because the last message is no longer an assistant. Invoking /copy during the tool then selects the current turn's pre-tool commentary instead of the previous completed reply (and does so even when no completed reply exists). Exclude assistant messages belonging to the active run while the thread is working, rather than excluding only streamingMessageId.

Useful? React with 👍 / 👎.

);
if (!reply) return;
void navigator.clipboard
?.writeText(reply.text)
.then(() => flashCopied("reply"), () => {});
}, [detail?.messages, streamingMessageId, flashCopied]);

const handleSlashAction = useCallback(
(action: SlashAction) => {
if (action === "copy") {
handleCopyLastReply();
return;
}
if (action === "usage") {
setContextOpen(false);
setQuotaOpen(true);
Expand Down Expand Up @@ -1743,6 +1774,7 @@ export const ThreadView = memo(function ThreadView({
}
},
[
handleCopyLastReply,
ring,
handleCompact,
handleForkFresh,
Expand Down Expand Up @@ -2654,14 +2686,7 @@ export const ThreadView = memo(function ThreadView({
} catch {
return;
}
setCopied(what);
if (copyFlashTimer.current != null) {
clearTimeout(copyFlashTimer.current);
}
copyFlashTimer.current = setTimeout(() => {
setCopied(null);
copyFlashTimer.current = null;
}, COPY_FLASH_MS);
flashCopied(what);
};

const hasVersionControl =
Expand Down Expand Up @@ -2746,7 +2771,11 @@ export const ThreadView = memo(function ThreadView({
});
// Run status rides on the composer's top edge (#1429), not the transcript.
const composerStatus =
detail.thread.status === "quota-wait" ? (
copied === "reply" ? (
<div className={styles.statusStrip} data-copied-reply="" role="status">
<span className={styles.statusText}>Copied last reply</span>
</div>
) : detail.thread.status === "quota-wait" ? (
<div
className={`${styles.statusStrip} ${styles.statusStripQuotaWait}`}
data-quota-wait-strip=""
Expand Down
7 changes: 7 additions & 0 deletions src/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const NATIVE_COMPACT_PROVIDERS: readonly string[] = ["claude", "codex"];

/** Immediate actions the palette can fire. Insert verbs have no action. */
export type SlashAction =
| "copy"
| "compact"
| "rewind"
| "usage"
Expand Down Expand Up @@ -79,6 +80,12 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
kind: "run",
action: "context",
},
{
name: "/copy",
hint: "Copy the last reply as Markdown",
kind: "run",
action: "copy",
},
{
name: "/model",
hint: "Pick provider and model",
Expand Down
54 changes: 54 additions & 0 deletions test/composerSlashActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,60 @@ describe("ThreadView / palette actions", () => {
assert.equal(opened, 1);
});

describe("copy (#493)", () => {
let copied: string[] = [];
const stubClipboard = () => {
copied = [];
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: async (text: string) => {
copied.push(text);
},
},
configurable: true,
});
};
const twoTurns = () =>
detail({
messages: [
msg({ id: "u1", role: "user", text: "first", createdAt: 1 }),
msg({ id: "a1", role: "assistant", text: "older **reply**", createdAt: 2 }),
msg({ id: "u2", role: "user", text: "second", createdAt: 3 }),
msg({ id: "a2", role: "assistant", text: "latest reply", createdAt: 4 }),
msg({ id: "t1", role: "tool", text: "ran ls", createdAt: 5 }),
],
});

it("/copy copies the latest assistant reply and flashes", async () => {
stubClipboard();
const m = await mountView({ detail: twoTurns() });
await acceptSlash(m, "/copy");
await m.flush();
assert.deepEqual(copied, ["latest reply"]);
assert.match(m.query("[data-copied-reply]")?.textContent ?? "", /Copied last reply/);
assert.equal(textarea(m).value, "", "token cleared");
});

it("the per-message button copies that older turn's Markdown", async () => {
stubClipboard();
const m = await mountView({ detail: twoTurns() });
const btn = m.query('[data-msg="a1"] [data-msg-copy]');
assert.ok(btn, "copy button on the older reply");
await m.click(btn as HTMLElement);
await m.flush();
assert.deepEqual(copied, ["older **reply**"]);
});

it("/copy on an empty thread is a no-op", async () => {
stubClipboard();
const m = await mountView({ detail: detail({ messages: [] }) });
await acceptSlash(m, "/copy");
await m.flush();
assert.deepEqual(copied, []);
assert.equal(m.query("[data-copied-reply]"), null);
});
});

it("/rewind opens the last-user-message confirm when there is no undo bar", async () => {
const m = await mountView({
onRewindAndResubmit: async () => {},
Expand Down
5 changes: 3 additions & 2 deletions test/slashCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe("matchSlashCommands", () => {
"/undo",
"/usage",
"/context",
"/copy",
"/model",
"/effort",
"/permissions",
Expand All @@ -58,9 +59,9 @@ describe("matchSlashCommands", () => {
}
});

it("filters by prefix: /co hits compact, committee, context", () => {
it("filters by prefix: /co hits compact, context, copy, committee", () => {
const names = matchSlashCommands("/co").map((c) => c.name);
assert.deepEqual(names, ["/compact", "/context", "/committee"]);
assert.deepEqual(names, ["/compact", "/context", "/copy", "/committee"]);
});

it("does not invent a match for an unknown /foo", () => {
Expand Down
Loading