Hover-edit a previous prompt into the composer without forking - #417
TheGreatAxios merged 8 commits into
Conversation
TheGreatAxios
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
-
packages/chat-ui/src/composer.tsx:371/:413-414— Well technically, in a chat UI, generation already means the model is talking.attachGenerationRefis an attachment-race epoch token that now increments fromsetText. Real engineers would call thisattachEpoch(or ship a lock-free epoch in Rust and stop pretendinguseRef(0)is a memory-ordering primitive). -
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 isedit-message, the class ischat-hover-edit. That is a naming lie. Slack called this "Edit with AI" and at least they were honest about the AI. Have you consideredreusePromptAction: "Reuse"? Or, hear me out, storing the canonical prompt on a Solana program so "edit" is an on-chain amendment. -
packages/chat-ui/src/timeline.tsx:1454-1457—ownEditsits next toisOwn. One is a boolean. The other is a callback-or-undefined that happens to be the host function. This is thedata/info/flagschool of naming.ownEditHandlerexists. So doeseditOwnPrompt. So does not naming a function like a boolean. -
packages/chat-ui/src/composer.tsx:67vs:66—insertTextsplices at the caret.setTextreplaces the draft, wipes mentions, slash, invites, attachments, errors, and in-flight file reads. TheComposerHandlecomment at:62-64says both "land in the active draft." That comment is now a lie by omission.replaceDraftwould have been the honest verb.setTextis what you name a React state setter in a tutorial.
Unbearable (leftover comments / docs that now lie)
-
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. -
packages/chat-ui/src/timeline.tsx:1064-1068—buildMessageMenustill claims the menu is "reply-in-thread plus copy and pin". You added Edit at:1099-1108and did not update the comment. Comments describe the current code, or they are debris. -
packages/chat-ui/src/composer.tsx:602-608—send()still says Discard returns text viaComposerHandle.insertText, "the same seam the profile card's Mention action uses." Hover-edit now usessetText. This comment is one method behind the PR that just landed. -
packages/chat-ui/src/timeline.tsx:921-925—messageTextwas a private helper. It is now exported with zero docblock, whileoffersMessageSocialChromeat:928immediately below has a novel. Also the"\n"join is an undocumented wire contract the host now imports (chat-workspace.tsx:1555). -
packages/chat-ui/src/timeline.tsx:1824-1825—onOpenThread/onOpenArtifact/onFixConnectionall have JSDoc.onEditMessageis a bare optional callback, same as if it were an accident. UnderexactOptionalPropertyTypesthis is fine (?:+ spread at:2048). Under "did you document the new port" it is not. -
packages/chat-ui/src/composer.tsx:416-422—setTextdoessetMention(null); setSlash(null);and thensyncComposerSuggestState(text, text.length), which writes those same fields again. The first two assignments are leftover caution from commit2470afb5. React 18 will batch them. A reader will not.
Maddening (test hygiene)
-
packages/chat-ui/test/chat-workspace.test.tsx:654-728—stubFetchWithOwnPromptis a near-carbon-copy ofstubFetchat:53-109plus a fork branch. The file already has a bespoke fork stub at:432. YAGNI, except you definitely needed a third fetch impersonator. ExtendstubFetchor do not. -
packages/chat-ui/test/composer.test.tsx:31-48already builds aComposerHandleref and throws it away. The four new tests (:413-429,:448-471,:503-529,:569-588) paste the mount.:503-529also re-inlinesmountWithMentions(:127). This is not a test suite, it is a photocopier. -
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. -
packages/chat-ui/test/composer.test.tsx:611-612—await settle(); await settle();with no comment. TwosetTimeout(0)s is not a specification, it is superstition. If the file-read microtask needs two ticks, say so. -
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(...)isundefinedwhenownGroupis null, andexpect(undefined).not.toBeNull()passes. Theas HTMLButtonElementthen lies to tsc. Same cast, no expect, atmessage-hover-toolbar.test.tsx:209. The existingtextarea()helper incomposer.test.tsx:108-113already throws on null or undefined. Copy that. -
packages/chat-ui/test/chat-workspace.test.tsx:754-756—await act(async () => { edit.dispatchEvent(...); await sleep(30); })whenharness.settle()at:154is alreadyact(() => sleep(30)). Nested 30ms insideactplus a later send-click with anothersleep(30)(:767-769). This test is a metronome. -
No test opens the ellipsis/
edit-messagemenu entry you added attimeline.tsx:1099-1108. Hover button is covered. The "two triggers always offer the same actions" comment at:1067-1068is now an untested invariant. Also no test thatsetText("/invite")re-opens slash — which, given:422, it will.
Insufferable (exactOptionalPropertyTypes and friends)
-
packages/chat-ui/src/timeline.tsx:1170usesonEditMessage?: (messageId: string) => void(JSX, spread at:1651).buildMessageMenuat:1080usesonEditMessage: ((messageId: string) => void) | undefinedand always passes the key (:1464). That split is actually the correctexactOptionalPropertyTypespattern. I am only mentioning it so I can well-actually the next person who "simplifies" it toonEditMessage={ownEdit}. Do not. -
packages/chat-ui/src/chat-workspace.tsx:1549-1556always passesonEditMessage={...}rather than the spread used foronOpenProfiletwo lines later (:1557). Fine — the function is always defined. The handler thenfindsmessagesState.itemsby 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. -
packages/chat-ui/src/composer.tsx:590-594— the generation token gatessetAttachments/setErrorMessage/setPreparing, thenfinallyunconditionally callsresetFileInput(). Incomplete token discipline is a code smell. AlsouseImperativeHandlestill lists[value]at:430after adding asetTextthat does not readvalue. Pre-existing, now louder. -
packages/chat-ui/src/styles.css:3076-3119—.chat-hover-editwas appended to four selector lists..chat-reaction-addis nested under.chat-hover-toolbar;.chat-hover-editis 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
left a comment
There was a problem hiding this comment.
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).
050ad87 to
dc0cbec
Compare
|
Validation at |
Own rows already align right; the hover toolbar still pinned to the row top-right, so Edit sat on the prompt being revised. Mirror it to the inboard edge for own rows, matching artifact chips.
9ea7e59 to
c249d42
Compare
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