fix(desktop): show side-conversation message + progress immediately - #4655
Conversation
68842ed to
83079c8
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Items 1 and 2 solve a real gap and mostly solve it the right way. The main conversation has had both mechanisms for a while, addTransientMessage plus reconcileTransientMessages for the optimistic bubble and useDelayedFlag(turnActive, RUNNING_STATUS_DELAY_MS) for the running line, and the side chat had neither: it passed runningStatus={companion.processing}, and processing is only true in the pre-first-token window, which is exactly what #4654 describes. Choosing turnId as the optimistic id is right and not a guess, since runtime-host-session-execution-ipc-main.ts:353 sets const messageId = turnId.
One thing to fix before merge
Moving setPendingAdmission ahead of ensureFork makes the Stop button appear before there is anything to stop. streaming is hasPendingAdmission || activeTurnStreaming, so the Composer renders Stop the instant the admission is armed, but stop() opens with const id = companionIdRef.current; if (!id || …) return; and companionIdRef is set inside commitFork. On the first question in a side conversation, the whole branchFromTurn plus subscribe round trip is a window where the button is visible and pressing it does nothing at all, with no feedback. On a large session that is hundreds of milliseconds. Before this change the admission was armed in onBeforeSend, after the fork, so the button did not exist yet. Your own e2e comment records the button appearing instantly, without noting that it is inert at that moment.
quote-companion-core.ts's comment states the intended contract: "A pending admission remains stoppable before it owns a Turn." That still holds once the fork exists; it is only the pre-fork window that now breaks it.
The smaller fix is to leave the admission where it was and get the running-status line from what you actually added: useDelayedFlag(companion.streaming || companion.transientMessages.length > 0, …) lights the indicator during the same window without moving the admission, so the pre-fork window stays covered by the existing submitLockRef and Stop never appears before it works. If you would rather keep the early admission, stop() needs to record a cancel intent that send honors after ensureFork returns, by way of abandonOptimisticSend.
Smaller, on the same two items
durableMessageIds is built from the unfiltered allMessages, while the rendered messages additionally require ownTurnIdsRef.has(turnId). When a send returns outcome_unknown and the admission has not bound a turn yet, a settled read can merge the durable user message into allMessages, which retires the transient bubble, while the durable twin is still filtered out of the render. The question disappears entirely until reconcileUnknownAdmission binds and it heals. Building durableMessageIds from messages instead is one identifier and strictly better: only a durable twin that will actually render should be allowed to retire the transient one.
useDelayedFlag and the literal 200 are now a second copy of the main chat's running-status timing. Your reason is sound, both originals are in legacyRendererFiles and a features import would fail the architecture gate, but the result is two authorities for one delay and a number that will drift the first time someone tunes it, plus the fake-timer tests behind createDelayedFlag no longer cover the side chat. Lifting createDelayedFlag and useDelayedFlag somewhere both sides can import, @maka/ui being the obvious candidate since this file already imports TransientUserMessageProjection and useSessionSettingIntent from it, would let the main chat import it too and take two entries off the architecture ledger.
The third new test, the working state rides the whole active turn, not just the pre-first-token wait, does not test that. It asserts on data-streaming, data-live-turn-id and data-live-text, all derived from deriveCompanionComposerState, which this PR does not change, and the probe never renders QuoteCompanionPanel, where the actual change lives (runningStatus={companion.processing} becoming the delayed flag). It passes on the old code once the two transient props are removed. The other two new cases are genuine and do fail without the change.
Relatedly, the body says "The side-chat companion hook has no renderer unit-test harness in this repo." It does, and you used it: quote-companion-retry.test.ts is on main with a linkedom plus react-dom/client harness around useQuoteCompanion, and this PR modifies it rather than adding it. Living under src/main/__tests__ is the existing convention for renderer tests here, not a misplacement. With that corrected, Tests cover the change and fail without it can be checked for item 1.
The trivial one: result.status === 'pending' || result.steered ? result.messageId : turnId has three branches that all evaluate to turnId, since main sets messageId = turnId.
Item 3 belongs in its own PR, and needs a decision first
The per-session collapse is correct as written. useLayoutEffect flushes the dispatch before paint so there is no flash on switch, the one-shot guard cannot be missed because the recorder's dependencies always include the sync's, and folding only affects rendering, so no tool is unmounted and no tool state crosses sessions. The new test does go through the production hook and does fail on the old code.
What it does not do is remove the thing it replaces. rightCollapsed in workbar-layout.ts:47 is the reducer's collapse authority and it is persisted under maka-session-workbar-collapsed-v1. The new mirror is an in-memory Map, so after a restart it is empty, the sync effect reads ?? true, and the persisted false is overwritten before it can be used. From then on that storage key is unreachable but still written on every session switch. A preference that survived restart is now process-scoped, the PR does not say so, and the superseded path is left in place being overwritten. The cost to the user is one click per launch, so this is not severe, but it is a duplicate authority where the old one should have been deleted or moved.
The key belongs to the reducer. useWorkbarLayoutState is called by the controller, which knows activeSessionId; pass it in, hold collapsedBySession, derive rightCollapsed from it, and the Map, the guard ref, the two effects and the awkwardness of a reducer writing a fact it does not know the key for all disappear together, along with the unbounded Map (nothing evicts a deleted session today, though authoritativeSessionIds is right there and the same controller already uses it to clean up terminals) and the case where an expand issued in the same commit as the activation is silently swallowed by ?? true.
Which is why I would rather see this as its own PR: it is the one item of the three that needs a product decision before code, namely whether per-session collapse persists across restart. Either answer is fine, but the old key has to go with it. Items 1 and 2 have no such question and their value is unambiguous; bundling means the clear half waits on the undecided half, and a revert or a bisect cannot separate a workbar layout authority change from a side-chat rendering change.
Worth noting that "a new conversation starts clean" only holds for the collapse bit. The panel topology is still app-global and persisted under maka-session-workbar-panels-v3, with session-private tabs projected out, so B still opens showing whatever global tabs A left. Two ownership rules on one panel, and the body does not mention the split.
Value
Items 1 and 2 should exist. The gap is real, it fires every time someone uses the side chat, and the main conversation already paid for both mechanisms, which settles that the team considers it worth solving. The panel's own header comment says it renders with the same surface as the main conversation, so parity is the stated intent, not my preference. There is no case for closing them.
Item 3's evidence is the weakest of the three and its shape is the one I would push back on: it adds a Map, a guard and a bidirectional sync, three concepts, to express something the reducer could hold with one key, and it silently downgrades a persisted preference without deleting what it replaced. That is worth redoing rather than closing.
Manual checks nothing here covers: first question in a side chat, does the bubble appear immediately and does Stop respond during fork creation; second and third questions, does the running line appear before the first token with its timer; a fast answer should not flash it; and on a large session with a slow settled read, check the order of the bubble and the spinner, since chat-view.tsx:809 attaches the running status to the previous turn when no new tail turn exists yet. For item 3 if it stays: expand in A, quit, relaunch, open A.
Evidence boundary: read at 83079c81 against main cd4aa3d8; the id equality, the settle timeout, the architecture gate's legacy list, the reducer's persistence and the Stop precondition verified from source; no build, no test run, no Electron launch, so the dead Stop window and the restart behavior are traced rather than observed, and I did not verify the three new tests red on main.
AI-assisted review: drafted with Maka. I verified the admission ordering against stop()'s precondition and the collapse persistence path myself.
简体中文
第 1、2 项解决的是真问题,做法基本也对。主会话早就有这两套机制,乐观气泡是 addTransientMessage 加 reconcileTransientMessages,运行状态行是 useDelayedFlag(turnActive, RUNNING_STATUS_DELAY_MS),而侧栏两个都没有:它传的是 runningStatus={companion.processing},而 processing 只在首 token 之前那一小段为真,正好就是 #4654 描述的现象。乐观气泡取 turnId 当 id 是对的,也不是猜的,runtime-host-session-execution-ipc-main.ts:353 写着 const messageId = turnId。
合并前要改的一处
把 setPendingAdmission 提到 ensureFork 之前,会让「停止」按钮在还没有东西可停的时候就出现。streaming 是 hasPendingAdmission || activeTurnStreaming,所以 admission 一 arm,Composer 立刻渲染出停止按钮;但 stop() 的第一行是 const id = companionIdRef.current; if (!id || …) return;,而 companionIdRef 是在 commitFork 里才设的。侧栏第一次提问时,整个 branchFromTurn 加订阅的往返都是一个「按钮可见、按下去完全没反应、也没有任何反馈」的窗口。大会话上这是几百毫秒。改动前 admission 是在 onBeforeSend、也就是 fork 之后才 arm 的,那时按钮还不存在。你自己在 e2e 注释里记下了按钮瞬间出现这个现象,只是没注意到它此刻是空操作。
quote-companion-core.ts 的注释写明了意图:「A pending admission remains stoppable before it owns a Turn」。fork 存在之后这条仍然成立,破掉的只是 fork 之前那个窗口。
更小的修法是让 admission 留在原位,运行状态行改用你这次真正加进去的东西:useDelayedFlag(companion.streaming || companion.transientMessages.length > 0, …) 能在同一个窗口点亮指示器,不必移动 admission,于是 fork 之前那段仍由已有的 submitLockRef 覆盖,停止按钮也不会在它能工作之前出现。如果你更想保留提前 arm,那 stop() 需要记一个取消意图,让 send 在 ensureFork 返回后经 abandonOptimisticSend 兑现它。
同两项里更小的几条
durableMessageIds 建在未过滤的 allMessages 上,而真正渲染的 messages 还额外要求 ownTurnIdsRef.has(turnId)。当 send 返回 outcome_unknown、admission 还没绑定 turn 时,一次 settled read 可能已经把 durable 用户消息并进 allMessages,于是 transient 气泡被撤下,而 durable 那份还被渲染过滤掉。用户刚发的问题会整个消失,直到 reconcileUnknownAdmission 绑定后自愈。把 durableMessageIds 改建在 messages 上,一个标识符的改动,而且严格更优:只有真的会渲染出来的 durable 双胞胎才有资格顶掉 transient。
useDelayedFlag 和字面量 200 现在是主会话那套时序的第二份拷贝。你的理由站得住,两个原件都在 legacyRendererFiles 里,features 区导入会被架构闸挡下;但结果是同一个延迟有了两个权威,那个数字会在第一次有人调它的时候漂移,而且 createDelayedFlag 背后的假定时器测试不再覆盖侧栏。把 createDelayedFlag 和 useDelayedFlag 提到两侧都能导入的地方,@maka/ui 是现成的候选(这个文件已经从它导入 TransientUserMessageProjection 和 useSessionSettingIntent),主会话也改成导入,还能顺手让架构债少两项。
第三个新测试 the working state rides the whole active turn, not just the pre-first-token wait 并没有测这件事。它断言的全是 data-streaming、data-live-turn-id、data-live-text,都由 deriveCompanionComposerState 决定,而这个 PR 没有改它;probe 也从不渲染 QuoteCompanionPanel,而真正改动的那行在那里(runningStatus={companion.processing} 换成延迟标志)。把两个 transient 属性去掉,它在旧代码上照样通过。另外两个新用例是真的,没有这次改动确实会红。
顺带,正文说「The side-chat companion hook has no renderer unit-test harness in this repo」。有,而且你用了:quote-companion-retry.test.ts 在 main 上就存在,是一个用 linkedom 加 react-dom/client 挂 useQuoteCompanion 的测试台,这个 PR 是改它而不是新增。放在 src/main/__tests__ 是仓库里 renderer 测试的既有约定,不是放错了。这句改掉之后,第 1 项的「Tests cover the change and fail without it」是可以勾的。
小事一桩:result.status === 'pending' || result.steered ? result.messageId : turnId 三个分支的值都是 turnId,因为主进程设了 messageId = turnId。
第 3 项该单独成 PR,而且要先做个决定
按会话折叠这段本身写得是对的。useLayoutEffect 在 paint 之前 flush dispatch,所以切换时不会闪;一次性守卫不会漏消费,因为 recorder 的依赖必然包含 sync 的;折叠只影响渲染,所以不会卸载工具,也不会让工具状态跨会话。新测试确实穿过生产 hook,在旧代码上确实会红。
它没做的是删掉被它替代的东西。workbar-layout.ts:47 的 rightCollapsed 是 reducer 的折叠权威,而且持久化在 maka-session-workbar-collapsed-v1。新的镜像是内存 Map,重启后为空,sync effect 读到 ?? true,于是持久化的 false 在被用到之前就被覆盖掉。从那以后这个存储键不可达,却仍然在每次会话切换时被写。一个原本跨重启的偏好变成了进程内偏好,PR 正文没有说,被取代的旧路径也留在原地被覆写。对用户的代价是每次启动多点一下,所以不严重,但这是一处本该删掉或搬走旧权威的地方,现在成了两个权威。
这个 key 属于 reducer。useWorkbarLayoutState 由 controller 调用,而 controller 知道 activeSessionId;把它传进去,state 里放 collapsedBySession,rightCollapsed 从中派生,那么 Map、守卫 ref、两个 effect、以及「reducer 在写一个自己不知道 key 的事实」这层别扭会一起消失。顺带解决的还有无界 Map(今天没有任何东西回收被删除的会话,而 authoritativeSessionIds 就在手边,同一个 controller 已经用它清理 terminal),以及「和会话激活在同一 commit 内发出的展开会被 ?? true 静默吃掉」这个情况。
所以我更希望它单独成 PR:三项里只有它需要先定产品语义再写代码,也就是按会话的折叠要不要跨重启保留。哪个答案都行,但旧的存储键得跟着走。第 1、2 项没有这种问题,价值也明确;捆在一起意味着清楚的那一半要等未定的那一半,而且一次 revert 或一次 bisect 没法把 workbar 布局权威的改动和侧栏渲染的改动分开。
另外「新会话干净开始」只对折叠位成立。面板拓扑仍然是应用级且持久化的(maka-session-workbar-panels-v3),只是会话私有的 tab 被投影掉了,所以 B 展开后看到的还是 A 留下的全局 tab。同一个面板上两套归属规则,正文没有提这个取舍。
价值
第 1、2 项该存在。这个缺口是真的,每次用侧栏都会撞到,而主会话早就为同一件事付了两套机制,说明团队已经认定它值得解决。面板自己的头部注释就写着它「renders with the same surface as the main conversation」,所以对齐是既定意图,不是我的偏好。没有关掉它们的理由。
第 3 项的证据是三项里最弱的,形状也是我要反对的那一个:它加了一个 Map、一个守卫和一套双向同步,三个概念,去表达 reducer 用一个 key 就能持有的东西,同时静默降级了一个持久化偏好,还没删掉被它替代的部分。这值得重做,而不是关掉。
|
Severities for the review above, which I should have included with it. P2 — the Stop button is inert during the first send. Normal path, introduced by this change: moving P2 — a persisted preference is silently downgraded and the path it replaces is left in place. Normal path: the mirror is in-memory, so after a restart it is empty, the sync effect reads P3 — the optimistic bubble can vanish entirely for a moment. Reasonable failure path, self-healing: P3 — P3 — the third new test does not cover item 2. It asserts on values derived from P3 — the unbounded per-session Map (nothing evicts a deleted session, though No finding on the id choice for the optimistic bubble, the settle timeout and its cleanup, the collapse sync's flush ordering or guard, or tool mounting across sessions. No P0 and no P1. The first P2 is what I would fix before merge; the second is the reason I would rather see item 3 as its own PR, since it needs a decision on whether per-session collapse persists. 简体中文上面那份 review 的分级,应该跟着一起给的。 P2 —— 首次发送期间停止按钮是哑的。 正常路径,本次改动引入:把 P2 —— 一个持久化偏好被静默降级,而被它替代的路径留在原地。 正常路径:镜像是内存态,重启后为空,sync effect 读到 P3 —— 乐观气泡可能短暂整个消失。 合理故障路径,可自愈: P3 —— P3 —— 第三个新测试没有覆盖第 2 项。 它断言的值都由 P3 —— 无界的按会话 Map(没有任何东西回收被删除的会话,而 无发现:乐观气泡的 id 选择、settle 超时及其清理、折叠同步的 flush 顺序与守卫、跨会话的工具挂载。 没有 P0、没有 P1。第一条 P2 是我希望合并前修的;第二条 P2 正是我更希望第 3 项单独成 PR 的原因,它需要先定「按会话折叠要不要跨重启保留」。 |
Astro-Han
left a comment
There was a problem hiding this comment.
Putting the local ones inline with their grades, so it is clear which is worth fixing before merge. The reasoning is in my review above.
83079c8 to
5232c75
Compare
|
Thanks — all fair. Addressed at
|
3ab7bc2 to
778f51d
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
The optimistic row is the right shape, and I checked the part that usually goes wrong with optimistic inserts: it is not a second source of truth. The id is the turnId, and the send handler sets const messageId = turnId (runtime-host-session-execution-ipc-main.ts:353), so the optimistic row and its durable twin are one identity rather than two. Retirement is keyed on messages, already filtered by ownTurnIdsRef, so only a twin that would actually render can displace it. Runtime Host's authority is untouched — the row takes no admission, binds no turn, persists nothing.
I rebuilt the branch and ran the two new files (42 pass), then ablated: reverting only packages/ui/src/chat-view.tsx to base turns renders the optimistic bubble and running status before a session exists red — the markup falls back to empty-state-marker. The render-tier test is genuinely red-to-green.
I also drove the failure paths myself, since they are what an optimistic insert usually leaks on. send() returning {ok:false} and send() throwing both return data-transient-count to 0. performCompanionTurn with a non-empty existingForkId can only fail after onBeforeSend, so the pendingAdmissionRef !== admission early return in releaseAdmission cannot orphan a bubble. Deleting or archiving the active session does not leave a stray bubble either — every setActiveId(undefined) is followed by setMessages([]), and setMessages projects transients to [] when activeIdRef.current is empty.
Both points from the previous round are closed, and the second one by the smaller of the two options: streaming in deriveCompanionComposerState is hasPendingAdmission || activeTurnStreaming and excludes transients, so there is no stop button with nothing to stop.
No P0–P2. Four notes.
Rebase: drop one hunk, and fix the body
The only conflict is apps/desktop/e2e/slash-command-menu.spec.ts, and it is benign: #4752 (3f4ac8c9dd) deleted dispatches /side instead of steering it into a running turn entirely, sinking it into three assertions on resolveFollowUpModeAtSubmit. This branch edits those same six lines. The resolution is to drop the hunk — the other five files merge clean.
That does reach the PR body, though: the e2e verification line points at a test that no longer exists on main, so it needs rewriting rather than re-running.
[P3] A second authority for one delay, in a PR that is already touching the package that could hold it
quote-companion-panel.tsx:60-79 adds a feature-local useDelayedFlag and RUNNING_STATUS_DELAY_MS = 200. Both already exist: apps/desktop/src/renderer/use-delayed-flag.ts and model-wait-state.ts:50.
The architecture-gate reason for not importing them is real. What changed is the cost: this revision already edits packages/ui, so promoting createDelayedFlag / useDelayedFlag into @maka/ui and pointing the main session at it is two more files in this diff rather than a separate PR. That leaves one number with one owner, removes two entries from the architecture debt ledger, and puts the existing fake-timer tests for createDelayedFlag back in front of the companion path — which currently has none for the delay.
[P3] The render-tier test is in the wrong package
apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts asserts the behaviour of packages/ui/src/chat-view.tsx. packages/ui has its own test:dist and a dozen renderToStaticMarkup tests of exactly this shape (chat-turn-answer-identity.test.tsx, composer-send-toggle.test.tsx, session-context-layer-goal.test.tsx). As placed, a future change to packages/ui alone will not turn its own suite red for this contract.
Moving it to packages/ui/src/__tests__/chat-view-optimistic-render.test.tsx is a file move plus the existing SSR harness. The two hook cases in quote-companion-retry.test.ts are correctly placed and should stay.
[P3] The second question onward is only half covered, and the other half rides a 480 ms deadline
On a follow-up send, onBeforeSend arms the live turn, so tailTurnId (chat-view.tsx:439) names a turn that is not yet in turns. The tail injection loop cannot find it, and the #642 fallback at chat-view.tsx:876 requires !tailTurnId — so for that window the running-status row renders nowhere.
readSettledMessages(forkId, { requiredAssistantMessageId: turnId }) is what closes it, by materialising the turn quickly. But session-message-settlement.ts:24 gives that a 480 ms deadline and returns the snapshot anyway on timeout, so on a slow settled read or a large transcript the second question still shows no progress indicator — the symptom #4654 opens with.
Widening the fallback from streamingActive && !tailTurnId to streamingActive && !turns.some((turn) => turn.turnId === tailTurnId) would let the bare turn cover the un-materialised window too. Read from source, not observed — worth confirming against a large transcript before acting on it.
[P3] Smaller notes
One frame after setActiveId(undefined), activeSession is already undefined but useDelayedFlag's visible has not been cleared by its effect, so the new hasOptimisticContent branch can paint a bare "thinking" turn instead of the onboarding hero. Before this change the empty branch ignored runningStatus, so the window is new. One frame, self-correcting — flagged only because it is newly opened.
And 74 of the 183 new production lines are comments, with "admission is armed late so the stop button does not appear first" written out in full three times (use-quote-companion.ts:869-881, quote-companion-panel.tsx:194-202, chat-view.tsx:612-617). Keeping the one nearest the decision — in send() — and pointing at it from the other two would read the same.
Nothing here blocks. Once it is rebased I would take P3-1 and P3-2 in this PR, since both are cheap now and one of them removes an authority, and leave the rest.
The side conversation (quote-companion side chat) gave no immediate feedback on send: it rendered the user's message and the running-status line only after the turn settled, and — because it forks lazily — nothing at all during the first send's fork round trip. - Render the user's message optimistically on send (transientMessages), matching the main conversation, instead of only after the turn completes. The bubble is armed before the fork exists, so a cold first send is not blank. - Render that optimistic content even before a session exists: ChatView's no-activeSession branch now shows transientMessages + the running-status line, so the first question and progress cue appear during the lazy fork creation (they were previously dropped until the fork committed). - Drive the running-status line from `streaming || transientMessages.length > 0` with the same rising-edge delay as the main chat. The admission (and the Composer's Stop button) is armed only in onBeforeSend, once the fork exists and stop() can act on it — so Stop never appears while it would be a no-op. - Wait for the just-sent user message to be durable before the settled read so the turn materializes promptly. The Host mints the message id from the turn id, so one identity gates every path. Per-session workbar collapse (item 3 of apache#4654) is deferred to its own issue (apache#4693): it needs a decision on whether the collapse preference persists across restart, and should hold the session key in the layout reducer. Refs apache#4654 Generated-by: Claude Code
778f51d to
7e14fa3
Compare
Summary
Two parity/UX fixes for the side conversation (quote-companion side chat) in the Desktop renderer. The main conversation already behaves this way; this brings the side conversation in line.
transientMessages, the same mechanism the main chat uses) instead of only after the turn completes. Because the fork is created lazily, there is no session yet on the first send, soChatView's no-activeSessionbranch now also renderstransientMessages(and the running-status line) — otherwise the first question stayed invisible for the whole fork round trip. It is reconciled away once the durable transcript echoes the same message id (matched against the renderedmessages, so anoutcome_unknownsettle can't briefly blink the bubble away).streaming || transientMessages.length > 0with the same rising-edge delay as the main chat'sshowRunningStatus, so it rises in the pre-fork window and rides the whole active turn — not just the brief pre-first-tokenprocessingwindow. The admission (and therefore the Composer's Stop button) is armed only inonBeforeSend, once the fork exists andstop()can act on it, so Stop never appears while it would be a no-op. The post-send settled read waits for the just-sent user message so the turn materializes promptly.Deferred to its own issue (#4693): per-session workbar collapse (item 3 of #4654). It needs a product decision — whether the collapse preference persists across restart — and should hold the session key in the layout reducer rather than mirror it in an in-memory map (which would otherwise duplicate, and silently downgrade, the persisted
maka-session-workbar-collapsed-v1authority). Kept out so this side-chat rendering change can land independently.Refs #4654 (items 1 & 2; item 3 tracked in #4693)
Verification
Local (all pass):
tsc -p tsconfig.renderer.json --noEmit/tsc -p tsconfig.main.json --noEmit, plus@maka/uitscbuild — 0 errors.biome checkon the changed files — clean.check:architecture— renderer snapshot + monotonic-debt ratchet pass.node --test dist/main/__tests__/…):chat-view-optimistic-render.test.tsx— SSR-rendersChatViewwith noactiveSessionand asserts the optimistic bubble and the running-status line appear (and that neither appears when there is no bubble/running turn). Verified it fails on the pre-fixChatView(empty branch rendered only the hero) and passes after — this is the render-layer proof the earlier hook-only tests lacked.quote-companion-retry.test.ts— the two new cases: a first send shows the question bubble immediately but arms Stop only once the fork exists (bubble on screen whilestreamingis still false during fork creation; fails onmain), and a failed first send retires the optimistic bubble without ever arming Stop.playwright test e2e/slash-command-menu.spec.ts e2e/session-workbar.spec.ts, against a build that resolves this branch's@maka/ui) — pass, includingdispatches /side …,a collapsed workbar never flashes during the first send, andSide Chat survives collapse ….Manual (dev app): the side conversation shows the submitted message immediately and the "working" indicator during the pre-first-token wait, matching the main conversation.
Note: this PR touches
@maka/ui(ChatView's no-session branch) in addition toapps/desktop, since that render path is where the first-send content was being dropped.AI use
Tool(s) and scope: Claude Code — investigation, implementation, and local verification of the change. The commit carries a
Generated-by: Claude Codetrailer.Checklist
Does this PR entail a change in behavior?