feat(chat): add /btw side conversations across providers#8296

closed44
main
GitHub fork: feat/btw-side-questions
openedAug 26, 2026byBBil0000
View on GitHubTwo-way sync enabledPR last imported 10d ago

Summary

Adds /btw side conversations for questions about the current coding session. A side answer runs separately from the active agent turn, so it does not steer, queue behind, or stop the main agent.

Experience

  • Web and desktop open the side conversation in the right panel.

  • Minimize moves it to a compact strip above the normal composer. Close hides it; /btw by itself reopens it.

  • The side composer matches the normal chat composer. Enter sends and Shift+Enter adds a new line.

  • Questions use the normal user-message bubble and copy actions.

  • Follow-ups stay in the same side conversation and include its earlier successful turns.

  • Model and reasoning controls use the normal composer pickers. Their selection stays local to the side conversation.

  • Stop ends only that side answer. The main agent keeps running, and another client waiting on the same shared answer is not stopped.

  • Mobile provides the same conversation, follow-up, model, reasoning, minimize, and Stop behavior in a native card above the composer.

Isolation and provider support

| Provider | Isolation path |

| --- | --- |

| Codex | Separate text-generation request with read-only sandboxing |

| Claude Code | Separate request with built-in and MCP tools disabled |

| Cursor | Separate request in permission-denied mode |

| Grok | Separate request in read-only mode |

| OpenCode | Separate request with tools disabled |

Side questions stay outside the durable thread transcript and never dispatch an orchestration command.

How it works

  • Typed askSideQuestion and cancelSideQuestion WebSocket RPCs keep the feature provider-neutral and remote-ready.

  • Each client request has an ID. Stop can cancel it while context is loading or while the provider is running.

  • The server supplies completed messages and completed tool activity from the thread. Partial streaming output is excluded.

  • Follow-ups include the earlier successful side questions and answers.

  • Matching requests share provider work only when the thread context, side history, question, and model selection match.

  • Cancellation is per caller. Shared provider work stops only after its last caller leaves.

  • Context loads in bounded pages and is rejected above 512 KiB before a provider starts.

  • Requests are text-only. A failed initial request restores the composer draft for retry.

Demo

https://github.com/user-attachments/assets/52d82180-23cc-43c4-b47d-1f123b8902e0

Verification

  • 26 focused web and shared-client tests passed.

  • 47 orchestration contract tests passed.

  • 9 focused server side-question tests passed, including active, early, disconnect, duplicate-ID, and bounded cancellation.

  • 6 focused Claude provider tests passed.

  • 6 RPC authorization tests passed.

  • Typechecks passed for contracts, client runtime, server, web, and mobile.

  • Every changed TypeScript file passed targeted lint, formatting, and whitespace checks.

Current limits

  • The side conversation is local to the current app view.

  • Side questions accept text only and use completed thread context, not partial streaming output.

  • Codex has no no-tools CLI switch, so it uses a separate read-only request plus the no-tool instruction.

  • Context above 512 KiB or 1,000 paged thread turns returns an error instead of starting a provider.

Note

Add /btw side conversations across chat providers

  • Adds answerSideQuestion to all text generation providers (Claude, Codex, Cursor, Grok, OpenCode) using a shared prompt that forbids tool usage and returns a JSON { answer } object.
  • Introduces SideQuestionCoordinator for single-flight coordination and cancellation of concurrent side-question requests per thread.
  • Adds WebSocket RPC methods orchestration.askSideQuestion and orchestration.cancelSideQuestion with typed schemas, paginated thread context (up to 20 pages of 50 turns), and a 512 KiB context byte limit enforced in ws.ts.
  • Web and mobile UI: /btw slash-command detection, a side-question right-panel surface, minimized chip, follow-up composer, model selection, and stop/cancel controls.
  • Risk: All Claude CLI invocations now always pass --dangerously-skip-permissions; side questions additionally pass --tools "" and --disallowedTools mcp__* in runClaudeJson. Right panel persistence strips side-question surfaces via persistableThreadState.

Macroscope summarized d96408d.

Added 2 commits

  • feat(chat): add provider-neutral btw side questions794654b
  • fix(chat): harden btw side questionsa5dad56
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

βš™οΈ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c71a798-34e0-4aa8-9954-2f606deea3d8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • πŸ” Trigger review

Comment @coderabbitai help to get the list of available commands.

apps/server/src/textGeneration/ClaudeTextGeneration.tslines 175–176Outdated Β· a5dad56Open thread
……@@ -169,6 +172,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
170173 ...(cliEffort ? ["--effort", cliEffort] : []),
171174 ...(settingsJson ? ["--settings", settingsJson] : []),
175+ "--tools",
176+ "",
MmacroscopeappBoton lines 175–176Aug 26, 2026

🟠 High textGeneration/ClaudeTextGeneration.ts:175

The side-question request still allows configured MCP tools, so untrusted thread context can cause MCP operations despite the prompt requiring no tools. --tools "" disables built-in tools only; add --disallowedTools mcp__* (or otherwise disable MCP configuration) before using --dangerously-skip-permissions.

βœ… Resolved in e3c7f3890b12bc6b8de24811cecd81c07d8728d5

Fixed in e3c7f3890. Claude side-question requests now pass --disallowedTools mcp__* in addition to --tools "". A focused adapter test verifies the MCP restriction is present.

apps/web/src/components/ChatView.tsxline 5430Outdated Β· a5dad56Open thread
……@@ -5367,6 +5388,106 @@ function ChatViewContent(props: ChatViewProps) {
5427+ );
5428+ return;
5429+ }
5430+ if (sideQuestionState?.status === "loading") {
MmacroscopeappBoton line 5430Aug 26, 2026

🟑 Medium components/ChatView.tsx:5430

The guard sideQuestionState?.status === "loading" reads React state, which hasn't re-rendered between two synchronous calls to onSend (e.g. double-click, repeated Enter). Both calls see the pre-render state, pass the guard, and fire separate askSideQuestion RPCs β€” duplicating the server request and its cost. The sideQuestionRequestRef discards the earlier response, but both requests still execute.

Add a ref-based in-flight guard (similar to sendInFlightRef used for the main send path) that is set synchronously before the await askSideQuestion(…) call and cleared in both the success and error branches.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/ChatView.tsx around line 5430:
2
3The guard `sideQuestionState?.status === "loading"` reads React state, which hasn't re-rendered between two synchronous calls to `onSend` (e.g. double-click, repeated Enter). Both calls see the pre-render state, pass the guard, and fire separate `askSideQuestion` RPCs β€” duplicating the server request and its cost. The `sideQuestionRequestRef` discards the earlier *response*, but both requests still execute.
4
5Add a ref-based in-flight guard (similar to `sendInFlightRef` used for the main send path) that is set synchronously before the `await askSideQuestion(…)` call and cleared in both the success and error branches.

Fixed in e3c7f3890 at the shared client-runtime command boundary. askSideQuestion now uses singleFlight concurrency keyed by environmentId and threadId, so synchronous duplicate submissions share one RPC across web, mobile, desktop, and future clients.

apps/server/src/ws.tsline 1285Outdated Β· a5dad56Open thread
……@@ -1271,6 +1278,54 @@ const makeWsRpcLayer = (
1282+ observeRpcEffect(
1283+ ORCHESTRATION_WS_METHODS.askSideQuestion,
1284+ Effect.gen(function* () {
1285+ const thread = yield* projectionSnapshotQuery.getThreadDetailById(input.threadId);
MmacroscopeappBoton line 1285Aug 26, 2026

🟠 High src/ws.ts:1285

askSideQuestion fully loads and serializes an unbounded thread via getThreadDetailById and formatSideQuestionContext before the 512-KiB check, so repeated requests for a thread with large history or tool.completed payloads can exhaust database, heap, or CPU despite the limit. Apply a bounded context query/serialization before loading the complete detail, or otherwise enforce the cap during retrieval.

βœ… Resolved in e3c7f3890b12bc6b8de24811cecd81c07d8728d5

Fixed in e3c7f3890. The handler now reads context through bounded getThreadDetailSnapshot pages (50 user turns per page), accumulates in chronological order, and stops at either 20 pages or 512 KiB before provider startup. The RPC tests cover multi-page ordering and oversized rejection without a provider call.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/ChatComposer.tsxline 1150Outdated Β· a5dad56Open thread
……@@ -1139,11 +1142,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11441147 label: "/model",
11451148 description: "Switch response model for this thread",
11461149 },
1150+ ...(isServerThread
MmacroscopeappBoton line 1150Aug 26, 2026

🟑 Medium chat/ChatComposer.tsx:1150

On a promoted draft route, selecting or submitting /btw question sends the literal command through the normal turn path instead of calling askSideQuestion, because ChatComposer treats the loaded thread as a server thread while ChatView.onSend only recognizes side questions when routeKind === "server". Gate the /btw menu and isSideQuestionDraft on the route kind (or otherwise make both sides use the same predicate) so this transition cannot expose a command with different submission semantics.

No longer relevant as of 3ea7e6ad2766708ed6d0c56966517f4182ab1f01

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Added 1 commit

  • fix(server): satisfy side-question lint rulese45b88c
MmacroscopeappBotreviewed Β· 2 inline threadsAug 26, 2026
Synced from GitHub

Reviewed the new answerSideQuestion service operation, its layer wiring, RPC handler, and the provider implementations against the Effect service conventions.

Two findings, both in apps/server/src/textGeneration/:

  • TextGeneration.ts: the new method was added to the dead standalone TextGenerationService Promise shape, which the conventions say should not be retained alongside the inline Context.Service interface.
  • ClaudeTextGeneration.ts: --tools "" was added to the shared runClaudeJson argv, changing behavior for the four pre-existing Claude text-generation operations with no test coverage.

Everything else looks consistent with the conventions: ws.ts acquires TextGeneration from the environment (yield* TextGeneration.TextGeneration) via a namespace import, TextGeneration.layer is composed at the runtime layer rather than through an ad-hoc runtime, per-provider methods are typed as TextGeneration["Service"]["answerSideQuestion"], and the RPC boundary passes an existing TextGenerationError through while wrapping unknown failures with a preserved cause.

Posted via Macroscope β€” Effect Service Conventions

apps/server/src/textGeneration/TextGeneration.tslines 94–94Outdated Β· e45b88cOpen thread
……@@ -73,13 +73,25 @@ export interface ThreadTitleGenerationResult {
8091 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
8192 generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
8293 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
94+ answerSideQuestion(input: SideQuestionGenerationInput): Promise<SideQuestionGenerationResult>;
MmacroscopeappBoton lines 94–94Aug 26, 2026

textGeneration/TextGeneration.ts:94

TextGenerationService (lines 87-95) is a second, Promise-based copy of the service shape with no consumers anywhere in the repo β€” the canonical interface is the one declared inline in the Context.Service below. Per the conventions, a standalone service shape shouldn't be retained (let alone extended), so consider dropping this line and deleting the dead interface instead of keeping two shapes in sync.

diff
1 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
2- answerSideQuestion(input: SideQuestionGenerationInput): Promise<SideQuestionGenerationResult>;

No longer relevant as of a7702e6ab00b4dcc9767bd6a94e85eed2e7ddf58

Fixed in a7702e6ab. Removed the unused Promise-based TextGenerationService interface; the Effect Context.Service definition is now the single canonical service contract.

apps/server/src/textGeneration/ClaudeTextGeneration.tslines 175–176Outdated Β· e45b88cOpen thread
……@@ -169,6 +172,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
170173 ...(cliEffort ? ["--effort", cliEffort] : []),
171174 ...(settingsJson ? ["--settings", settingsJson] : []),
175+ "--tools",
176+ "",
MmacroscopeappBoton lines 175–176Aug 26, 2026

--tools "" is appended in the shared runClaudeJson argv, so it also disables tools for the pre-existing generateCommitMessage, generatePrContent, generateBranchName, and generateThreadTitle operations β€” a backend behavior change outside the side-question feature, and untested. The other providers rely on the prompt instruction alone, so gating the flag to the new operation (or covering the change with a focused test) would keep the change scoped.

suggestion
1 ...(operation === "answerSideQuestion" ? ["--tools", ""] : []),

Posted via Macroscope β€” Effect Service Conventions

MmacroscopeappBotreviewed Β· 1 inline threadAug 26, 2026
Synced from GitHub

UI consistency review

Three findings in the new web side-question card and composer wiring. All are scoped to the lines this PR adds/changes in apps/web/src.

  • ChatView.tsx: the side-answer card is the only opaque bg-popover surface in the composer overlay stack, and it has no height bound in a bottom-anchored, overflow-hidden region.
  • ChatView.tsx: the close control is a raw <button> that reconstructs the shared micro icon Button, dropping focus-visible ring, pointer cursor and coarse-pointer hit target.
  • chat/ChatComposer.tsx: the collapsed mobile primary action is now enabled for /btw drafts while a turn runs, but still announces itself as "Send message" while the expanded control announces "Ask side question".

Posted via Macroscope β€” UI Consistency

apps/web/src/components/chat/ChatComposer.tsxlines 1370–1370Outdated Β· e45b88cOpen thread
……@@ -1350,8 +1365,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1368+ const isSideQuestionDraft = isServerThread && parseSideQuestion(prompt.trim()) !== null;
13531369 const collapsedComposerPrimaryActionDisabled =
1354- phase === "running" ||
1370+ (phase === "running" && !isSideQuestionDraft) ||
MmacroscopeappBoton lines 1370–1370Aug 26, 2026

chat/ChatComposer.tsx:1370

This change makes the collapsed mobile primary action clickable for a /btw draft while a turn is running, but collapsedComposerPrimaryActionLabel just below is still the constant "Send message", while the expanded control now announces "Ask side question" via ComposerPrimaryActions. On the collapsed mobile composer the arrow <svg> is aria-hidden, so the aria-label is the only accessible name and it now describes the wrong action.

Smallest fix: derive the label from the same flag.

diff
1- const collapsedComposerPrimaryActionLabel = "Send message";
2+ const collapsedComposerPrimaryActionLabel = isSideQuestionDraft
3+ ? "Ask side question"
4+ : "Send message";

No longer relevant as of a7702e6ab00b4dcc9767bd6a94e85eed2e7ddf58

Synced from GitHub

Reviewed the new answerSideQuestion service surface, its provider implementations, layer wiring, and the ws RPC handler against the Effect service conventions. Dependency acquisition (yield* TextGeneration.TextGeneration in ws.ts), namespace imports, Foo["Service"] usage, make/layer shape, and the pass-through of the already structured TextGenerationError all look correct. Two smaller convention points below.

Posted via Macroscope β€” Effect Service Conventions

MmacroscopeappBotreviewed Β· 1 inline threadAug 26, 2026
Synced from GitHub

Three UI-consistency findings in the new web side-question card and composer wiring. See inline comments.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/ChatView.tsxlines 7138–7150Outdated Β· e45b88cOpen thread
……@@ -7002,6 +7123,49 @@ function ChatViewContent(props: ChatViewProps) {
7147+ }
7148+ >
7149+ <XIcon className="size-4" />
7150+ </button>
MmacroscopeappBoton lines 7138–7150Aug 26, 2026

components/ChatView.tsx:7150

This raw <button> reconstructs the Button icon-ghost primitive, so it drops the primitive's focus-visible:ring-* ring, cursor-pointer, pointer-coarse 44px hit target, disabled handling, and --control-icon-color icon tone. Every other close affordance in the composer region (e.g. ComposerBannerStack, ExpandedImageDialog) uses Button size="icon-xs". Consider using the primitive here too (Button is already imported at line 385):

suggestion
1 <Button
2 size="icon-xs"
3 variant="ghost"
4 aria-label="Close side answer"
5 onClick={() =>
6 setSideQuestionsByThread((current) => ({
7 ...current,
8 [routeThreadKey]: { ...sideQuestionState, visible: false },
9 }))
10 }
11 >
12 <XIcon className="size-3.5" />
13 </Button>

No longer relevant as of a7702e6ab00b4dcc9767bd6a94e85eed2e7ddf58

Fixed in a7702e6ab. The close action now uses the shared Button component.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable β€” This PR introduces a substantial cross-platform side-conversation workflow with new authenticated RPCs, provider execution, context forwarding, and concurrency/cancellation behavior. It also changes authorization code and handles potentially sensitive thread and tool context, making the runtime and security surface too broad for automatic approval.

You can add or adjust custom eligibility rules. Learn more.

Added 1 commit

  • fix(chat): address btw review feedbacke3c7f38
MmacroscopeappBotreviewed Β· 1 inline threadAug 26, 2026
Synced from GitHub

One new finding on this revision; two earlier findings are still open (see below).

  • apps/server/src/textGeneration/ClaudeTextGeneration.ts β€” --tools "" and the newly added --disallowedTools mcp__* are still appended unconditionally in the shared runClaudeJson argv, so the four pre-existing operations change behavior with no coverage. Inline comment posted.
  • apps/server/src/textGeneration/TextGeneration.ts:94 β€” the dead, Promise-shaped TextGenerationService interface is still being extended alongside the inline Context.Service interface (previously flagged, unchanged).
  • apps/server/src/ws.ts β€” the answerSideQuestion TextGenerationError details still drop the entity context available at the failure site (input.threadId, thread.projectId) (previously flagged, unchanged).

Posted via Macroscope β€” Effect Service Conventions

apps/server/src/textGeneration/ClaudeTextGeneration.tslines 175–179Outdated Β· e3c7f38Open thread
……@@ -169,7 +172,11 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
176+ "",
172177 "--dangerously-skip-permissions",
178+ "--disallowedTools",
179+ "mcp__*",
MmacroscopeappBoton lines 175–179Aug 26, 2026

The new --disallowedTools mcp__* (and the --tools "" added with it) are appended in the shared runClaudeJson argv, so they also disable tools for the pre-existing generateCommitMessage, generatePrContent, generateBranchName, and generateThreadTitle operations. The new test only exercises answerSideQuestion, so that backend behavior change for the other four operations remains untested, and no other provider changes tool behavior for them. Consider gating the flags on the side-question operation so the change stays scoped to this feature.

suggestion
1 ...(operation === "answerSideQuestion"
2 ? ["--tools", "", "--disallowedTools", "mcp__*"]
3 : []),
4 "--dangerously-skip-permissions",

Posted via Macroscope β€” Effect Service Conventions

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/ChatView.tsxline 5463Outdated Β· e3c7f38Open thread
……@@ -5367,6 +5388,105 @@ function ChatViewContent(props: ChatViewProps) {
5460+ ...current,
5461+ [routeThreadKey]: {
5462+ threadKey: routeThreadKey,
5463+ question: sideQuestion,
MmacroscopeappBoton line 5463Aug 26, 2026

🟑 Medium components/ChatView.tsx:5463

The side-question success and error handlers unconditionally write visible: true, so dismissing the loading card is immediately undone when the request finishes. A user who clicks the close button while the spinner is showing will see the card reappear.

Both the success branch (~line 5463) and the error branch (~line 5482) replace the entire state object with a hard-coded visible: true instead of reading the current value from current[routeThreadKey]?.visible. Change both to preserve the existing visible flag, e.g. visible: current[routeThreadKey]?.visible ?? true.

βœ… Resolved in c8f9188f7c299abdcca3abbeaf22af9dbc475b64

Fixed in c8f9188f7. Success and error preserve the current visibility flag. The helper is shared by web and mobile, with focused regression coverage.

Added 1 commit

  • fix(chat): address remaining btw review feedbacka7702e6
apps/server/src/ws.tsline 1284Outdated Β· a7702e6Open thread
……@@ -1271,6 +1281,87 @@ const makeWsRpcLayer = (
12711281 ),
12721282 { "rpc.aggregate": "orchestration" },
12731283 ),
1284+ [ORCHESTRATION_WS_METHODS.askSideQuestion]: (input) =>
MmacroscopeappBoton line 1284Aug 26, 2026

🟑 Medium src/ws.ts:1284

Concurrent askSideQuestion calls for the same threadId start separate textGeneration.answerSideQuestion executions, producing duplicate answers and unnecessary provider work. Because this handler is created inside makeWsRpcLayer, its state is isolated per WebSocket connection; use a shared per-thread in-flight guard with cleanup after completion.

No longer relevant as of 3ea7e6ad2766708ed6d0c56966517f4182ab1f01

MmacroscopeappBotreviewed Β· 2 inline threadsAug 26, 2026
Synced from GitHub

Reviewed the changed web UI surfaces. The prior findings (raw close button, unbounded answer height, collapsed primary-action label) are resolved. One remaining issue: the new side-question flag is derived from the composer draft prompt, which is not the visible input while a pending user-input question is active, so the pending-answer primary actions and submit path can be taken over by an invisible /btw draft.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/chat/ChatComposer.tsxlines 1368–1368Outdated Β· a7702e6Open thread
……@@ -1350,16 +1365,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13501365 : null,
13511366 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
13521367 );
1368+ const isSideQuestionDraft = isServerThread && parseSideQuestion(prompt.trim()) !== null;
MmacroscopeappBoton lines 1368–1368Aug 26, 2026

chat/ChatComposer.tsx:1368

While a pending user-input question is active the textarea shows activePendingProgress.customAnswer, and prompt keeps a stale draft the user cannot see (onPromptChange routes edits to the custom answer). A leftover /btw … draft therefore sets isSideQuestionDraft, and ComposerPrimaryActions now skips its pending branch (pendingAction && !isSideQuestion), so the Previous / Submit answer controls disappear and the collapsed row announces "Ask side question" above a custom-answer field. Consider excluding the pending state from the flag:

suggestion
1 const isSideQuestionDraft =
2 isServerThread && pendingPrimaryAction === null && parseSideQuestion(prompt.trim()) !== null;

No longer relevant as of c8f9188f7c299abdcca3abbeaf22af9dbc475b64

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/ChatView.tsxlines 5391–5391Outdated Β· a7702e6Open thread
……@@ -5367,6 +5388,105 @@ function ChatViewContent(props: ChatViewProps) {
53675388 );
53685389 return;
53695390 }
5391+ const sideQuestion = isServerThread ? parseSideQuestion(promptRef.current.trim()) : null;
MmacroscopeappBoton lines 5391–5391Aug 26, 2026

components/ChatView.tsx:5391

This side-question branch runs before the activePendingProgress branch at line 5490, and it reads promptRef.current, which is not the visible input while a pending user-input question is open. A leftover /btw … draft then hijacks the submit: a side question is asked and onAdvanceActivePendingUserInput() never runs, so the answer the user typed is silently dropped. Consider skipping the side-question path while a pending question owns the composer:

suggestion
1 const sideQuestion =
2 isServerThread && !activePendingProgress ? parseSideQuestion(promptRef.current.trim()) : null;

No longer relevant as of c8f9188f7c299abdcca3abbeaf22af9dbc475b64

Added 1 commit

  • fix(chat): harden btw concurrency and pending inputc8f9188

Added 2 commits

  • chore: retry PR checks5d3291f
  • docs(chat): explain concurrent btw requests3ea7e6a
Synced from GitHub

One finding on the new /btw slash-menu entry: it is still offered while a pending user-input question owns the composer, where the command is intentionally inert. Details inline.

Posted via Macroscope β€” UI Consistency

MmacroscopeappBotreviewed Β· 1 inline threadAug 26, 2026
Synced from GitHub

One finding on the new /btw composer surface. The side-answer card itself now matches the composer glass/Button contracts, so this comment is limited to the slash-menu gate.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/chat/ChatComposer.tsxlines 1150–1160Outdated Β· 3ea7e6aOpen thread
……@@ -1139,11 +1142,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1157+ description: "Ask a side question without interrupting the agent",
1158+ },
1159+ ]
1160+ : []),
MmacroscopeappBoton lines 1150–1160Aug 26, 2026

chat/ChatComposer.tsx:1160

The /btw menu entry is gated only on isServerThread, but the feature gate used everywhere else is isServerThread && !hasPendingUserInput (parseComposerSideQuestion). The slash menu is still open while a pending user-input question owns the editor (composerMenuOpen && !isComposerApprovalState only), and applyPromptReplacement writes into activePendingProgress.customAnswer, so picking this item inserts /btw into the agent's answer field and the text is then submitted verbatim as the answer β€” the opposite of the item's description.

Consider gating the item on the same condition, e.g. ...(isServerThread && activePendingProgress === null ? [...] : []), and adding activePendingProgress to the composerMenuItems dependency list.

No longer relevant as of 87abd2d9107611388d22c6d31c1b494a7e651001

Fixed in 87abd2d91. The slash menu now uses the same shared eligibility rule as parsing, includes pending input in the memo dependencies, and has regression coverage.

Added 1 commit

  • fix(web): hide btw during pending input87abd2d

Added 1 commit

  • docs(chat): note btw pending-input limit8961ec7
MmacroscopeappBotreviewed Β· 1 inline threadAug 26, 2026
Synced from GitHub

Two small consistency findings on the new web side-question UI. The earlier findings (pending-user-input gating, glass surface, height cap, collapsed action label, Button primitive for the close affordance) look addressed on this head.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/chat/ChatComposer.tsxlines 1867–1870Outdated Β· 8961ec7Open thread
……@@ -1830,6 +1857,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1867+ applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, {
1868+ expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd),
1869+ });
1870+ setComposerHighlightedItemId(null);
MmacroscopeappBoton lines 1867–1870Aug 26, 2026

chat/ChatComposer.tsx:1870

The other insertion branches in onSelectComposerItem (path, provider slash command, skill, plan/default) only clear the highlight when applyPromptReplacement returns true, so a stale-snapshot rejection (expectedText mismatch after the user kept typing) leaves the menu highlight where the user put it. This branch clears it unconditionally, so a rejected /btw insertion silently resets the highlight to the first item and the next Enter selects a different entry. Consider matching the surrounding contract.

suggestion
1 const applied = applyPromptReplacement(
2 trigger.rangeStart,
3 replacementRangeEnd,
4 replacement,
5 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
6 );
7 if (applied) {
8 setComposerHighlightedItemId(null);
9 }

No longer relevant as of 0a701f85837bf285c596f1747515a61a1915b8c5

Added 1 commit

  • fix(web): align btw interaction details0a701f8

Added 2 commits

  • feat(server): preserve btw follow-up context12d0677
  • feat(chat): add btw side conversations21fe66f
docs/user/side-questions.mdlines 14–15Outdated Β· 21fe66fOpen thread
……@@ -0,0 +1,28 @@
12+change what the main agent is doing.
13+
14+Matching questions from connected clients share one request. Different questions get separate answers.
15+
MmacroscopeappBoton lines 14–15Aug 27, 2026

🟒 Low user/side-questions.md:14

Identical questions from connected clients do not always share one request: when their prior side-conversation histories differ, server coalescing issues separate provider requests. Document that the prior side-conversation context must also match.

No longer relevant as of 8267d2a8132b05546dcc0a98fa199554b79e3443

Fixed in 8267d2a81. The docs now state that both the current thread context and prior side-conversation context must match before requests are shared.

apps/web/src/components/ChatView.tsxline 5379Outdated Β· 21fe66fOpen thread
……@@ -5327,6 +5366,93 @@ function ChatViewContent(props: ChatViewProps) {
5376+ ? []
5377+ : (sideQuestionState?.turns
5378+ .filter((turn) => turn.status === "success")
5379+ .map((turn) => ({ question: turn.question, answer: turn.answer })) ?? []);
MmacroscopeappBoton line 5379Aug 27, 2026

🟠 High components/ChatView.tsx:5379

previousTurns grows without bound, collecting every successful turn. The RPC input schema accepts at most 50 entries, so once the side-question panel accumulates 51 successful follow-ups the next call is rejected and the conversation cannot continue. Add .slice(-50) (or whichever server-side cap applies) before the ?? [] fallback so only the most recent turns are forwarded.

No longer relevant as of 41df32ab34f093005b5de326e3fb931d40cf0af5

MmacroscopeappBotreviewed Β· 1 inline threadAug 27, 2026
Synced from GitHub

Two UI-consistency findings in the web side-question surface. Everything else (right-panel surface wiring, Button/ScrollArea/Spinner primitive use, the minimized strip mirroring ComposerTasksDrawer, composer gating via canAskComposerSideQuestion, explicit environmentId/threadRef scoping) looks consistent with the existing contracts.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/ChatView.tsxlines 7214–7217Outdated Β· 21fe66fOpen thread
……@@ -7021,6 +7211,19 @@ function ChatViewContent(props: ChatViewProps) {
7214+ {sideQuestionState?.mode === "minimized" &&
7215+ latestSideQuestionTurn &&
7216+ activeThreadRef ? (
7217+ <SideQuestionMinimized
MmacroscopeappBoton lines 7214–7217Aug 27, 2026

SideQuestionMinimized mounts a second .chat-composer-top-drawer, but it sits outside the composer form where every other top drawer lives. ChatComposer deliberately keeps at most one attached: the banner/approval drawer is gated on showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) and ComposerTasksDrawer on isTasksDrawerOpen && !hasBlockingComposerTopDrawer, and index.css:919-923 only flattens the attachment seam for .chat-composer-drawer-slot + :has(.chat-composer-top-drawer).

So when the strip is minimized while an approval / pending-user-input drawer or the tasks drawer is open, two attachments stack and the lower one keeps its own border-radius: 16px 16px 0 0 plus top border (and, in dark mode, the seam gradient), painting a rounded outline in the middle of the glass stack instead of the flat seam used everywhere else.

Smallest fixes: either suppress the minimized strip while a composer top drawer is open (keeping the existing single-attachment invariant), or extend the seam-flattening rule to cover a top drawer that directly precedes another one.

Posted via Macroscope β€” UI Consistency

Added 1 commit

  • fix(chat): address btw review findings8267d2a
apps/mobile/src/features/threads/ThreadDetailScreen.tsxline 695Outdated Β· 8267d2aOpen thread
……@@ -520,7 +562,140 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
692+
693+ props.onChangeDraftMessage("");
694+ draftMessagesByThreadRef.current[selectedThreadKey] = "";
695+ await submitSideQuestion(sideQuestion, "new");
MmacroscopeappBoton line 695Aug 27, 2026

🟑 Medium threads/ThreadDetailScreen.tsx:695

handleSendMessage holds ThreadComposer.handleSend's per-thread in-flight lock until submitSideQuestion finishes, so a /btw RPC blocks every subsequent main-thread send or queue action until it completes. Invoke the side-question request without awaiting it so the composer lock is released immediately.

βœ… Resolved in 2427d5995ba36f7c4c67b22584888f54a1b154b7

MmacroscopeappBotreviewed Β· 1 inline threadAug 27, 2026
Synced from GitHub

One finding on the new side-question panel: the question bubble uses accent tokens where the rest of the app uses the semantic message-bubble role.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/SideQuestionPanel.tsxlines 59–59Outdated Β· 8267d2aOpen thread
……@@ -0,0 +1,146 @@
56+ <div className="space-y-5 p-4" aria-live="polite">
57+ {props.turns.map((turn) => (
58+ <div key={turn.id} className="space-y-2.5">
59+ <div className="ml-8 rounded-xl bg-accent px-3 py-2 text-sm text-foreground">
MmacroscopeappBoton lines 59–59Aug 27, 2026

components/SideQuestionPanel.tsx:59

This bubble is the same element as the timeline's user bubble (MessagesTimeline.tsx uses bg-message / text-message-foreground), but it reads its surface and text from different roles. The two match only in the default theme, where --message-surface: var(--accent); under a custom app theme --message-surface / --message-foreground resolve to --app-theme-message-* while --accent / --foreground resolve to the accent surface and base text, so the side-question bubble drifts to a different surface family and skips --contrast-message-foreground, which is the foreground derived against the message surface. Consider using the message tokens here.

suggestion
1 <div className="ml-8 rounded-xl bg-message px-3 py-2 text-message-foreground text-sm">

No longer relevant as of 8b4f94c9ed03330163d43030f029cd742825deaa

Added 2 commits

  • fix(server): project btw context payloads1b07c9c
  • fix(mobile): release composer after btw submit2427d59

Added 1 commit

  • fix(web): use message tokens for btw prompts8b4f94c

Added 1 commit

  • fix(mobile): preserve btw history on attachment errorsf439bf1
Synced from GitHub

Two findings in apps/web/src/components/SideQuestionPanel.tsx. Everything flagged in earlier runs (composer /btw gating, applyPromptReplacement highlight handling, collapsed primary-action label, Textarea height override, the raw close/minimize buttons, the nested Spinner live region, the second .chat-composer-top-drawer seam, and the bg-message / text-message-foreground bubble tokens) now looks resolved.

Posted via Macroscope β€” UI Consistency

Added 1 commit

  • fix(web): align btw follow-up input behaviora7a2337
MmacroscopeappBotreviewed Β· 1 inline threadAug 27, 2026
Synced from GitHub

One consistency finding: the minimized side-question strip attaches to the composer's top edge without being declared as an attachment, so ChatComposer still renders its shoulder tabs there.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/ChatView.tsxlines 7211–7214Outdated Β· a7a2337Open thread
……@@ -7021,6 +7208,19 @@ function ChatViewContent(props: ChatViewProps) {
7211+ {sideQuestionState?.mode === "minimized" &&
7212+ latestSideQuestionTurn &&
7213+ activeThreadRef ? (
7214+ <SideQuestionMinimized
MmacroscopeappBoton lines 7211–7214Aug 27, 2026

components/ChatView.tsx:7214

SideQuestionMinimized renders a .chat-composer-top-drawer, i.e. a surface attached to the composer's top edge (negative margin-bottom overlap, 16px top corners, masked seam), but externalComposerDrawerAttached (line 7006) is not aware of it. ChatComposer therefore still treats its top edge as free: showShoulderTabs stays true (ChatComposer.tsx:2549-2553), so the tasks/stash shoulder tabs keep rendering at -top-7 with their own narrower rounded-t-xl top border immediately under the strip's seam, and their inline fallbacks (showInlineStashBadge / showInlineTasksBadge, which exist precisely for the attached case) stay off. Since side questions are asked while a turn is running, a tasks or stash shoulder tab is usually present, so the two attachments collide on the same edge.

Smallest fix is to include the minimized strip in the existing attachment flag, which both suppresses the shoulder tabs and moves those badges inline:

diff
1 const externalComposerDrawerAttached =
2- composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
3+ composerBannerItems.length > 0 ||
4+ Boolean(threadSyncPhase && !activeEnvironmentUnavailable) ||
5+ (sideQuestionState?.mode === "minimized" && latestSideQuestionTurn !== null);

No longer relevant as of 4a8667cb15292969122a31f1af6970945b77d872

Added 1 commit

  • fix(web): reserve composer edge for minimized btw4a8667c
Synced from GitHub

One finding: the new side-question right-panel surface is persisted like a durable surface, but its content is view-local React state, so a restored session shows an empty tab until a repair effect closes it.

Posted via Macroscope β€” UI Consistency

Added 1 commit

  • fix(web): keep btw panel state session-onlyf7fd4b3

Added 1 commit

MmacroscopeappBotreviewed Β· 1 inline threadAug 27, 2026
Synced from GitHub

One finding, in the right-panel persistence change. The rest of the web surface (SideQuestionPanel, the minimized composer drawer, the chat-composer-top-drawer seam rule, RightPanelTabs title/icon, and the composer primary-action labels) matches existing precedent.

Posted via Macroscope β€” UI Consistency

apps/web/src/rightPanelStore.tslines 125–127Outdated Β· 1b1bda2Open thread
……@@ -120,6 +122,15 @@ interface RightPanelStoreState {
122124
125+const persistableThreadState = (current: ThreadRightPanelState): ThreadRightPanelState | null => {
126+ const surfaces = current.surfaces.filter((surface) => surface.kind !== "side-question");
127+ if (surfaces.length === 0) return null;
MmacroscopeappBoton lines 125–127Aug 27, 2026

src/rightPanelStore.ts:127

Bailing out whenever the filtered list is empty also drops thread states that never held a side-question surface. show() / toggleVisibility() (used by ChatView.toggleRightPanel) leave { isOpen: true, activeSurfaceId: null, surfaces: [] }, which renders the open panel with the "Open a surface" launcher; the previous partialize persisted that entry, so the panel reopened after a reload. With this helper the entry is skipped, so an open-but-empty right panel now silently closes itself on restart. Consider dropping the entry only when the surfaces it had were side-question-only.

suggestion
1const persistableThreadState = (current: ThreadRightPanelState): ThreadRightPanelState | null => {
2 const surfaces = current.surfaces.filter((surface) => surface.kind !== "side-question");
3 if (surfaces.length === 0) return current.surfaces.length === 0 ? current : null;

No longer relevant as of 0be5138c93d935d4df8e1b26a18df68caa8bd5fa

Fixed in 0be5138c9. Truly empty open-panel state is preserved, while side-question-only state is still omitted. Added a regression test; all 40 right-panel store tests pass.

Added 1 commit

  • fix(web): preserve empty right panel state0be5138
MmacroscopeappBotreviewed Β· 1 inline threadAug 27, 2026
Synced from GitHub

Reviewed the web UI changes for the /btw side-question surface (SideQuestionPanel, the minimized composer drawer, right-panel store/tabs, composer primary actions, index.css seam rule). One interaction-consistency issue on the follow-up composer; everything else lines up with the existing drawer/panel contracts (the minimized strip now feeds externalComposerDrawerAttached, the seam rule covers the stacked top-drawer case, and side-question surfaces are excluded from partialize without dropping open-but-empty panel state).

Posted via Macroscope β€” UI Consistency

apps/web/src/components/SideQuestionPanel.tsxlines 90–90Outdated Β· 0be5138Open thread
……@@ -0,0 +1,155 @@
87+ <Textarea
88+ size="sm"
89+ value={draft}
90+ disabled={pending}
MmacroscopeappBoton lines 90–90Aug 27, 2026

components/SideQuestionPanel.tsx:90

Disabling the field itself while a follow-up is in flight differs from the app's other text-entry composers: DiffCommentAnnotation (line 80-103) and PullRequestReviewAnnotation's reply box keep the Textarea editable and disable only the submit Button. Because a side question is a provider round-trip, the field greys out (has-disabled:opacity-64) and the browser blurs it the moment pending flips, so keyboard focus is lost after every Cmd/Ctrl+Enter and the next follow-up cannot be drafted while waiting. isCommentSubmitShortcut(event, draft, pending) already blocks submits while pending, and the button below is already gated on pending, so the field-level disabled isn't needed for correctness.

diff
1 value={draft}
2- disabled={pending}

No longer relevant as of 93b50bc825a90a81e43e004b53fde285903b5e02

Added 1 commit

  • fix(web): align btw panel with chat UI93b50bc
MmacroscopeappBotreviewed Β· 1 inline threadAug 27, 2026
Synced from GitHub

Reviewed the web UI changes for shared-primitive usage, Tailwind/CSS ownership, and per-thread state scoping. The composer-attachment CSS seam (index.css:920) and the externalComposerDrawerAttached wiring for the minimized strip now look correct, and the follow-up field reuses the shared isCommentSubmitShortcut contract. Three findings below, all in apps/web/src.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/ChatView.tsxlines 6879–6879Outdated Β· 93b50bcOpen thread
……@@ -6701,7 +6875,20 @@ function ChatViewContent(props: ChatViewProps) {
67036877 const rightPanelContent = activeThreadRef ? (
6704- activeRightPanelSurface?.kind === "preview" ? (
6878+ activeRightPanelSurface?.kind === "side-question" && sideQuestionState?.mode === "panel" ? (
6879+ <SideQuestionPanel
MmacroscopeappBoton lines 6879–6879Aug 27, 2026

components/ChatView.tsx:6879

SideQuestionPanel keeps its follow-up draft in local component state, but ChatViewContent is not remounted per thread (which is why sideQuestionsByThread / sideQuestionRequestRef are keyed by thread). Switching to another thread that also has mode === "panel" reuses this element, so thread A's unsent draft shows up in thread B's side conversation and would be submitted against thread B. The other thread-scoped surfaces in this block avoid that with a key (DiffPanel at line 6925, PullRequestDetailPanel at 6945). Consider keying this one too.

suggestion
1 <SideQuestionPanel
2 key={activeThreadKey}

No longer relevant as of 93ef91989c41360913e4f9b7948cc042c1f1d2bd

Added 1 commit

  • fix(web): polish btw follow-up interactions53d1203
Synced from GitHub

One finding in the follow-up composer's new keyboard handling. Everything else in this revision (the UserMessageActions extraction and its reuse in MessagesTimeline / SideQuestionPanel, and the MessageCopyButton reuse) matches the shared contracts.

Posted via Macroscope β€” UI Consistency

Added 2 commits

  • feat(chat): add side question run controlscd95985
  • fix(web): align side composer interactions93ef919
packages/contracts/src/rpc.tsline 927Outdated Β· 93ef919Open thread
……@@ -912,6 +915,24 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make(
924+ },
925+);
926+
927+export const WsOrchestrationCancelSideQuestionRpc = Rpc.make(
MmacroscopeappBoton line 927Aug 27, 2026

🟠 High src/rpc.ts:927

An authorized client can repeatedly cancel distinct unknown (threadId, requestId) pairs and grow the process-wide cancelledRequests set without bound, eventually exhausting server memory. The cancellation handler records every unknown pair and does not expire it unless a matching ask later arrives; restrict cancellation tracking to active requests or add bounded expiration/cleanup.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @packages/contracts/src/rpc.ts around line 927:
2
3An authorized client can repeatedly cancel distinct unknown `(threadId, requestId)` pairs and grow the process-wide `cancelledRequests` set without bound, eventually exhausting server memory. The cancellation handler records every unknown pair and does not expire it unless a matching ask later arrives; restrict cancellation tracking to active requests or add bounded expiration/cleanup.

Fixed in b193a2811 at the coordinator boundary. Unknown early-cancel IDs are now bounded to 1,024 entries, so the RPC cannot grow process memory without limit.

Added 11 commits

  • fix(chat): complete side question controlsb193a28
  • fix(chat): harden side question coordinationb345f2f
  • Merge branch 'main' into feat/btw-side-questionsc2a960f
  • fix(web): show side question run context8e154b9
  • fix(web): keep side context inside glass shellf502209
  • fix(web): compact side question context labels8703cd9
  • Merge remote-tracking branch 'origin/main' into feat/btw-side-questionsc346110
  • fix(server): preserve Claude text generation permissionsf9227b6
  • fix(web): limit btw command to prompt start6d0556a
  • refactor(web): share composer context labels3f4a36a
  • fix(web): align side question context seam5e81561

Added 6 commits

  • fix(web): align side question composer7756d62
  • refactor(web): share composer glass layers8c27f73
  • fix(clients): preserve side answers when stop fails9ba42bc
  • fix(web): align side question prompt texted367be
  • fix(mobile): submit side follow-ups on return41df32a
  • fix(web): use shared side panel headerd96408d

Note

πŸ€– GPT-5.6 Sol responding on behalf of Theo

We're closing this PR as we clean up the T3 Code backlog. Thank you for taking the time to put this together.

We are not adding a second ephemeral side-conversation protocol. It would need separate provider cost and context rules across server, web, and mobile without repairing the durable thread path.

If you believe we closed this in error, please reopen the PR and leave a comment explaining what we missed.

Closing as part of the open-PR backlog sweep (wave 2).

Reason: Theo already rejected a second ephemeral /btw protocol; close-intent comment left it open.

Reopen if this is still wanted and you’re willing to rebase onto current main.