Skip to content

🐛 fix(client): keep each send and run on the chat it started in - #89

Merged
KakkoiDev merged 17 commits into
mainfrom
fm/useai-chatid
Oct 28, 2026
Merged

KakkoiDev merged 17 commits into
mainfrom
fm/useai-chatid

Conversation

@KakkoiDev

@KakkoiDev KakkoiDev commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

A chat switch while a send or run was in progress mixed two chats. handleSendMessage awaits attachment processing, the user-message save and forwardedPropsProvider before it dispatches the run, and the run read the client's live thread id, history and agent. A switch in that window sent the message on the other chat's thread, without its own history, with the agent picked later, and showed it in the other chat. A switch while a run streamed dropped its pending tool calls, so the server waited until the run was aborted.

✅ What this PR does

  • A send captures its chat id, history and agent before the first await. The run, forwardedPropsProvider({ chatId, chat }) and the attachment pipeline all use that capture, so the run's threadId and the chatId given to the provider cannot disagree.
  • A chat can be bound to an agent: chat.create({ agent }) or sendMessage(text, { newChat: true, agent }). Persisted by LocalStorageChatRepository. Unbound chats follow the global selection; an agent in forwarded props overrides both.
  • A run in flight keeps its tool calls and turn when the chat changes (setThreadId / clearConversation). Its tools still run and send results; an abort records { aborted: true } in the run's own chat.
  • chat.updateMetadata(metadata, overwrite, chatId): a file transformer passes context.chat.id so its write lands on the chat the file is sent from.
  • The "Processing file... N%" progress shows only in the chat the file is sent from. Before, switching chats while a send-time transformer ran showed its progress in the other chat. This leak predates this PR: it shows at the base commit 21bee9e too.
  • The "..." pending bubble and the "Thinking..." input placeholder show only in the chat the run belongs to. Before, every chat showed them while one chat's run was in flight. UseAIChatPanel gains an optional loadingInCurrentChat prop (defaults to loading).

✔️ Already correct, no change needed

  • Blocking chat switching during upload: not done. It freezes the user for the whole upload and does not fix the dropped tool calls or the agent routing.
  • No server change.

⚠️ Not in this PR, needs attention later

  • Concurrent runs in two chats: sending (and the stop button) stays blocked in every chat while a run is loading.
  • The built-in agent selector does not show or lock a bound chat's agent.

API: additive only. forwardedPropsProvider gains a context argument (providers without arguments still work); sendPrompt gains an optional target; chat.updateMetadata gains an optional chatId; new optional Chat.agent, CreateChatOptions.agent, SendMessageOptions.agent; new types ForwardedPropsProviderContext, RunTarget; new getter UseAIClient.runMessages; new optional UseAIChatPanelProps.loadingInCurrentChat. Direct UseAIClient users read a run's turn from client.runMessages after a switch mid-run. A custom ChatRepository must persist agent, or bound chats fall back to the global selection.

Tests: bun run test (client 573, server 363, workflows 27 pass) and bun run build pass. Each behavior test in useAIProvider.forwardedPropsProvider.test.tsx, LocalStorageChatRepository.test.ts and client.test.ts fails with its fix reverted; the review test from f09d558 is unchanged and passes.

Before / After

The example app's File Transformers page, run against the server's mock agent (no model calls), at 1.7x speed.

  • The mock's thinking now quotes the prompt it answers. Its reply follows the request: 1 to 300 then ALPHA, BRAVO, or the last word of the chat's previous reply.
  • The old videos showed "15! = 1,307,674,368,000" in both chats. The old mock gave that canned answer to every prompt, so each chat showed it from its own run. It was not a leak between chats, but the video could not prove that.
  • The walkthrough and the mock change are in test: make the mock agent answer its prompt and check each chat's thinking #91.
  • Flow: chat A sends while chat B is open. Then A sends a follow-up with a PDF, and the user switches to B while the PDF is processed. The checks are shown on screen.
  • BEFORE (base 21bee9e): 14/18 checks pass. Chat B shows "Thinking...", chat A's "Processing file..." progress, and chat A's follow-up. A's follow-up reply has lost A's history: "There is no previous answer in this chat."
  • AFTER (this PR's client code, 4996d00; the caption shows 4eb217a, which only adds tests): 18/18 checks pass. This includes "chat B shows no thinking or answer from chat A's run" and "each chat's thinking is about its own question(s)".
Before After
pr89-multi-chat-before.mp4
pr89-multi-chat-after.mp4

Manual testing

Two chats, one run in flight: steps, expected vs leak

Two chats, one run in flight while you switch between them. Each step says what you should see and what a leak looks like.

Setup

  1. bun install && bun run build at the repo root.
  2. Free the ports: bun run kill (3000, 3002, 8081).
  3. Start the server, in one of two modes:
    • Real model (paid calls):
      cd apps/use-ai-server-app
      ANTHROPIC_API_KEY=sk-... CORS_ORIGIN=http://localhost:3000 bun src/index.ts
      
      (AI_GATEWAY_API_KEY=... works instead of ANTHROPIC_API_KEY.)
    • Mock agent (no key, no cost):
      cd apps/use-ai-server-app
      ANTHROPIC_API_KEY= AI_GATEWAY_API_KEY= OPENAI_API_KEY= USE_AI_ENABLE_MOCK_AGENT=true \
        CORS_ORIGIN=http://localhost:3000 bun --no-env-file src/index.ts
      
      The mock's thinking starts with The user asked: "<your prompt>". Its reply follows the prompt: a count, a single word, or the last word of the chat's previous reply. If you see thinking about a factorial ("15! = ..."), the server is running an old build. Rebuild.
  4. Start the example app: cd apps/example && bun --no-env-file run dev, then open http://localhost:3000.
  5. Open the DevTools console and run localStorage.clear(), then reload.
  6. Click File Transformers in the sidebar. Its PDF transformer takes about 4 seconds, which gives you time to switch chats mid-send.
  7. Make a small PDF to attach: printf '%%PDF-1.4\nQuarterly report.' > /tmp/quarterly-report.pdf.

Prompts:

  • A: Count from 1 to 300, one number per line. After the last number, write the single word ALPHA.
  • B: Reply with the single word BRAVO and nothing else.
  • A follow-up: Which word did you write at the end of your previous answer in this chat? Reply with that word only.

Steps

# Do Expected Leak (wrong)
1 Open the chat (AI button). Type prompt A and send. Chat A shows the "..." bubble, "Thinking..." as placeholder, the stop button, then its thinking block and the numbers streaming. -
2 While A streams, click + (new chat). This is chat B. B is empty. No thinking block, no numbers, no "..." bubble, placeholder not "Thinking...". The stop button stays and sending stays blocked until A finishes. That is known and out of scope. Any thinking block, any of A's numbers, the "..." bubble or "Thinking..." in B.
3 Stay in B until A finishes (the send button comes back). B is still empty. A's answer or thinking shows up in B when A finishes.
4 In B, send prompt B. B shows its question, then one reply: BRAVO. Expand Thinking complete: it is about the BRAVO request (mock: The user asked: "Reply with the single word BRAVO...). Reply or thinking mentions counting, 300, ALPHA, or a calculation nobody asked for.
5 Open the history dropdown and pick chat A ("Count from 1 to 300..."). A shows its question and one reply: 1 to 300, then ALPHA. Its thinking is about counting. A shows BRAVO, B's question, or a second reply.
6 In A, type the follow-up, attach the PDF, send. Within 4 seconds, use the history dropdown to switch to chat B. In A before the switch: "Processing file... N%". In B after the switch: nothing new. No "Processing file...", no "..." bubble, no "Thinking...", no thinking block, no streamed text. "Processing file..." in B, the "..." bubble or "Thinking..." in B, A's follow-up question in B, or a thinking block or ALPHA streaming in B.
7 Wait in B until the send button returns. B still holds only its question and BRAVO. A's follow-up question or its reply is added to B.
8 Switch back to A. A holds 2 questions and 2 replies. The last reply is ALPHA. Expand both thinking blocks: neither mentions BRAVO. Last reply is "There is no previous answer in this chat." (mock) or "I don't have any previous answers" (real model): the follow-up ran without A's history. Or a thinking block about BRAVO.
9 Console: JSON.parse(localStorage.getItem('use-ai:chat-index')).map(id => JSON.parse(localStorage.getItem('use-ai:chat:' + id)).messages.map(m => m.role)) One chat [user, assistant, user, assistant] (A), one [user, assistant] (B). A message in the wrong chat, or a chat with 3 or 5 messages.

The reported symptoms, and what they mean

  • "15! thinking in chat B, and in chat A, never asked for"
    • The old mock agent answered every prompt with the same thinking and reply: the factorial of 15.
    • The walkthrough added "Use long reasoning." to every prompt in mock mode, so every chat got that canned text from its own run.
    • It did not move from one chat to another.
    • With the mock fixed, a real leak looks like step 2 or 6 above: a thinking block appearing in B while A runs, and its text quoting A's prompt.
  • Thinking from A in B. A thinking block appears in B while A runs (steps 2, 6). Or B's thinking mentions counting or ALPHA (step 4).
  • Text from A in B. Numbers or ALPHA stream in B (steps 2, 3, 6, 7).
  • Pending bubble in B. A "..." bubble, or "Thinking..." as placeholder, in B while A runs (steps 2, 6).
  • File indicator in B. "Processing file... N%" in B after the switch (step 6).
  • History lost. A's follow-up reply does not know ALPHA (step 8).

Automated version

MOCK=1 apps/example/scripts/record-multi-chat.sh runs the same walkthrough against the code before and after the branch. It records both runs and prints every check. Without MOCK=1 it needs ENV_FILE=... with a key, and it makes paid calls.

handleSendMessage awaits attachment processing, which can take a long time,
before calling forwardedPropsProvider with no arguments. A provider that
resolves 'the current chat' at call time then sees whichever chat the user
switched to in the meantime, so per-chat props land on the wrong conversation.

The provider now receives { chatId }, captured when the send starts. Zero-arg
providers keep working.
@KakkoiDev

Copy link
Copy Markdown
Contributor Author

@claude

…sagree

If the user switches chats while attachments are still processing,
forwardedPropsProvider gets the original chat id. But sendPrompt reads
this.threadId when the send finishes, so the run goes to the new chat.

This test fails on purpose. It shows that the request holds two
different chat ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mm-zacharydavison

Copy link
Copy Markdown
Contributor

I wasn't too sure about the design, so I investigated a little.

Basically, I'm not a big fan of there being two conflicting sources of truth, one which could be wrong in certain cases. It's a footgun for a client of the library.

Digging into it, I think this fix doesn't quite work anyway, I added a test illustrating the error case.

I'm wondering, could we just block chat switching while attachment is uploading? This seems the simplest, and likely the user doesn't need to switch chats while uploading attachments anyway.

@KakkoiDev

Copy link
Copy Markdown
Contributor Author

@mm-zacharydavison
Hi Zach, thank you very much for your review!

I'm wondering, could we just block chat switching while attachment is uploading? This seems the simplest, and likely the user doesn't need to switch chats while uploading attachments anyway.

I'll take some time to think about it.

handleSendMessage awaits attachment processing, the user-message save and
forwardedPropsProvider before dispatching the run, and the run then read the
client's live thread id, message buffer and selected agent. A chat switch in
that window sent the message on the other chat's thread, without its own
history, with the agent selected later, and showed it in the other chat.

The send now captures its chat id, history and agent before the first await
and passes them to sendPrompt as a RunTarget. forwardedPropsProvider and the
attachment pipeline receive that chat, loaded once by id. saveUserMessage
only appends to the displayed list when its chat is displayed, and
activatePendingChat always reloads the chat's messages into the client, so a
run that finished after a switch does not leak into the next chat's send.
The agent of a run came from the global selection at send time, so opening an
older chat sent it to whatever agent was selected now. Apps that needed a
stable route per conversation had to infer it from the conversation text.

Chat and CreateChatOptions gain an optional agent field, persisted by
LocalStorageChatRepository. sendMessage({ newChat: true, agent }) creates a
bound chat, and the blank-chat reuse check compares the agent. A send in a
bound chat uses that agent; unbound chats keep following the global
selection, and message-level forwardedProps.agent still wins.
@KakkoiDev KakkoiDev changed the title feat(client): pass the send's chat id to forwardedPropsProvider fix(client): bind each send to its chat, and the agent to the chat Oct 19, 2026
setThreadId and clearConversation cleared the client's tool-call and
assistant assembly state on every chat switch, including while a run was
streaming. A TOOL_CALL_END that arrived after the switch found no tool call,
so the client tool never ran, no tool_result was sent, and the server waited
for it until the run was aborted. The run's turn messages were also pushed
into the buffer of the chat the user switched to.

While a run is in flight, a thread change now keeps that run's assembly state
and moves its message buffer aside; only the visible thread's buffer is reset.
The run appends its turn to its own buffer, exposed as client.runMessages,
and useServerEvents extracts the persisted turn from it. Idle switches reset
state as before.
@KakkoiDev KakkoiDev changed the title fix(client): bind each send to its chat, and the agent to the chat fix(client): keep each send and run on its own chat, and bind the agent to the chat Oct 19, 2026
An agent returned by forwardedPropsProvider is merged into the run's
forwarded props like a message-level one, so it overrides the chat's
bound agent too. The README only named the message-level override.
The test asserts that a bound chat with history keeps its agent after
the global selection changes. Its name and fixture text described a
text-based routing mechanism the library does not have.
@mm-zacharydavison

Copy link
Copy Markdown
Contributor

@KakkoiDev let me know when this is ready for review and what the conclusion was after:

#89 (comment)

…le is sent from

chat.updateMetadata takes an optional chatId. Without it the write still
goes to the chat on screen. With attachments, forwardedPropsProvider's
ctx.chat is reloaded by the send's chat id after they are processed, so
it sees metadata written by transformers during the same send.
@KakkoiDev KakkoiDev changed the title fix(client): keep each send and run on its own chat, and bind the agent to the chat fix(client): bind each send, run and metadata write to its own chat, and bind the agent to the chat Oct 21, 2026
@KakkoiDev

Copy link
Copy Markdown
Contributor Author

@mm-zacharydavison
Hi Zach, thanks for the follow-up. This PR is now ready for review.

On the conclusion: I didn't block chat switching.
That would freeze the user for the whole upload, and it doesn't fix the dropped tool calls or the agent routing.
Instead, the send context is captured when the send starts, which fixes the race where it begins.

I had several issues with multiple chats in one window.
What I did:

  1. One send context (a3dd4b1, 969e621). chatId and threadId now come from one value, so they can't disagree.
  2. Agent bound to the chat (2de2e66).
  3. An in-flight run keeps its state (d2c264a).
  4. Metadata keyed by chat id (71d595b).

Your test is unchanged and passes. Every new test fails when its fix is reverted.

@mm-zacharydavison

Copy link
Copy Markdown
Contributor

@masudahiroto can you review this?

@mm-zacharydavison mm-zacharydavison left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like your AI has overfit a little bit on the frozen metadata request and decided it needs to mention it all over the place. Please check comments and simplify.

* Implementations can store chats locally (localStorage, IndexedDB)
* or remotely (REST API, GraphQL, etc.)
*
* Implementations must persist every {@link Chat} field, including `agent`. A chat whose

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems redundant to say that agent must be saved, if it is required to save every field.

const updateMetadata = useCallback(async (metadata: ChatMetadata, overwrite = false): Promise<void> => {
/**
* Gets the current chat object (including metadata).
* Metadata is frozen to prevent accidental mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think the comment should say anything about metadata being frozen. If the implementation wants to prevent mutation, it should freeze or copy.

// Load existing messages into client for conversation history
if (clientRef.current && messages.length > 0) {
// Load the chat's messages into the client for conversation history, even when there are
// none: the client buffer may still hold a run that finished after the user switched away.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what does none mean? note typo?
Also, what is the impact of this happening, if it happens?

@KakkoiDev KakkoiDev Oct 26, 2026 •

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.

🏁 Updated wording to:

Load the chat's messages into the client for conversation history, also when the chat has no messages, so the client's history is always the activated chat's.

clearConversation keeps an in-flight run's tool-call and assistant state
and moves its buffer to runMessages, like setThreadId. No test covered it:
with the in-flight branch removed, every client test still passed. The new
test fails on both transports without that branch.
The comment said the client buffer may still hold a run that finished after
a switch. Since runs for another thread write to runMessages, it cannot;
the empty load keeps the client's history equal to the activated chat's.
@KakkoiDev KakkoiDev changed the title fix(client): bind each send, run and metadata write to its own chat, and bind the agent to the chat 🐛 fix(client): keep each send and run on the chat it started in Oct 23, 2026
@KakkoiDev

Copy link
Copy Markdown
Contributor Author

🧭 Review walkthrough / レビューガイド

Generated for c09064651c with the pr-review-enhanced skill. The interactive page was built as a local file only and was not published. / pr-review-enhanced スキルで生成。ページはローカルのファイルのみで、公開していない。

  • Start at packages/client/src/providers/useAIProvider.tsx: the RunTarget (thread id, history copy, agent) is captured before the first await of handleSendMessage, and forwardedPropsProvider gets { chatId, chat } of that capture.

  • Then packages/client/src/client.ts: with a run in flight, setThreadId and clearConversation move the buffer into _runMessages; every push during the run goes to runMessages. _runMessages is reset only by the next sendPrompt.

  • processAttachments already loaded the chat once per send, so passing the captured chat does not give transformers older metadata than before.

  • The three earlier review threads (frozen-metadata wording, agent-persistence wording, the none typo) are addressed by c8fc5b4 and c090646.

  • 最初に packages/client/src/providers/useAIProvider.tsx を見る。RunTarget(スレッド ID、履歴のコピー、エージェント)を handleSendMessage の最初の await の前に取り、forwardedPropsProvider はその { chatId, chat } を受け取る。

  • 次に packages/client/src/client.ts。run の途中では、setThreadId と clearConversation がバッファを _runMessages に移し、run の間の push はすべて runMessages に入る。_runMessages を戻すのは次の sendPrompt だけ。

  • processAttachments は元から 1 回の送信でチャットを 1 回だけ読んでいたので、取ったチャットを渡しても、変換処理が前より古いメタデータを見ることはない。

  • 以前のレビュー 3 件(凍結したメタデータの文言、エージェントの保存の文言、none の誤字)は c8fc5b4 と c090646 で対応済み。

Action items / 対応事項

  • [reviewer] Check switching away and back to the same chat while its run streams: the client history loaded on return must include the run's turn before the next send. / run の途中で別のチャットに移って同じチャットに戻るケースで、戻ったときのクライアントの履歴が次の送信の前に run の内容を含むか確認する。
  • [author] Note in the release notes that a custom ChatRepository must store agent for bound chats to keep their agent. / 独自の ChatRepository が agent を保存する必要があることをリリースノートに書く。

…is sent from

The provider kept one file processing state for all chats, so switching chats while a send-time transformer ran showed its progress in the other chat. It is now passed to the UI only while the displayed chat is the one the send started in, like the streaming answer.
…at the run belongs to

The provider-wide loading flag drew the "..." bubble and the "Thinking..." placeholder in every chat while one chat's run was in flight. They now follow the run's chat, like the streaming answer. Sending stays blocked in every chat while a run is loading.
…nking (#91)

* test(example): record a multi-chat walkthrough with a visible cursor

* chore(example): add a launcher that records the multi-chat e2e video

* test(example): detect an in-flight run by its stop button in the multi-chat walkthrough

A provider that delivers the answer in one burst renders the streaming message for less than a frame, so waiting for it timed out after the reply had arrived. Longer answers keep each run in flight across the chat switch, and the stop button is checked again after the switch.

* chore(example): accept the AI gateway key for e2e runs

The example server runs its Claude agent through the gateway when AI_GATEWAY_API_KEY is set, so ANTHROPIC_API_KEY is not required then.

* chore(example): add a Playwright config that runs e2e tests against the mock agent

Blanks every provider key and loads no .env file, so no run with it can make a paid call.

* test(example): show the multi-chat checks on screen and switch chats while a PDF is processed

The walkthrough moves to the File Transformers page, whose PDF transformer delays the send by about 4 seconds; switching chats in that window put the message in the other chat before the chat fixes. Each check is captioned and the walkthrough continues past a failed one, so a run without the fixes records where it goes wrong. The test still fails at the end if any check failed.

* chore(example): record the multi-chat walkthrough before and after the branch's changes

The launcher extracts the merge-base with origin/main (git archive, no worktree), builds it once, runs the same spec against it and against the checkout, labels both videos, joins them into one mp4 and prints the failed checks of each. MOCK=1 records both with the mock agent at no cost.

* test(example): check that chat B shows no file processing indicator while chat A's PDF is processed

* test(example): check that chat B shows no pending bubble or "Thinking..." while chat A runs

* test(example): sample the pending bubble check once, with the run's in-flight state

A failed check holds its caption for 2.5 seconds, so a separate in-flight check after it failed on the base build because the run had ended meanwhile.

* fix(server): derive the mock agent's reasoning and reply from the prompt

The mock agent answered every prompt with the same text, and with
"long reasoning" the same factorial of 15. A demo or test on two chats
then showed that calculation in both, for prompts that never asked for it,
and the reply could not tell which prompt or which chat it answered.

The reasoning now quotes the last user message, and the reply follows
its request: a count, a single word, or the last word of the previous
reply in the prompt. Any other prompt gets a reply that quotes it.
createMockReasoningModel takes chunkDelayInMs for fast tests.

* test(client): check that a run's thinking and answer show only in its own chat

* test(example): check each chat's thinking in the multi-chat walkthrough

The walkthrough no longer appends "Use long reasoning." with the mock
agent and no longer skips the reply checks there: the mock now answers
the prompt it gets. New checks: chat B shows no thinking or answer from
chat A's runs while they stream, and each chat's thinking is about its own
questions. With the mock agent, BEFORE runs today's mock too, so only the
client differs.

@KakkoiDev KakkoiDev 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.

Review: keep each send and run on the chat it started in

Reviewed at head 755b334a1135626b90d1357d4497b8e0d7974970. Unit tests at this head: packages/client 574 pass, 0 fail; packages/server 370 pass, 1 skip, 0 fail (bun run test, after building packages/core and packages/client). I did not run the e2e suites.

The latest commit (755b334) changes no client code: it adds a multi-chat e2e walkthrough (apps/example/test/multi-chat.e2e.test.ts), a script that records it before and after the fix, a Playwright config for the server's mock agent, a mock model that answers the prompt it gets, and one more provider test for the thinking and the streamed answer.

What this PR does: handleSendMessage awaits attachment processing, the user-message save and forwardedPropsProvider before it dispatches the run, and the run used to read the client's live thread id, history and agent. A chat switch in that window sent the message on the other chat. The PR captures a RunTarget (thread id, history copy, agent) before the first await and passes it to sendPrompt. A run in flight keeps its own buffer (_runMessages) when the thread changes, so its tool calls still run and its turn is not written into the new chat. Chats can be bound to an agent (Chat.agent). The last two commits show the file progress, the pending bubble and "Thinking..." only in the run's chat; sending stays blocked in every chat while any run is in flight.

Where to look first: useAIProvider.tsx (the capture before the first await, loadingInCurrentChat), then client.ts (sendPrompt with a target, setThreadId and clearConversation during a run, the runMessages getter).

One send, before and after

Before:

flowchart TD
  UI["chat UI send"] --> Send["handleSendMessage"]
  Send --> Await["awaits: attachments, save user message, forwardedPropsProvider"]
  Switch["chat switch during the awaits"] -.-> Live
  Await --> Prompt["client.sendPrompt"]
  Live["live client threadId, messages, selectedAgent"] --> Prompt
  Prompt --> Server["server run"]
  Server --> Events["useServerEvents: tool calls, persist turn"]
  Events --> Buf["client._messages"]
Loading

After:

flowchart TD
  UI["chat UI send"] --> Send["handleSendMessage"]
  Send --> Target["RunTarget: threadId, messages copy, agent"]
  Repo[("ChatRepository: Chat.agent")] --> Target
  Target --> Await["awaits: attachments, save user message, forwardedPropsProvider with chatId and chat"]
  Await --> Prompt["client.sendPrompt with target"]
  Prompt --> Server["server run"]
  Server --> Events["useServerEvents: tool calls, persist turn"]
  Events --> RunBuf["client.runMessages"]
  Switch["setThreadId or clearConversation during a run"] --> RunBuf
  Events --> Ind["chat panel: pending bubble, Thinking, file progress only in the run's chat"]
  classDef added fill:#dafbe1,stroke:#1a7f37,color:#000
  classDef changed fill:#fff8c5,stroke:#9a6700,color:#000
  class Target,Repo,RunBuf added
  class Send,Await,Prompt,Events,Switch,Ind changed
Loading
Step of a send Before After
Thread id of the run live client.threadId at dispatch captured before the first await
History of the run live client.messages at dispatch copy captured before the first await
Agent of the run global selection at dispatch chat's bound agent, else global selection at capture; forwardedProps.agent still wins
forwardedPropsProvider argument none { chatId, chat } of the captured chat
Chat switch while a run streams tool calls and turn state cleared run keeps its own buffer and tool calls
Pending bubble, "Thinking...", file progress in another chat shown in every chat shown only in the run's chat

Findings

High: 0. Medium: 0. Low: 3.

  1. Low, UX (packages/client/src/components/UseAIChatPanel.tsx:322, :325): the placeholder now follows loadingInCurrentChat, but canSend still follows loading. In a chat with no run of its own, while another chat's run is in flight, the composer shows the idle placeholder and the send button is disabled, with nothing on screen saying why. Suggest a placeholder or hint for "a reply is in progress in another chat", or document the global block.
  2. Low, compatibility (packages/client/src/providers/chatRepository/types.ts:152): Chat.agent is optional and LocalStorageChatRepository stores it. A custom ChatRepository that copies known fields only drops it, and a bound chat silently falls back to the global agent. The PR body says this, and the code comment that said it was dropped in c8fc5b4 at review request, so the release notes are the one place left to tell custom-repository authors.
  3. Low, test suite (apps/example/test/multi-chat.e2e.test.ts:36): the walkthrough sits in apps/example/test, so the default playwright.config.ts runs it in every test:e2e run, against the real model. It forces video: 'on', which overrides the config's retain-on-failure, and its first prompt asks for 300 numbers. Suggest turning video on only when the record script runs (it already sets MULTI_CHAT_LABEL), so a normal e2e run keeps the regression test without the recording.

Nit: apps/example/playwright.config.ts:6 now accepts AI_GATEWAY_API_KEY or ANTHROPIC_API_KEY, but the hint at :9 still names only ANTHROPIC_API_KEY.

Checked, no finding

  • Switch away from a chat and back while its run streams, then send: saveAIResponse saves to currentChatId (still the run's chat, since activating another chat needs a send, which is blocked) and appends the turn to the displayed list when that chat is shown. activatePendingChat then loads that list into the client before RunTarget is captured, so the next send carries the run's turn.
  • forwardedPropsProvider signature change: a provider written as () => props still type-checks. ForwardedPropsProviderContext and RunTarget are exported from index.ts.
  • _runMessages is reset only by the next sendPrompt, so after a cross-thread run ends, runMessages returns that run's buffer until the next send. Its two readers in useServerEvents read it only during a run, and the getter's doc says "latest run", so this matches.
  • Mock model rewrite: the existing mock-agent e2e test (extended-thinking.e2e.test.ts) checks only that reasoning shows and is stored with a signature, not the old fixed texts, and no test refers to the removed factorial reply. The walkthrough's thinking-quotes-the-prompt checks run only with MULTI_CHAT_MOCK_AGENT, so the real-model run does not depend on the mock's wording.
  • The three earlier inline threads (agent-persistence wording on types.ts, frozen-metadata wording and the none typo on useChatManagement.ts) are addressed by c8fc5b4 and c090646. The "Metadata is frozen" lines still in the code predate this PR, and getChat does Object.freeze the metadata.

Before you approve

  • Author: decide what a chat without its own run shows while another chat's run blocks sending (finding 1).
  • Author: note in the release notes that a custom ChatRepository must store agent for bound chats to keep their agent (finding 2).
  • Author: decide whether the walkthrough records video in every e2e run (finding 3).

Comment on lines +322 to 325
: loadingInCurrentChat
? `${executingTool?.displayText ?? strings.input.thinking}...`
: strings.input.placeholder;
const canSend = !!(

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.

Low, UX: the placeholder now follows loadingInCurrentChat, but canSend below still follows loading. In a chat with no run of its own, while another chat's run is in flight, the composer shows the idle placeholder and the send button is disabled, with nothing on screen saying why. A placeholder such as "A reply is in progress in another chat" when loading && !loadingInCurrentChat would explain the block; or document that sending is blocked globally.

* Agent this chat is bound to. Every message sent in the chat goes to this agent,
* whatever the global agent selection is. When unset, the chat follows the global selection.
*/
agent?: string;

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.

Low, compatibility: a custom ChatRepository that copies known fields only drops agent, and a bound chat then silently falls back to the global agent. The code comment that said so was removed in review, so a line in the release notes is the remaining place to tell custom-repository authors.


test.use({
viewport: { width: 1280, height: 720 },
video: { mode: 'on', size: { width: 1280, height: 720 } },

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.

Low, test suite: this file sits in apps/example/test, so the default playwright.config.ts runs it in every test:e2e run, against the real model. video: 'on' here overrides the config's retain-on-failure, so every run records it. Suggest turning video on only when the record script runs (it already sets MULTI_CHAT_LABEL), so a normal e2e run keeps the regression test without the recording.

@KakkoiDev

Copy link
Copy Markdown
Contributor Author

@mm-zacharydavison
Hi Zach, sorry for keeping you waiting.
This PR is ready for your re-review!

@mm-zacharydavison

mm-zacharydavison commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

🧭 Review walkthrough / レビューガイド

Open the review page / レビューページを開く (generated for 2614494bfc)

  • The send captures its chat id, history and agent before the first await, and the run uses that capture as a RunTarget. Start at useAIProvider.tsx (handleSendMessage) and client.ts (sendPrompt, runMessages).

  • A run in flight keeps its own buffer when the thread changes, so setThreadId and clearConversation in client.ts no longer reset tool-call state mid-run.

  • The reply save in useChatManagement.ts still writes to currentChatIdSnapshot. It is correct only because sending is blocked while a run loads. The checklist asks the reviewer to confirm this before concurrent runs are added.

  • multi-chat.e2e.test.ts adds up to five minutes of paid model calls to the test:e2e suite. Every existing E2E test already uses the real model, so this is a cost question, not a new kind of test.

  • 送信は最初の await の前にチャット id、履歴、agent を取得し、実行はそれを RunTarget として使う。useAIProvider.tsx(handleSendMessage)と client.ts(sendPrompt、runMessages)から読む。

  • 実行中に thread が変わっても実行は自分のバッファを保持するため、client.ts の setThreadId と clearConversation は実行中の tool call の状態をリセットしなくなった。

  • useChatManagement.ts の返答保存は今も currentChatIdSnapshot に書く。実行のロード中は送信がブロックされるため正しく動くが、同時実行を追加する前に確認が必要である。チェックリストにその項目がある。

  • multi-chat.e2e.test.ts は test:e2e スイートに最大 5 分の有料モデル呼び出しを加える。既存の E2E テストはすべて実モデルを使うため、これは新しい種類のテストではなくコストの問題である。

@mm-zacharydavison generated this page with the pr-review-enhanced skill. It is a claude.ai artifact. If it does not open, ask @mm-zacharydavison to share it. / claude.ai の Artifact です。開けない場合は @mm-zacharydavison に共有を依頼してください。

@KakkoiDev
KakkoiDev merged commit c676b8c into main Oct 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants