Skip to content
11 changes: 11 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down
59 changes: 41 additions & 18 deletions packages/chat-ui/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -367,6 +368,7 @@ export const Composer = forwardRef<
const [focused, setFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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
Expand All @@ -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,
() => ({
Expand All @@ -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],
);
Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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();
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 16 additions & 3 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"] {
Expand All @@ -3171,6 +3180,7 @@

.chat-hover-toolbar .chat-reaction-add,
.chat-hover-reply,
.chat-hover-edit,
.chat-hover-ellipsis {
position: relative;
display: inline-flex;
Expand All @@ -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;
Expand All @@ -3199,13 +3210,15 @@

.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;
}

.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"] {
Expand Down
Loading
Loading