From a9aa31c29f35752028b609edf581e39dbcd7a52d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 26 Aug 2026 15:57:22 -0700 Subject: [PATCH 1/8] Add tests for hover-edit of own prompts into the composer --- packages/chat-ui/test/chat-workspace.test.tsx | 130 ++++++++++++++++++ packages/chat-ui/test/composer.test.tsx | 37 +++++ .../test/message-hover-toolbar.test.tsx | 107 +++++++++++++- 3 files changed, 269 insertions(+), 5 deletions(-) diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index 6874635d3..2a81daa81 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -651,6 +651,136 @@ describe("Thread breadcrumb and fork (CL-5908, CL-5948)", () => { }); }); +describe("hover Edit copies an own prompt into the composer", () => { + const OWN_PROMPT = "rewrite this prompt"; + + function stubFetchWithOwnPrompt( + sentMessages: unknown[], + forkCalls: string[], + ) { + globalThis.EventSource = StubEventSource as unknown as typeof EventSource; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = typeof input === "string" ? input : String(input); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { + return json({ items: [WORKBENCH_WIRE] }); + } + if (/\/chat\/workbenches\?kind=chat$/.test(path)) + return json({ items: [] }); + if (/\/chat\/workbenches\/[^/]+\/threads\/fork$/.test(path)) { + forkCalls.push(path); + return json({ id: "thr_should_not_fork" }); + } + if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { + return json({ rootThreadId: "", items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { + if (init?.method === "POST") { + sentMessages.push(JSON.parse(String(init.body))); + return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" }); + } + return json({ + items: [ + { + id: "msg_own", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [{ kind: "text", text: OWN_PROMPT }], + sender: { name: null, address: "prn_alice@acme.example" }, + }, + { + id: "msg_agent", + createdAt: "2026-01-01T00:00:01.000Z", + parts: [{ kind: "text", text: "agent reply" }], + sender: { + name: "Researcher", + address: "researcher@agents.example", + }, + }, + ], + }); + } + if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); + if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { + return json({ + ...WORKBENCH_WIRE, + settings: {}, + contextWindow: { value: 20, source: "inherit" }, + }); + } + if (/\/chat\/bench\/settings$/.test(path)) { + return json({ settings: {}, contextWindow: 20 }); + } + throw new Error(`unstubbed fetch: ${path}`); + }) as typeof fetch; + } + + test("clicking Edit fills the composer; send posts a new message and does not fork", async () => { + const sentMessages: unknown[] = []; + const forkCalls: string[] = []; + stubFetchWithOwnPrompt(sentMessages, forkCalls); + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + currentUser: { principalId: "prn_alice" }, + }); + await harness.settle(); + + const ownGroup = harness.container.querySelector("#chat-message-msg_own"); + const agentGroup = harness.container.querySelector( + "#chat-message-msg_agent", + ); + expect(ownGroup?.getAttribute("data-own")).toBe("true"); + expect(agentGroup?.querySelector(".chat-hover-edit")).toBeNull(); + + typeInComposer(harness.container, "unsent draft"); + const edit = ownGroup?.querySelector( + ".chat-hover-edit", + ) as HTMLButtonElement; + expect(edit).not.toBeNull(); + await act(async () => { + edit.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await sleep(30); + }); + + const composer = harness.container.querySelector( + ".chat-composer-input", + ) as HTMLTextAreaElement; + expect(composer.value).toBe(OWN_PROMPT); + expect(ownGroup?.textContent).toContain(OWN_PROMPT); + + const sendButton = harness.container.querySelector( + 'button[aria-label="Send"]', + ) as HTMLButtonElement; + await act(async () => { + sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await sleep(30); + }); + + expect(forkCalls).toEqual([]); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0]).toMatchObject({ + parts: [{ kind: "text", text: OWN_PROMPT }], + }); + expect( + harness.container.querySelector("#chat-message-msg_own"), + ).not.toBeNull(); + expect( + harness.container.querySelector("#chat-message-msg_own")?.textContent, + ).toContain(OWN_PROMPT); + harness.unmount(); + }); +}); + const WORKBENCH_WITH_AGENT_WIRE = { ...WORKBENCH_WIRE, participants: [ diff --git a/packages/chat-ui/test/composer.test.tsx b/packages/chat-ui/test/composer.test.tsx index 518d18777..74e18bcb9 100644 --- a/packages/chat-ui/test/composer.test.tsx +++ b/packages/chat-ui/test/composer.test.tsx @@ -408,3 +408,40 @@ describe("Composer mention bring-in load error (CL-6839)", () => { expect(handles).toEqual(["@researcher"]); }); }); + +describe("ComposerHandle.setText", () => { + test("replaces the existing draft and focuses with the caret at the end", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const ref = createRef(); + act(() => { + root?.render( + createElement(Composer, { + ref, + agents: [], + onSend: () => Promise.resolve(true), + onInviteAgent: () => undefined, + onOpenAgentsSettings: () => undefined, + onCreateRoutineInSpace: () => undefined, + }), + ); + }); + + typeInto(textarea(), "unsent draft"); + await settle(); + expect(textarea().value).toBe("unsent draft"); + + await act(async () => { + ref.current?.setText("previous prompt"); + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + }); + + expect(textarea().value).toBe("previous prompt"); + expect(document.activeElement).toBe(textarea()); + expect(textarea().selectionStart).toBe("previous prompt".length); + expect(textarea().selectionEnd).toBe("previous prompt".length); + }); +}); diff --git a/packages/chat-ui/test/message-hover-toolbar.test.tsx b/packages/chat-ui/test/message-hover-toolbar.test.tsx index 9c8c0f54d..580c3b4d7 100644 --- a/packages/chat-ui/test/message-hover-toolbar.test.tsx +++ b/packages/chat-ui/test/message-hover-toolbar.test.tsx @@ -8,9 +8,13 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import type { MessageItem } from "../src/api"; import { WorkbenchTimeline } from "../src/timeline"; -import type { PinActions, ReactionActions } from "../src/timeline"; +import type { + CurrentUser, + PinActions, + ReactionActions, + TimelineMessageItem, +} from "../src/timeline"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -24,7 +28,9 @@ async function flush(): Promise { }); } -function textMessage(overrides: Partial = {}): MessageItem[] { +function textMessage( + overrides: Partial = {}, +): TimelineMessageItem[] { return [ { id: "m1", @@ -32,7 +38,7 @@ function textMessage(overrides: Partial = {}): MessageItem[] { parts: [{ kind: "text", text: "ship it" }], sender: { name: "Researcher", address: "researcher@agents.example" }, ...overrides, - } as MessageItem, + }, ]; } @@ -47,8 +53,10 @@ afterEach(() => { }); async function mount(props: { - items: MessageItem[]; + items: TimelineMessageItem[]; onOpenThread?: (messageId: string) => void; + onEditMessage?: (messageId: string) => void; + currentUser?: CurrentUser; reactionActions?: ReactionActions; pinActions?: PinActions; }) { @@ -62,6 +70,12 @@ async function mount(props: { {...(props.onOpenThread !== undefined ? { onOpenThread: props.onOpenThread } : {})} + {...(props.onEditMessage !== undefined + ? { onEditMessage: props.onEditMessage } + : {})} + {...(props.currentUser !== undefined + ? { currentUser: props.currentUser } + : {})} {...(props.reactionActions !== undefined ? { reactionActions: props.reactionActions } : {})} @@ -112,6 +126,89 @@ describe("message hover toolbar", () => { }); }); +describe("hover Edit on own prompts", () => { + test("Edit appears only when the row is the signed-in user's own prompt", async () => { + const el = await mount({ + items: textMessage(), + currentUser: { principalId: "researcher" }, + onEditMessage: () => undefined, + }); + + const group = el.querySelector(".chat-message-group"); + expect(group?.getAttribute("data-own")).toBe("true"); + expect(el.querySelector(".chat-hover-edit")).not.toBeNull(); + }); + + test("Edit is absent on someone else's prompt even when onEditMessage is wired", async () => { + const el = await mount({ + items: textMessage(), + currentUser: { principalId: "someone-else" }, + onEditMessage: () => undefined, + }); + + expect(el.querySelector(".chat-message-group")?.getAttribute("data-own")).toBe( + "false", + ); + expect(el.querySelector(".chat-hover-edit")).toBeNull(); + }); + + test("Edit is absent on a pending own send", async () => { + const el = await mount({ + items: textMessage({ pendingStatus: "sending" }), + currentUser: { principalId: "researcher" }, + onEditMessage: () => undefined, + }); + + expect(el.querySelector(".chat-hover-toolbar")).toBeNull(); + expect(el.querySelector(".chat-hover-edit")).toBeNull(); + }); + + test("Edit is absent on a streaming row", async () => { + const el = await mount({ + items: textMessage({ streaming: true }), + currentUser: { principalId: "researcher" }, + onEditMessage: () => undefined, + }); + + expect(el.querySelector(".chat-hover-toolbar")).toBeNull(); + expect(el.querySelector(".chat-hover-edit")).toBeNull(); + }); + + test("clicking Edit calls onEditMessage with the message id and does not open a thread", async () => { + const edited: string[] = []; + const opened: string[] = []; + const el = await mount({ + items: textMessage(), + currentUser: { principalId: "researcher" }, + onEditMessage: (id) => edited.push(id), + onOpenThread: (id) => opened.push(id), + }); + + const edit = el.querySelector(".chat-hover-edit") as HTMLButtonElement; + await act(async () => edit.click()); + + expect(edited).toEqual(["m1"]); + expect(opened).toEqual([]); + }); + + test("the hover reply button still only opens a thread", async () => { + const edited: string[] = []; + const opened: string[] = []; + const el = await mount({ + items: textMessage(), + currentUser: { principalId: "researcher" }, + onEditMessage: (id) => edited.push(id), + onOpenThread: (id) => opened.push(id), + }); + + const reply = el.querySelector(".chat-hover-reply") as HTMLButtonElement; + await act(async () => reply.click()); + + expect(opened).toEqual(["m1"]); + expect(edited).toEqual([]); + }); +}); + describe("the persistent inline 'Reply in thread' link is gone", () => { test("a message with no replies yet renders no .chat-thread-affordance row", async () => { const el = await mount({ From a0ae0b2a4c3f09b9a0af8fc505bb39199fb02ac5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 26 Aug 2026 16:05:24 -0700 Subject: [PATCH 2/8] Fill the composer from a hover Edit on own prompts --- packages/chat-ui/src/chat-workspace.tsx | 15 +++++- packages/chat-ui/src/composer.tsx | 13 ++++- packages/chat-ui/src/strings.ts | 1 + packages/chat-ui/src/styles.css | 4 ++ packages/chat-ui/src/timeline.tsx | 48 ++++++++++++++++--- packages/chat-ui/test/chat-workspace.test.tsx | 5 +- .../test/message-hover-toolbar.test.tsx | 6 +-- 7 files changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index d714e45bf..c4a975b94 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -60,7 +60,12 @@ import { displayWorkbenchTitle } from "./workbench-display-title"; import { useStreamingReply, typingAgentNames } from "./streaming-reply"; import { useTurnActivity, TurnActivityStrip } from "./turn-activity"; import type { StreamingReplyState } from "./streaming-reply"; -import { AgentBadge, WorkbenchTimeline, messageDomId } from "./timeline"; +import { + AgentBadge, + WorkbenchTimeline, + messageDomId, + messageText, +} from "./timeline"; import { NoUsableModelBanner } from "./no-usable-model-banner"; import { ResumeFailedBanner } from "./resume-failed-banner"; import type { @@ -1541,6 +1546,14 @@ function ChatWorkspaceInner({ onOpenThread={ inThreadView ? forkMessage : openThreadForMessage } + onEditMessage={(messageId) => { + if (messagesState.kind !== "ready") return; + const item = messagesState.items.find( + (message) => message.id === messageId, + ); + if (item === undefined) return; + composerRef.current?.setText(messageText(item)); + }} {...(onOpenProfile !== undefined ? { onOpenProfile } : {})} {...(onOpenArtifact !== undefined ? { onOpenArtifact } diff --git a/packages/chat-ui/src/composer.tsx b/packages/chat-ui/src/composer.tsx index 534608694..e67ceeb9a 100644 --- a/packages/chat-ui/src/composer.tsx +++ b/packages/chat-ui/src/composer.tsx @@ -60,10 +60,11 @@ export type ComposerSendPayload = { }; /** Imperative seam a host can grab a ref to, so content from outside the - * composer's own tree — the profile card's Mention action (CL-5914) — can - * land in the active draft at the caret. */ + * composer's own tree — the profile card's Mention action (CL-5914) or + * hover-edit of a previous prompt — can land in the active draft. */ export type ComposerHandle = { readonly insertText: (text: string) => void; + readonly setText: (text: string) => void; }; /** Splice `insertion` in at `caret`, pure and independent of any DOM state @@ -393,6 +394,14 @@ export const Composer = forwardRef< textarea?.setSelectionRange(result.caret, result.caret); }); }, + setText: (text: string) => { + setValue(text); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + textarea?.focus(); + textarea?.setSelectionRange(text.length, text.length); + }); + }, }), [value], ); diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index f6e6760ce..7fcb26e96 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -132,6 +132,7 @@ export const CHAT_STRINGS = { forkThreadAction: "Fork", forkThreadError: "Couldn't fork that message into a thread — try again.", replyInThreadAction: "Reply in thread", + editMessageAction: "Edit", messageActionsMenuLabel: "Message actions", copyTextAction: "Copy text", copyTextCopiedToast: "Copied", diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 57df5848b..5a8e1a857 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -3171,6 +3171,7 @@ .chat-hover-toolbar .chat-reaction-add, .chat-hover-reply, +.chat-hover-edit, .chat-hover-ellipsis { position: relative; display: inline-flex; @@ -3191,6 +3192,7 @@ .chat-hover-toolbar .chat-reaction-add::after, .chat-hover-reply::after, +.chat-hover-edit::after, .chat-hover-ellipsis::after { content: ""; position: absolute; @@ -3199,6 +3201,7 @@ .chat-hover-toolbar .chat-reaction-add svg, .chat-hover-reply svg, +.chat-hover-edit svg, .chat-hover-ellipsis svg { width: 0.9rem; height: 0.9rem; @@ -3206,6 +3209,7 @@ .chat-hover-toolbar .chat-reaction-add:hover, .chat-hover-reply:hover, +.chat-hover-edit:hover, .chat-hover-ellipsis:hover, .chat-hover-toolbar .chat-reaction-add[aria-expanded="true"], .chat-hover-ellipsis[aria-expanded="true"] { diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index e06703b59..6f2f1365b 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -34,6 +34,7 @@ import { Clock, Copy, DotsThree, + PencilSimple, PushPin, PushPinSlash, Smiley, @@ -936,7 +937,7 @@ async function copyMessageText(text: string): Promise { } } -function messageText(item: MessageItem): string { +export function messageText(item: MessageItem): string { return item.parts .filter((part): part is Part & { kind: "text" } => part.kind === "text") .map((part) => part.text) @@ -1164,11 +1165,13 @@ function buildMessageMenu({ item, threadAffordanceMode, onOpenThread, + onEditMessage, pinActions, }: { readonly item: MessageItem; readonly threadAffordanceMode: ThreadAffordanceMode; readonly onOpenThread: ((messageId: string) => void) | undefined; + readonly onEditMessage: ((messageId: string) => void) | undefined; readonly pinActions: PinActions | undefined; }): ContextMenu { const entries: ContextMenuEntry[] = []; @@ -1187,6 +1190,17 @@ function buildMessageMenu({ ); } + if (onEditMessage !== undefined) { + entries.push( + contextMenuItem({ + id: "edit-message", + label: CHAT_STRINGS.editMessageAction, + icon: