diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a26139c7..c23511c23 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -77,6 +77,17 @@ tenant and writes settings rows — no deploy, no host, no anchor instance one publish onto the workbench's live stream, so the conversation takes messages whether or not any agent process is running. +**Editing an own prompt.** Edit is a composer replace, not a timeline +mutation and not a thread fork. The timeline offers Edit only on the +signed-in reader's own prompts that have text +(`packages/chat-ui/src/timeline.tsx`). Choosing it copies that message's +text into the composer through `ComposerHandle.setText` +(`packages/chat-ui/src/composer.tsx`). `setText` replaces the draft and +clears leftover composer-private state (slash, mention, pending invites, +attachments, in-flight attachment reads). Sending is the ordinary post +onto the timeline already in view (root or an open thread). It does not +PATCH the origin message and it does not call `forkThread`. + A workbench's address is derived, not resolved. Asking an invited agent for a turn is a separate act over Interchange mail; the agent replies by emitting `connector.reply` events on its own stream, and a reply bridge diff --git a/DESIGN.md b/DESIGN.md index 0dbb6d538..cb60a8756 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -277,6 +277,18 @@ short tags or a card, and mirroring them to the right would land next to the composer and break the one consistent place a reader looks for approvals and tool activity. +## Message actions + +A message's compact action cluster — add reaction, reply in thread (or +Fork inside a thread), Edit on own prompts, and ellipsis — reveals on +pointer hover or keyboard focus-within. It is not hover-only and not a +persistent inline row of links. The ellipsis button and a right-click on +the message open the same menu; Edit appears there too when the row is +the signed-in reader's own prompt with text. + +Edit copies the prompt into the composer. It is not an in-place rewrite +of the bubble. + ## Connect cards In-thread cards flip in place: disconnected → connected → next step. The diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 1d3abd7a8..6f1fbaa48 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -41,6 +41,12 @@ core, reusable components live there; only workbench-specific composition is pinned to a specific upstream commit rather than a floating version range. +`@corbits/chat-ui` owns the conversation surface. The composer's host +seam is `ComposerHandle`: `insertText` splices at the caret (Mention); +`setText` replaces the whole draft (Edit a previous prompt) and is the +layer that clears leftover slash, mention, invite, and attachment state +so a replaced draft cannot send under the old picker's rules. + ## Vendored `@intx/*` Interchange capabilities are consumed as published `@intx/*` npm packages diff --git a/PRODUCT.md b/PRODUCT.md index 5e33328ec..7375a46dd 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -115,6 +115,14 @@ does not wake an agent. Generic `connections/pending` still wakes the asking agent. Neither path posts the connected notice as the connecting person. +## Reusing an own prompt + +A person can **Edit** their own previous prompt. Edit copies that text +into the composer, replacing any leftover draft — slash command, mention, +invite, and attachments. Sending posts a new message on the same +timeline: it does not rewrite the original, and it does not fork a +thread. Other people's prompts have no Edit. + ## Plugins and Skills A **Skill** is a named, reusable capability — instructions an agent can 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..152751c2c 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 @@ -367,6 +368,7 @@ export const Composer = forwardRef< const [focused, setFocused] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); + const attachGenerationRef = useRef(0); /** Auto-grow: the textarea reports its own content height, so the * measurement resets to the CSS-declared min-height before reading @@ -380,6 +382,21 @@ export const Composer = forwardRef< textarea.style.height = `${textarea.scrollHeight}px`; }, [value]); + function syncComposerSuggestState(text: string, caret: number) { + setHelpOpen(false); + const openSlash = activeSlashQuery(text, caret); + if (openSlash !== null) { + setSlash(openSlash); + setSlashHighlight(0); + setMention(null); + return; + } + setSlash(null); + const openMention = activeMentionQuery(text, caret); + setMention(openMention); + setHighlight(0); + } + useImperativeHandle( ref, () => ({ @@ -393,6 +410,22 @@ export const Composer = forwardRef< textarea?.setSelectionRange(result.caret, result.caret); }); }, + setText: (text: string) => { + attachGenerationRef.current += 1; + setValue(text); + setMention(null); + setSlash(null); + setHelpOpen(false); + setPendingInvites([]); + setAttachments([]); + setErrorMessage(null); + setPreparing(false); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + textarea?.focus(); + textarea?.setSelectionRange(text.length, text.length); + }); + }, }), [value], ); @@ -413,21 +446,6 @@ export const Composer = forwardRef< sending, }); - function syncComposerSuggestState(text: string, caret: number) { - setHelpOpen(false); - const openSlash = activeSlashQuery(text, caret); - if (openSlash !== null) { - setSlash(openSlash); - setSlashHighlight(0); - setMention(null); - return; - } - setSlash(null); - const openMention = activeMentionQuery(text, caret); - setMention(openMention); - setHighlight(0); - } - /** * Fires the send and tracks its flight for the button's spinner — * nothing here decides what the timeline shows on success or failure. @@ -537,6 +555,7 @@ export const Composer = forwardRef< if (!canAttachComposer({ sending, preparing })) return; const files = Array.from(fileList); + const generation = attachGenerationRef.current; const validation = validateAttachmentPick( attachments.length, attachmentBytesOnComposer(attachments), @@ -563,11 +582,15 @@ export const Composer = forwardRef< }); } // All-or-nothing: only commit once every file in the pick has read. + if (attachGenerationRef.current !== generation) return; setAttachments((previous) => [...previous, ...next]); } catch { + if (attachGenerationRef.current !== generation) return; setErrorMessage(CHAT_STRINGS.composerAttachmentReadError); } finally { - setPreparing(false); + if (attachGenerationRef.current === generation) { + setPreparing(false); + } resetFileInput(); } } 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..fc6e6a00b 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -3105,9 +3105,11 @@ /* Mock's hover toolbar: the compact trailing-edge cluster (add-reaction, reply-in-thread, ellipsis) that replaces the old persistent inline "Reply in thread" link and the standalone add-reaction trigger. Floats - over the message's top-right corner, fading in on hover/focus-within - like `.chat-pin-toggle` above, plus `data-open` so it stays put while its - own picker or menu is open even if focus moves into a portaled menu. + over the inboard (trailing) corner — top-right on others' left-aligned + rows, top-left on own right-aligned rows so Edit does not sit on the + prompt — fading in on hover/focus-within like `.chat-pin-toggle` above, + plus `data-open` so it stays put while its own picker or menu is open + even if focus moves into a portaled menu. Positioned relative to `.chat-message-row` (CL-6376) — the wrapper around this one message's own content, siblings-with but excluding its optional `.chat-day-divider` — rather than `.chat-message-group` as a @@ -3162,6 +3164,13 @@ transition: opacity 120ms var(--chat-ease); } +/* Own (right-aligned) rows: inboard edge is the left of the row, matching + how `[data-own="true"] .chat-artifact-chip` already mirrors. */ +.chat-message-group[data-own="true"] .chat-hover-toolbar { + right: auto; + left: 0.4rem; +} + .chat-message-group:hover .chat-hover-toolbar, .chat-message-group:focus-within .chat-hover-toolbar, .chat-hover-toolbar[data-open="true"] { @@ -3171,6 +3180,7 @@ .chat-hover-toolbar .chat-reaction-add, .chat-hover-reply, +.chat-hover-edit, .chat-hover-ellipsis { position: relative; display: inline-flex; @@ -3191,6 +3201,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 +3210,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 +3218,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..31caa8b1d 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) @@ -1155,20 +1156,21 @@ function StreamingMessageGroup({ } /** - * The ellipsis/right-click menu for a message: everything that used to be - * a persistent inline affordance (reply-in-thread) plus copy and pin, all - * in one place so the two triggers (the hover toolbar's ellipsis button and - * a right-click anywhere on the message) always offer the same actions. + * The ellipsis/right-click menu for a message: reply-in-thread, copy, pin, + * and Edit (own prompts) in one place so the hover ellipsis and a + * right-click always offer the same actions. */ 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 +1189,17 @@ function buildMessageMenu({ ); } + if (onEditMessage !== undefined) { + entries.push( + contextMenuItem({ + id: "edit-message", + label: CHAT_STRINGS.editMessageAction, + icon: