Skip to content

Hover-edit a previous prompt into the composer without forking - #417

Merged
TheGreatAxios merged 8 commits into
mainfrom
cl-7086-hover-edit-a-previous-prompt-into-the-composer-without
Aug 29, 2026
Merged

TheGreatAxios merged 8 commits into
mainfrom
cl-7086-hover-edit-a-previous-prompt-into-the-composer-without

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Hovering an own prompt shows Edit. Edit copies that text into the composer. Sending is a new message on the same timeline (no fork). Leftover composer state (slash command, mention, invite, attachments) is cleared.

Linear: CL-7086

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

greybeard — hold

Claim. Hover Edit replaces the current composer draft with an own prompt's text and sends as a new message on the same timeline. It does not fork, and it does not mutate the origin message.

setText is the right seam. ComposerHandle already exists so content from outside the composer tree can land in the draft (packages/chat-ui/src/composer.tsx:62-68). insertText splices at the caret (profile Mention, draft restore). Edit is a replace, not a splice — expanding that handle is the correct API. The host must not own slash/mention/invite/attachment state, and the timeline must not import the composer.

Leftover-draft clearing belongs in setText. Slash, mention, pending invites, attachments, preparing, and in-flight FileReader commits are composer-private (packages/chat-ui/src/composer.tsx:413-427). Bumping attachGenerationRef is the owning layer for "old attachment reads do not rejoin a replaced draft." Re-running syncComposerSuggestState on the new text is the same constraint: leftover slash must not steal Enter; an active slash in the copied text itself is the composer's normal rule.

Do not reuse fork-thread. forkMessage (packages/chat-ui/src/use-thread-navigation.ts:127-144) owns conversation topology and navigation. Reusing it would create a sub-thread, leave the current feed, and still not fill the composer. Reply/Fork stay on onOpenThread; Edit stays on onEditMessage. Two operations, two ports (packages/chat-ui/src/timeline.tsx:1250-1272).

Who-sees-Edit is the timeline's. ownEdit gates on isOwn, social chrome, and non-empty messageText (packages/chat-ui/src/timeline.tsx:1449-1457). The host always wires the port; the timeline drops it for everyone else. That matches pin/reaction/thread.

Not merge blockers. setText names a text field and implements replace-the-draft — document that contract on ComposerHandle so a later caller does not treat it as text-only. The host re-finds the item in messagesState.items (packages/chat-ui/src/chat-workspace.tsx:1549-1555) after the timeline already had it; identity-shaped like onOpenThread, but Edit only needs the text. Silent no-op if the lookup misses.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

neckbeard

Actually, I have reviewed this hover-edit PR and am ready to provide maximally annoying, pedantic nitpicks while completely missing the point. Everything should be rewritten in Rust. Also, have you considered blockchain?

Hygiene only. Receipts at path:line. I will not implement. I will not approve.

Peak Neckbeard

  1. packages/chat-ui/src/composer.tsx:371 / :413-414 — Well technically, in a chat UI, generation already means the model is talking. attachGenerationRef is an attachment-race epoch token that now increments from setText. Real engineers would call this attachEpoch (or ship a lock-free epoch in Rust and stop pretending useRef(0) is a memory-ordering primitive).

  2. packages/chat-ui/src/strings.ts:133 / packages/chat-ui/src/timeline.tsx:1102 / :1267 — The product copies text into a new draft and explicitly does not mutate or fork the original. The string is still "Edit", the menu id is edit-message, the class is chat-hover-edit. That is a naming lie. Slack called this "Edit with AI" and at least they were honest about the AI. Have you considered reusePromptAction: "Reuse"? Or, hear me out, storing the canonical prompt on a Solana program so "edit" is an on-chain amendment.

  3. packages/chat-ui/src/timeline.tsx:1454-1457 — ownEdit sits next to isOwn. One is a boolean. The other is a callback-or-undefined that happens to be the host function. This is the data / info / flag school of naming. ownEditHandler exists. So does editOwnPrompt. So does not naming a function like a boolean.

  4. packages/chat-ui/src/composer.tsx:67 vs :66 — insertText splices at the caret. setText replaces the draft, wipes mentions, slash, invites, attachments, errors, and in-flight file reads. The ComposerHandle comment at :62-64 says both "land in the active draft." That comment is now a lie by omission. replaceDraft would have been the honest verb. setText is what you name a React state setter in a tutorial.

Unbearable (leftover comments / docs that now lie)

  1. packages/chat-ui/test/message-hover-toolbar.test.tsx:1-5 — File banner still describes "add-reaction / reply-in-thread / ellipsis". The new describe is literally "hover Edit on own prompts". Stale header is a leftover comment.

  2. packages/chat-ui/src/timeline.tsx:1064-1068 — buildMessageMenu still claims the menu is "reply-in-thread plus copy and pin". You added Edit at :1099-1108 and did not update the comment. Comments describe the current code, or they are debris.

  3. packages/chat-ui/src/composer.tsx:602-608 — send() still says Discard returns text via ComposerHandle.insertText, "the same seam the profile card's Mention action uses." Hover-edit now uses setText. This comment is one method behind the PR that just landed.

  4. packages/chat-ui/src/timeline.tsx:921-925 — messageText was a private helper. It is now exported with zero docblock, while offersMessageSocialChrome at :928 immediately below has a novel. Also the "\n" join is an undocumented wire contract the host now imports (chat-workspace.tsx:1555).

  5. packages/chat-ui/src/timeline.tsx:1824-1825 — onOpenThread / onOpenArtifact / onFixConnection all have JSDoc. onEditMessage is a bare optional callback, same as if it were an accident. Under exactOptionalPropertyTypes this is fine (?: + spread at :2048). Under "did you document the new port" it is not.

  6. packages/chat-ui/src/composer.tsx:416-422 — setText does setMention(null); setSlash(null); and then syncComposerSuggestState(text, text.length), which writes those same fields again. The first two assignments are leftover caution from commit 2470afb5. React 18 will batch them. A reader will not.

Maddening (test hygiene)

  1. packages/chat-ui/test/chat-workspace.test.tsx:654-728 — stubFetchWithOwnPrompt is a near-carbon-copy of stubFetch at :53-109 plus a fork branch. The file already has a bespoke fork stub at :432. YAGNI, except you definitely needed a third fetch impersonator. Extend stubFetch or do not.

  2. packages/chat-ui/test/composer.test.tsx:31-48 already builds a ComposerHandle ref and throws it away. The four new tests (:413-429, :448-471, :503-529, :569-588) paste the mount. :503-529 also re-inlines mountWithMentions (:127). This is not a test suite, it is a photocopier.

  3. packages/chat-ui/test/composer.test.tsx:437-439 (and :479-481, :553-555, :619-621) — the same
    await new Promise<void>((resolve) => { requestAnimationFrame(() => resolve()); })
    appears four times. Have you considered a helper. Have you considered not worshipping rAF in a jsdom that does not paint.

  4. packages/chat-ui/test/composer.test.tsx:611-612 — await settle(); await settle(); with no comment. Two setTimeout(0)s is not a specification, it is superstition. If the file-read microtask needs two ticks, say so.

  5. packages/chat-ui/test/chat-workspace.test.tsx:749-756 —

    const edit = ownGroup?.querySelector(".chat-hover-edit") as HTMLButtonElement;
    expect(edit).not.toBeNull();
    

    Well technically, ownGroup?.querySelector(...) is undefined when ownGroup is null, and expect(undefined).not.toBeNull() passes. The as HTMLButtonElement then lies to tsc. Same cast, no expect, at message-hover-toolbar.test.tsx:209. The existing textarea() helper in composer.test.tsx:108-113 already throws on null or undefined. Copy that.

  6. packages/chat-ui/test/chat-workspace.test.tsx:754-756 — await act(async () => { edit.dispatchEvent(...); await sleep(30); }) when harness.settle() at :154 is already act(() => sleep(30)). Nested 30ms inside act plus a later send-click with another sleep(30) (:767-769). This test is a metronome.

  7. No test opens the ellipsis/edit-message menu entry you added at timeline.tsx:1099-1108. Hover button is covered. The "two triggers always offer the same actions" comment at :1067-1068 is now an untested invariant. Also no test that setText("/invite") re-opens slash — which, given :422, it will.

Insufferable (exactOptionalPropertyTypes and friends)

  1. packages/chat-ui/src/timeline.tsx:1170 uses onEditMessage?: (messageId: string) => void (JSX, spread at :1651). buildMessageMenu at :1080 uses onEditMessage: ((messageId: string) => void) | undefined and always passes the key (:1464). That split is actually the correct exactOptionalPropertyTypes pattern. I am only mentioning it so I can well-actually the next person who "simplifies" it to onEditMessage={ownEdit}. Do not.

  2. packages/chat-ui/src/chat-workspace.tsx:1549-1556 always passes onEditMessage={...} rather than the spread used for onOpenProfile two lines later (:1557). Fine — the function is always defined. The handler then finds messagesState.items by id (:1551-1553) after the timeline already had the item. O(n) on a click. Have you profiled this. (No.) Pass the text. Or a rope. Or SIMD.

  3. packages/chat-ui/src/composer.tsx:590-594 — the generation token gates setAttachments / setErrorMessage / setPreparing, then finally unconditionally calls resetFileInput(). Incomplete token discipline is a code smell. Also useImperativeHandle still lists [value] at :430 after adding a setText that does not read value. Pre-existing, now louder.

  4. packages/chat-ui/src/styles.css:3076-3119 — .chat-hover-edit was appended to four selector lists. .chat-reaction-add is nested under .chat-hover-toolbar; .chat-hover-edit is not. You cloned the pre-existing inconsistency instead of giving the four buttons one shared class. CSS-in-JS would never. (It would, actually. Worse.)

Recommendation

Do not rewrite in Rust (this time). Do delete the leftover comments, name the replace-draft seam like a replace-draft seam, stop photocopying mounts, and stop treating not.toBeNull() as a definedness check.

Also have you considered HTTP/3.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

critic — Hover Edit on the signed-in user's own prompt replaces the composer draft with that text; Send posts a new message on the same conversation and does not fork.

Blocking
None. Own-only gate, setText replace, leftover-draft clear, and POST-not-fork all hold.

Should-fix
packages/chat-ui/src/composer.tsx:413-422 — setText nulls slash/mention then calls syncComposerSuggestState(text, text.length). A copied prompt that is still an open token at end-of-text reopens the picker, so Enter does not send. Concrete input: own prompt "@researcher" or "please ask @researcher" (no trailing space). composer.test.tsx:448 pins leftover "/" but not this. Send click still posts.

File-for-later
packages/chat-ui/src/chat-workspace.tsx:1549-1555 — host re-finds messagesState.items by id after the row already had the item; a miss is a silent no-op.
packages/chat-ui/src/timeline.tsx:1099-1108 — ellipsis/right-click edit-message is untested (only .chat-hover-edit is clicked).
No thread-view proof that Edit then Send omits POST .../threads/fork and keeps the open thread's threadId.
No proof an in-flight FileReader after setText is dropped (composer.tsx:414, :585).

@TheGreatAxios
TheGreatAxios force-pushed the cl-7086-hover-edit-a-previous-prompt-into-the-composer-without branch from 050ad87 to dc0cbec Compare August 27, 2026 19:16
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Validation at dc0cbecf46f4943408c5e856eeffba64dfcc3e14: local bun run check passes; CI typecheck, lint, build-test, structural, and walking-skeleton all pass. Browser validation also passed hover Edit and resend in the same Myra thread.

@TheGreatAxios
TheGreatAxios force-pushed the cl-7086-hover-edit-a-previous-prompt-into-the-composer-without branch from 9ea7e59 to c249d42 Compare August 29, 2026 04:47
@TheGreatAxios
TheGreatAxios merged commit 8ec6819 into main Aug 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant