feat(chat): concurrent agents on highlighted parts of a response
From the PR description
Summary
Highlight part of an assistant response, hand it to an agent with an instruction, and the agent answers in a side panel while you keep working. Multiple agents run at once. Each is a real, persisted sub-chat, so it gets the whole existing chat stack for free - streaming, history, access control, deletion cascade. Agents can also propose concrete rewrites of the response they were spawned from, which land as accept/reject cards.
Builds on #384 (highlight → "Add to Chat"): the same selection popup gains a second action, and an agent is seeded with the excerpt using #384's own quoted- excerpt encoding, so its thread renders the quote with no agent-specific message format.
What changed
Schema (20260826_02_chat_agents.sql)
chatsgainsparent_chat_id(self-FK,on delete cascade),agent_instruction,source_message_id,source_excerpt, and a partial index onparent_chat_id.chat_messagesgainsedited_at.get_chats_overviewand bothget_projects_overviewoverloads exclude agents, so they neither appear in the recent-chats list nor inflate a project's chat count.
Backend
POST /chat/createaccepts an assignment. Validated: parent reachable, parent is not itself an agent (depth-1), at most six per response.GET /chat/:chatId/agents- the dock's source of truth.statusis derived from stored messages (ready/empty), never stored.PATCH /chat/:chatId/messages/:messageId- replaces an assistant message's events and stampsedited_at.PATCH /chat/:chatId/proposals/:proposalId- resolves one proposal card.propose_edit, a chat-scoped tool offered only to agents via a newrouteToolsadapter onrunLLMStream.GET /projects/:projectId/chatsexcludes agents.
Frontend
hooks/assistantStream.ts-AssistantEventBuffer,applyAssistantStreamFrame,consumeAssistantSseStream, extracted out ofuseAssistantChatso one stream is an object rather than the only stream.hooks/useChatAgents.ts- the concurrent runtime: one buffer and one abort controller per agent id.lib/chatAgents.ts- card labelling, status overlay, proposal application and stale detection, source-message resolution.components/assistant/agents/- the dock, the side panel (assign + thread), proposal cards, pending-region markers, anduseAgentSurface, the single integration point both chat surfaces use.QuoteSelectionPopupgains "Assign to agent".
Why
Reading a long answer, the useful next question is usually about one part of it - "is that clause enforceable?", "tighten this paragraph". Today that means either derailing the main thread or losing the context. Agents let several of those run at once, beside the response, without touching the conversation.
Three decisions are worth calling out.
An agent is a chat. The alternative - a bespoke chat_agents table - would
have meant re-deriving streaming, message persistence, access control and
deletion for a thing that is a conversation in every respect. Four additive
columns buy all of it. The cost is that agents must be filtered out of three
listing queries, which this PR does.
propose_edit is gated by absence, not by a check. Rather than a tool that
asks "am I in a sub-chat?" at call time, the tool is handed to the turn only
when the turn belongs to an agent. A normal chat is never told it exists, which
is a stronger guarantee than a runtime branch.
The streaming refactor was necessary, not incidental. useAssistantChat
had no module-level state - but it had a single event buffer and a dozen
closures writing to "the last assistant message", which is correct while one
stream can be open and unfixable-in-place once seven can. Making the per-stream
state an ordinary value, and routing the application-touching effects (current
chat id, sidebar title, URL) out through explicit handlers, is what lets an
agent render exactly like the main transcript without navigating the page out
from under the user.
Replication
Base case (before this PR, on olp-pr/chat-highlight-quote)
- Open an assistant chat and get a multi-paragraph answer.
- Highlight a sentence inside it. The popup offers one action, "Add to Chat".
- There is no way to ask a question about that sentence without sending it as a turn in the main conversation.
GET /chatreturns every chat the user can see; there is no notion of a chat belonging to another chat.
With this PR
- Open an assistant chat and get a multi-paragraph answer.
- Highlight a sentence. The popup now offers Assign to agent.
- Click it - a side panel opens showing the excerpt and an instruction field. Type something ("is this enforceable?") and press Assign.
- A card appears above the composer with a spinner and "Processing"; the agent's answer streams in the panel.
- Repeat on a different sentence while the first is still streaming. Both cards spin, both threads fill independently, and the main conversation is still usable.
- When an agent answers, its card flips to a green check. If the agent called
propose_edit, the card shows a badge with the number of pending edits and the panel shows accept/reject cards. - Accept one: the original response updates in place, gains a "Revised" marker, and the card reads "Applied to the response."
- Accept a second proposal whose target text the first one already rewrote: the card reads "This part has changed, so the edit no longer applies" and Accept is disabled. Nothing is written.
- Click a card to reopen its thread and reply - the composer continues the agent's conversation.
- Reload the page. Cards rebuild from
GET /chat/:id/agents. An agent whose stream was interrupted shows "Needs rerun" with a rerun control, not a spinner. - Check the sidebar and the project chat list: no agent appears in either.
- Try to assign a seventh agent to the same response - refused, with the cap named in the message.
Tradeoffs and design decisions
- Pending regions are marked below the response, not underlined inside it. The contract asks for a subtle inline marker. The prose is produced by the markdown renderer, and wrapping a substring in place means splitting text nodes React owns - a reliable way to make a later re-render throw. A row of underlined region chips under the response says the same thing, stays inside React's tree, and gives the region something to click. This is the one deliberate deviation from the contract.
- Proposal status lives on the event, not in a new table.
document_editsis the precedent for a status table, but it exists because an edit is shared across chats and documents. A proposal belongs to exactly one message in one agent's thread, so a table would have added RLS, grants and a lifecycle for no reachability the event does not already have. Cost: resolving a proposal is a read-modify-write of a jsonb array. - The client computes the accepted content, and
PATCH /chat/:chatId/messages/:messageIdstays a generic replace. Cost: the substitution logic lives on the client only. Benefit: the endpoint is one clear thing, and stale detection happens where the user can see it - the card says "this part has changed" before the click, not after a failed request. - Accepting is two writes (rewrite the response, then resolve the card), not a transaction. Order is chosen so a failure of the second leaves a card that still says pending over a response that is correct - the recoverable direction. A failed first write resolves nothing at all.
- The agent panel and the document panel share the right-hand column. Opening an agent hides the document panel; its tabs survive in state, so a citation clicked afterwards re-mounts it unchanged. Splitting the column three ways was the alternative.
AgentSidePaneldoes not joinAssistantSidePanel's tab strip. Every tab there is a document version, and an agent thread has no document; sharing would mean makingdocumentoptional. It borrows the frame, resize handle and glass surface instead.- Agents lose
ask_inputs. Their thread renders in a narrow panel with a plain composer and no picker to answer with, so offering the tool would strand the conversation. get_projects_overviewis re-created in full (both overloads) to fix the chat count. This is ~200 lines of copied SQL and may conflict with in-flight org work that touches the same functions - accepted, because a feature that makes project chat counts wrong is not finished.chatAgents.tsis inlib/andassistantStream.tsis inhooks/. The former is pure and floor-gated at 100%; the latter is a 25-branch event dispatcher whose exhaustive line coverage would be an unwinnable ratchet inlib/. It is tested through the hook's existing SSE suite plus the new concurrency test.- Not in scope: tabular-review chat (its chat is bound to review semantics), the Word add-in, and any team/multi-user semantics beyond the existing chat access rules.
Demo
Recorded live against this branch (dev stack, real Claude responses actually
calling propose_edit): highlight → Assign to agent → instruction in the
side panel → a second agent assigned while the first shows ready + a
1-pending-edit badge (processing spinner on camera) → the Delaware proposal's
Accept rewrites the Governing Law sentence of the original response in
place → clicking the first agent's card opens its thread with its own pending
proposal.

Recording the demo also caught a real bug, fixed in the final commit: agent
turns sent no model, so every agent ran on the server's default provider
regardless of what the parent conversation used - and failed outright on
stacks where that provider's key is unconfigured. Agents now inherit the
model from the parent's newest model-carrying message, falling back to the
composer's persisted selection (the same localStorage value the picker
reads), on seeds, panel follow-ups, and reruns alike.
Testing performed
npm test --prefix backend- 871 passed, 25 skippednpm run build --prefix backend- cleannpm test --prefix frontend- 790 passed across 108 filesnpm run lint --prefix frontend- 0 errors (34 warnings, down from 36 on the base branch)npm run build --prefix frontend- compiled successfullynpm run test:coverage --prefix frontend- 100 / 98 / 100 / 100; floors raised from 99/97/100/100 in the same change- Backend coverage ratchet - 56.44 / 50.03 / 57.92 / 57.93; floors raised from 52/46/53/54
git diff --check- clean
New tests, by the contract's list:
- Backend: sub-chat creation validation (depth-1, cap, access, project
inheritance, missing instruction); history-list exclusion; agents listing +
status derivation (
ready/empty, reservations, error-only turns, whitespace-only answers) and pending-proposal counting;propose_edittool gating (present for an agent, absent for a normal chat); message PATCH guard (unreachable chat, wrong chat, user message, malformed content). - Frontend: dock states (processing / ready / needs-rerun, badge singular/plural/hidden, pressed card, live region); assign flow (excerpt shown, empty instruction disabled, Enter vs Shift+Enter, server refusal); proposal accept / reject / stale target / in-flight / resolved; popup's second action including the disabled-at-cap state; and a concurrency test that interleaves two live agent streams frame by frame and asserts neither writes into the other's thread.
The migration was applied twice to a scratch database built from a
schema-only dump of a main-shaped Postgres instance. The second pass produced
only already exists, skipping notices. Verified against live rows that
get_chats_overview returns the parent and not its agent, and that deleting the
parent cascades the agent away.
🤖 Generated with Claude Code
Our analysis
Add parallel chat agents for response excerpts — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-385.md from
inside the repo you want the changes in.