feat(widget): bouncing-dots loading that survives thread switches - #60
Conversation
|
The new per-conversation message state is a good improvement. However, methods like This can cause a problem if the user switches conversations while a request is still running. The request may start in conversation A, but later use conversation B from local storage. I suggest passing the conversation ID directly to every method: stream(conversationId, text)
send(conversationId, text)
loadHistory(conversationId)
loadUsage(conversationId)Local storage should only remember the last selected conversation. It should not decide which conversation an active request belongs to. I would fix this before merging. |
Review feedback on #60: stream(), send(), loadHistory() and loadUsage() each re-read the active conversation id from localStorage when they ran. A turn that started in thread A but finished after the user switched to B could retry, or refresh usage, against B. Storage now only remembers which thread was selected last; it never decides where an in-flight request goes. - Conversation methods take the id: send(id, text), stream(id, text), loadHistory(id), loadUsage(id). The app already captures the id at submit time for its per-conversation message map, so it passes that same id. - Usage moves into a per-conversation map alongside messages, so a late response cannot paint another thread's number. - Stale-conversation recovery (404 -> create a new one) now reports the replacement through `onReplaced`, and the app re-keys that thread's messages onto the id the turn actually ran under instead of leaving them stranded under the dead one. Test: a turn whose stream fails after the user switched threads retries on its own conversation, and the usage refresh follows it — both landed on the other thread before this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e3688f3 to
f529a92
Compare
eaaea03 to
0c6ed9f
Compare
|
Agreed — fixed in send(conversationId, text)
stream(conversationId, text)
loadHistory(conversationId)
loadUsage(conversationId)
Two follow-ons that fell out of it:
Regression test: a turn whose stream fails after the user switched threads retries on its own conversation, and the usage refresh follows it. I checked it fails against the previous code (the retry went to the other thread) before landing the fix. Rebased on top of the updated #58 and #59; whole stack now sits on |
|
Thanks for addressing the original thread-switching issue. Passing the conversation ID explicitly into However, I think there is still an issue in the stale-conversation recovery flow. When a stale conversation returns But the const cid = await conversation.ensureId();
for await (const event of conversation.stream(cid, text)) {
replaceEntry(cid, pending.id, entry);
}
void refreshUsage(cid);So after the request is retried against This can leave the response or usage stored under the old conversation while the UI is displaying the new one. I think the effective conversation ID used after recovery needs to be propagated back to the caller and then used consistently for:
Also, PR #60 currently depends on #59, so I would hold off on merging it until the authorization issue in #59 is resolved and the branch is rebased onto One additional improvement: the GitHub CI currently does not appear to run the widget typecheck or Playwright tests, so it would be good to add those checks to the workflow rather than relying only on locally reported results. |
Review feedback on #60: stream(), send(), loadHistory() and loadUsage() each re-read the active conversation id from localStorage when they ran. A turn that started in thread A but finished after the user switched to B could retry, or refresh usage, against B. Storage now only remembers which thread was selected last; it never decides where an in-flight request goes. - Conversation methods take the id: send(id, text), stream(id, text), loadHistory(id), loadUsage(id). The app already captures the id at submit time for its per-conversation message map, so it passes that same id. - Usage moves into a per-conversation map alongside messages, so a late response cannot paint another thread's number. - Stale-conversation recovery (404 -> create a new one) now reports the replacement through `onReplaced`, and the app re-keys that thread's messages onto the id the turn actually ran under instead of leaving them stranded under the dead one. Test: a turn whose stream fails after the user switched threads retries on its own conversation, and the usage refresh follows it — both landed on the other thread before this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60. When a conversation vanished mid-turn, replace() created a new one and the UI moved that thread's messages onto the fresh id — but submit() kept writing to the id it captured before the request. Streamed content, the non-streaming fallback and the usage refresh all landed under the dead conversation while the view showed the new one, so the answer never appeared and the dots never stopped. A fallback that 404'd again compounded it, stranding the entry a second time. The effective id now travels back to the caller: send() and stream() take a Retarget callback, and the turn keeps a `target` that starts as the id it began with and moves when recovery happens. Everything after that point — the pending entry, streamed content, the fallback answer, the usage refresh — follows `target`. sendWithoutStreaming() returns where it landed, so a fallback that recovers again is also accounted for. useConversation() loses its constructor-level onReplaced: identity of the turn is per-request, not per-hook, and one channel is enough. Test: an e2e turn whose conversation 404s mid-stream must render the recovered answer and refresh usage on the replacement. It fails on the previous code with the answer stranded in the dead conversation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60: CI ran only `make check`, so the widget's TypeScript and the Playwright suite were verified nowhere but on a contributor's machine. Adds a `widget` job: typecheck (widget + e2e), the node self-check, and Playwright on chromium. It also rebuilds widget.js and fails if the result differs from the committed bundle — that file is what the browser loads, so a stale one would let the browser tests pass against source that was never built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f529a92 to
d48f166
Compare
|
Good catch — and it was worse than the trace shows. If the non-streaming fallback also 404s, Fixed in
Worth saying why the existing stale-conversation test didn't catch this: its history request 404s at open, which clears storage before the turn, so that test creates a fresh conversation up front and never exercises mid-turn replacement. The new e2e test keeps the conversation alive through history loading and only 404s it once the turn is under way. I confirmed it fails on the previous code — the answer stays stranded in the dead conversation and the dots never stop. On CI: you're right, and that was the most useful of the three points. Agreed on merge order — this stays behind #59. Once that lands I'll rebase this branch onto |
|
Thanks for fixing the stale-conversation retargeting flow and for adding the widget CI job. The effective conversation ID now correctly follows the stream, fallback, and usage refresh, and the new browser coverage is valuable. However, I think there is still a race condition involving the selected conversation in The current replacement flow removes the stored conversation ID and then creates a fresh conversation: removeStoredConversationId(endpoint, userId);
const fresh = await startConversation();
Consider this sequence:
setActiveId((current) => (current === staleId ? freshId : current));But The next submission calls: const cid = await conversation.ensureId();Since Recovery for an inactive background conversation should not replace the user’s currently selected conversation. I suggest only updating storage when the stale conversation is still the selected one: const freshId = await client.createConversation();
if (peekId() === staleId) {
setStoredConversationId(endpoint, userId, freshId);
}
onReplaced?.(staleId, freshId);
return freshId;It would also be safer for submission to use the currently displayed const cid = activeId || await conversation.ensureId();A regression test should cover this sequence:
The current tests verify that the original in-flight request follows its replacement, but they do not verify that background recovery cannot change the destination of the next user request. I would hold off on merging until this selected-conversation race is addressed. |
Review feedback on #60: stream(), send(), loadHistory() and loadUsage() each re-read the active conversation id from localStorage when they ran. A turn that started in thread A but finished after the user switched to B could retry, or refresh usage, against B. Storage now only remembers which thread was selected last; it never decides where an in-flight request goes. - Conversation methods take the id: send(id, text), stream(id, text), loadHistory(id), loadUsage(id). The app already captures the id at submit time for its per-conversation message map, so it passes that same id. - Usage moves into a per-conversation map alongside messages, so a late response cannot paint another thread's number. - Stale-conversation recovery (404 -> create a new one) now reports the replacement through `onReplaced`, and the app re-keys that thread's messages onto the id the turn actually ran under instead of leaving them stranded under the dead one. Test: a turn whose stream fails after the user switched threads retries on its own conversation, and the usage refresh follows it — both landed on the other thread before this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60. When a conversation vanished mid-turn, replace() created a new one and the UI moved that thread's messages onto the fresh id — but submit() kept writing to the id it captured before the request. Streamed content, the non-streaming fallback and the usage refresh all landed under the dead conversation while the view showed the new one, so the answer never appeared and the dots never stopped. A fallback that 404'd again compounded it, stranding the entry a second time. The effective id now travels back to the caller: send() and stream() take a Retarget callback, and the turn keeps a `target` that starts as the id it began with and moves when recovery happens. Everything after that point — the pending entry, streamed content, the fallback answer, the usage refresh — follows `target`. sendWithoutStreaming() returns where it landed, so a fallback that recovers again is also accounted for. useConversation() loses its constructor-level onReplaced: identity of the turn is per-request, not per-hook, and one channel is enough. Test: an e2e turn whose conversation 404s mid-stream must render the recovered answer and refresh usage on the replacement. It fails on the previous code with the answer stranded in the dead conversation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60: CI ran only `make check`, so the widget's TypeScript and the Playwright suite were verified nowhere but on a contributor's machine. Adds a `widget` job: typecheck (widget + e2e), the node self-check, and Playwright on chromium. It also rebuilds widget.js and fails if the result differs from the committed bundle — that file is what the browser loads, so a stale one would let the browser tests pass against source that was never built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60. replace() cleared the stored conversation and let startConversation() store the replacement, so a turn recovering in the background rewrote the selection: with A in flight and the user reading B, A's 404 left storage pointing at A2, and the next message — sent while B was on screen — went to A2. replace() no longer touches storage at all. It creates the conversation and reports it through onReplaced; the component decides, because only it knows which thread is displayed. adoptConversation() moves the messages either way, but selects the replacement only when the stale conversation is the one on screen. The decision reads a ref rather than state: recovery runs from async turn code that outlives the render it started in, and a state updater cannot be used because selecting also writes storage, which is a side effect. Every setActiveId now goes through selectThread, so the ref cannot drift. submit() reads the same ref, falling back to storage only before anything has been selected. Tests: two e2e cases — recovery while viewing another thread, and while sitting in a fresh unsaved chat — assert the selection stays put and the next message goes where the user is looking. Both fail on the previous code, sending "fresh start" to conv-reborn. The foreground case now asserts the inverse, that recovery of the visible thread does move the selection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d48f166 to
a10fb7b
Compare
|
Confirmed and fixed in I went slightly further than your suggestion. Rather than guarding the storage write inside const replace = useCallback(
async (staleId: string, onReplaced?: Retarget) => {
// Deliberately does not touch storage. Recovery can run for a turn the
// user has since navigated away from, and only the caller knows which
// thread is on screen — it selects the replacement via `onReplaced`.
const fresh = await client.createConversation();
onReplaced?.(staleId, fresh);
return fresh;
},
[client],
);The component then decides, because it is the thing that knows what is displayed: const adoptConversation = useCallback((staleId: string, freshId: string) => {
setEntriesById(({ [staleId]: moved = [], ...rest }) => ({ ...rest, [freshId]: moved }));
if (activeIdRef.current !== staleId) return;
selectThread(freshId);
conversation.switchTo(freshId);
}, [conversation, selectThread]);Your On why a ref and not state: the old code read the current selection through Tests, following your sequence:
I also added the inverse assertion to the existing foreground test: recovery of the thread on screen must still move the selection. That direction is now conditional, so without pinning it a future change could quietly stop selecting anything. CI is green on both jobs. This branch is rebased onto the current #59 head; once #59 lands I will rebase it onto |
ee9c003 to
506d21d
Compare
Show a bouncing-dots indicator while the assistant has no answer text yet, and keep it visible when the user leaves a streaming thread and returns. Messages now live in a per-conversation map keyed by the id captured at submit time, so switching threads only swaps the view — the in-flight stream keeps writing to its own bucket. `typing` means "stream in flight" (set at submit, cleared at completion); reduceStreamEvent is reduced to content accumulation only, fixing dots that could otherwise linger on an empty final answer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review feedback on #60: stream(), send(), loadHistory() and loadUsage() each re-read the active conversation id from localStorage when they ran. A turn that started in thread A but finished after the user switched to B could retry, or refresh usage, against B. Storage now only remembers which thread was selected last; it never decides where an in-flight request goes. - Conversation methods take the id: send(id, text), stream(id, text), loadHistory(id), loadUsage(id). The app already captures the id at submit time for its per-conversation message map, so it passes that same id. - Usage moves into a per-conversation map alongside messages, so a late response cannot paint another thread's number. - Stale-conversation recovery (404 -> create a new one) now reports the replacement through `onReplaced`, and the app re-keys that thread's messages onto the id the turn actually ran under instead of leaving them stranded under the dead one. Test: a turn whose stream fails after the user switched threads retries on its own conversation, and the usage refresh follows it — both landed on the other thread before this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60. When a conversation vanished mid-turn, replace() created a new one and the UI moved that thread's messages onto the fresh id — but submit() kept writing to the id it captured before the request. Streamed content, the non-streaming fallback and the usage refresh all landed under the dead conversation while the view showed the new one, so the answer never appeared and the dots never stopped. A fallback that 404'd again compounded it, stranding the entry a second time. The effective id now travels back to the caller: send() and stream() take a Retarget callback, and the turn keeps a `target` that starts as the id it began with and moves when recovery happens. Everything after that point — the pending entry, streamed content, the fallback answer, the usage refresh — follows `target`. sendWithoutStreaming() returns where it landed, so a fallback that recovers again is also accounted for. useConversation() loses its constructor-level onReplaced: identity of the turn is per-request, not per-hook, and one channel is enough. Test: an e2e turn whose conversation 404s mid-stream must render the recovered answer and refresh usage on the replacement. It fails on the previous code with the answer stranded in the dead conversation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60: CI ran only `make check`, so the widget's TypeScript and the Playwright suite were verified nowhere but on a contributor's machine. Adds a `widget` job: typecheck (widget + e2e), the node self-check, and Playwright on chromium. It also rebuilds widget.js and fails if the result differs from the committed bundle — that file is what the browser loads, so a stale one would let the browser tests pass against source that was never built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #60. replace() cleared the stored conversation and let startConversation() store the replacement, so a turn recovering in the background rewrote the selection: with A in flight and the user reading B, A's 404 left storage pointing at A2, and the next message — sent while B was on screen — went to A2. replace() no longer touches storage at all. It creates the conversation and reports it through onReplaced; the component decides, because only it knows which thread is displayed. adoptConversation() moves the messages either way, but selects the replacement only when the stale conversation is the one on screen. The decision reads a ref rather than state: recovery runs from async turn code that outlives the render it started in, and a state updater cannot be used because selecting also writes storage, which is a side effect. Every setActiveId now goes through selectThread, so the ref cannot drift. submit() reads the same ref, falling back to storage only before anything has been selected. Tests: two e2e cases — recovery while viewing another thread, and while sitting in a fresh unsaved chat — assert the selection stays put and the next message goes where the user is looking. Both fail on the previous code, sending "fresh start" to conv-reborn. The foreground case now asserts the inverse, that recovery of the visible thread does move the selection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a10fb7b to
3fdbe7c
Compare
Stack 3/3 · base:
feat/widget-thread-list(#59)Shows a bouncing-dots indicator while the assistant has no answer text yet, and keeps it visible when the user leaves a streaming thread and returns. Messages and usage live in per-conversation maps keyed by the id captured at submit time, so switching threads only swaps the view — the in-flight stream keeps writing to its own bucket.
typingmeans "stream in flight" (set at submit, cleared at completion);reduceStreamEventis reduced to content accumulation only, fixing dots that could otherwise linger on an empty final answer.Every request now names its conversation (
send(id, text),stream(id, text),loadHistory(id),loadUsage(id)); localStorage only remembers which thread was selected last and never decides where an in-flight request lands (review feedback,f529a92).Verified: widget typecheck,
test:widget, and Playwright e2e (incl. switch-away-and-back and a mid-request thread-switch regression test) all green.🤖 Generated with Claude Code