From cae6c6627a323d1d33a340842b0eb384807daedb Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 14 Oct 2026 17:45:39 +0000 Subject: [PATCH 01/41] fix(server): only Claude progress updates before a tool call show as text (#18923) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../Adapters/ClaudeAdapterV2.test.ts | 54 ++++++++++++------- .../Adapters/ClaudeAdapterV2.ts | 50 ++++++++++++++++- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ec65e9a5ac5c..edaafff081e2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2863,10 +2863,29 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Summarized thinking can't tell a progress update from reasoning, so both stay Thoughts. - it.effect.each(["claude-opus-5-5", "claude-fable-5-1", "claude-opus-5", "claude-sonnet-4-6"])( - "keeps %s thinking right before a tool call as reasoning", - (model) => + // Summarized display can't mark a progress update, so only the second of two + // thinking blocks before a tool call becomes text; a lone block is reasoning. + const plan = "Planning the check"; + const update = "Found it: the memory file is the cause."; + it.effect.each([ + { + model: "claude-opus-5-5", + texts: [plan, update], + expected: ["reasoning", "assistant_message"], + }, + { + model: "claude-fable-5-1", + texts: [plan, update], + expected: ["reasoning", "assistant_message"], + }, + { model: "claude-opus-5", texts: [plan, update], expected: ["reasoning", "reasoning"] }, + { model: "claude-sonnet-4-6", texts: [plan, update], expected: ["reasoning", "reasoning"] }, + { model: "claude-opus-5-5", texts: [plan], expected: ["reasoning"] }, + { model: "claude-opus-5-5", texts: [plan, " \n "], expected: ["reasoning", "reasoning"] }, + { model: "claude-opus-5-5", texts: [" \n ", update], expected: ["reasoning", "reasoning"] }, + ] as const)( + "shows $model thinking $texts before a tool call as $expected", + ({ model, texts, expected }) => Effect.gen(function* () { const harness = yield* makeWakeHarness; yield* harness.runtime.startTurn( @@ -2888,16 +2907,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { message: { id: "reply-message", model, content }, }); const frames = [ - snapshot("reasoning-snapshot", [ - { type: "thinking", thinking: "Planning the check", signature: "sig" }, - ]), - snapshot("reply-snapshot", [ - { - type: "thinking", - thinking: "Found it: the memory file is the cause.", - signature: "sig", - }, - ]), + ...texts.map((thinking, index) => + snapshot(`thinking-snapshot-${index}`, [ + { type: "thinking", thinking, signature: "sig" }, + ]), + ), snapshot("tool-snapshot", [ { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } }, ]), @@ -2913,12 +2927,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { : [], ), ); + const items = [...latest.values()]; assert.deepEqual( - [...latest.values()].map((item) => [item.type, item.text]), - [ - ["reasoning", "Planning the check"], - ["reasoning", "Found it: the memory file is the cause."], - ], + items.map((item) => item.type), + [...expected], + ); + assert.deepEqual( + items.map((item) => item.text), + [...texts], ); }).pipe( Effect.scoped, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9a871c49d400..c84aff8913c8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1275,6 +1275,17 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content" return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); } +// Claude 5.1 and later return the text written ahead of a tool call as a +// thinking block, at most one per call, instead of a text block. Summarized +// display makes it look like reasoning, so only the second of two thinking +// blocks right before a tool call is treated as that text. +function claudeWritesUpdatesAsThinking(model: string): boolean { + const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model); + if (match === null) return false; + const major = Number(match[1]); + return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1); +} + // In SDK mode Claude reports `/goal` only through the transcript (its // `active_goal` event is remote-only): synthetic command output names the // goal, and each unmet evaluator check returns as Stop hook feedback. @@ -3016,6 +3027,13 @@ interface ActiveClaudeTurnContext { readonly nextBlockIndex: Map; readonly snapshotBlockIndex: Map; readonly snapshots: Set; + // The latest snapshot block, when it was non-empty thinking. + lastThinking: { + readonly messageId: string; + readonly itemId: string; + readonly text: string; + readonly followsThinking: boolean; + } | null; readonly blocks: Map< string, { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } @@ -6962,18 +6980,45 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // The SDK emits one assistant snapshot per completed content block. // Count thinking blocks separately so snapshots share the streamed ID. for (const block of message.message.content) { - if (block.type !== "thinking") continue; + if (block.type !== "thinking") { + const update = context.reasoning.lastThinking; + context.reasoning.lastThinking = null; + if ( + block.type === "tool_use" && + update !== null && + update.messageId === message.message.id && + update.followsThinking && + claudeWritesUpdatesAsThinking(message.message.model) + ) { + // Same item ID, so the update replaces the Thought in place. + yield* emitAssistantTextArtifacts({ + context, + nativeItemId: update.itemId, + text: update.text, + }); + } + continue; + } const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0; context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1); const itemId = `${message.message.id}:thinking:${index}`; yield* ensureReasoningBlock(context, itemId); const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text; - yield* reasoningDeltas.complete({ + const completedText = yield* reasoningDeltas.complete({ turnId: context.nativeTurnId, itemId, ...(finalText ? { finalText } : {}), emitEmpty: false, }); + const previous = context.reasoning.lastThinking; + context.reasoning.lastThinking = completedText.trim() + ? { + messageId: message.message.id, + itemId, + text: completedText, + followsThinking: previous !== null && previous.messageId === message.message.id, + } + : null; } } @@ -8699,6 +8744,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( streamBlocks: new Map(), nextBlockIndex: new Map(), snapshotBlockIndex: new Map(), + lastThinking: null, snapshots: new Set(), blocks: new Map(), }, From ce6f8ce36e240d1c615e2130fc017652cbc1ec58 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 14 Oct 2026 17:45:50 +0000 Subject: [PATCH 02/41] fix(pr): keep known state in sync across controls (#18408) --- apps/web/src/state/pullRequests.test.ts | 37 ++++++++++++- apps/web/src/state/pullRequests.ts | 73 ++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/apps/web/src/state/pullRequests.test.ts b/apps/web/src/state/pullRequests.test.ts index 42dc8c2fba6f..ae26e443c80d 100644 --- a/apps/web/src/state/pullRequests.test.ts +++ b/apps/web/src/state/pullRequests.test.ts @@ -2,7 +2,11 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type PullRequestSummary } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRequests"; +import { + newestPullRequestObservation, + newestPullRequestSummary, + threadPullRequestLinkToSummary, +} from "./pullRequests"; function summary(overrides: Partial = {}): PullRequestSummary { return { @@ -81,4 +85,35 @@ describe("pull request summary cache", () => { expect(newestPullRequestSummary(older, reopened)).toBe(reopened); expect(newestPullRequestSummary(reopened, older)).toBe(reopened); }); + + it("reuses saved thread state while detail is stale and lets newer detail replace it", () => { + const stale = summary(); + const link = { + host: "github.com", + repository: stale.repository, + number: stale.number, + url: stale.url, + source: "manual" as const, + linkedAt: stale.updatedAt, + stack: null, + snapshot: { + ...stale, + state: "closed" as const, + isDraft: false, + updatedAt: "2026-09-11T00:00:00Z", + syncedAt: "2026-09-12T00:00:00Z", + }, + }; + const saved = threadPullRequestLinkToSummary(link, stale.projectId); + expect(newestPullRequestSummary(stale, saved)?.state).toBe("closed"); + const fresh = summary({ isDraft: true, updatedAt: "2026-09-13T00:00:00Z" }); + expect(newestPullRequestSummary(fresh, saved)).toBe(fresh); + expect(threadPullRequestLinkToSummary({ ...link, snapshot: null }, stale.projectId)).toBeNull(); + expect( + threadPullRequestLinkToSummary( + { ...link, snapshot: { ...link.snapshot, updatedAt: null } }, + stale.projectId, + ), + ).toBeNull(); + }); }); diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index fa5a97f9ee05..a7f09aeae5b4 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, @@ -6,12 +7,18 @@ import { } from "@t3tools/client-runtime/state/pull-requests"; import type { EnvironmentId, + ProjectId, PullRequestListInput, PullRequestListStatsInput, PullRequestListEntry, PullRequestRef, PullRequestSummary, + ThreadPullRequestLink, } from "@t3tools/contracts"; +import { + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useCallback, useLayoutEffect, useMemo } from "react"; @@ -24,6 +31,7 @@ import { type MergedPullRequestList, } from "../components/pullRequest/pullRequestList.logic"; import { formatEnvironmentQueryError } from "./query"; +import { environmentThreadShells } from "./threads"; export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily( @@ -149,6 +157,58 @@ function observePullRequestSummary( }); } +export function threadPullRequestLinkToSummary( + link: ThreadPullRequestLink, + projectId: ProjectId, +): PullRequestSummary | null { + // Without the host update time a snapshot cannot be ordered against a fresh read, so it sits out. + const updatedAt = link.snapshot?.updatedAt; + if (link.snapshot === null || updatedAt == null) return null; + return { + ...link.snapshot, + provider: ( + sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined) + ).kind, + projectId, + repository: link.repository, + number: link.number, + url: link.url, + updatedAt, + }; +} + +// Saved thread snapshots by environment and host-qualified link, indexed once per shell update +// rather than per control. The host keeps a link to another forge from matching the project's PR. +function savedPullRequestKey( + environmentId: EnvironmentId, + reference: Pick, +): string { + return reference.host === undefined + ? "" + : `${environmentId}:${threadPullRequestKeyOf({ ...reference, host: reference.host })}`; +} + +const savedPullRequestSummariesAtom = Atom.make((get) => { + const summaries = new Map(); + for (const thread of get(environmentThreadShells.threadShellsAtom)) { + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + const summary = threadPullRequestLinkToSummary(link, thread.projectId); + if (summary === null) continue; + const key = savedPullRequestKey(thread.environmentId, link); + summaries.set(key, newestPullRequestSummary(summaries.get(key) ?? null, summary) ?? summary); + } + } + return summaries; +}); + +const savedPullRequestSummaryAtom = Atom.family((key: string) => + Atom.make((get) => get(savedPullRequestSummariesAtom).get(key) ?? null).pipe( + // Shell updates also resend unchanged links; keep their subscribers quiet. + Atom.withEquality((previous, next) => JSON.stringify(previous) === JSON.stringify(next)), + Atom.setIdleTTL(5 * 60_000), + ), +); + export function useSharedPullRequestSummary( environmentId: EnvironmentId | null, reference: PullRequestRef | null, @@ -161,17 +221,24 @@ export function useSharedPullRequestSummary( : pullRequestSummaryKey(environmentId, reference); const atom = observedPullRequestSummaryAtom(key); const observed = useAtomValue(atom); + const saved = useAtomValue( + savedPullRequestSummaryAtom( + environmentId === null || reference === null + ? "" + : savedPullRequestKey(environmentId, reference), + ), + ); useLayoutEffect(() => { if (environmentId === null || reference === null || current === null || observedAt === null) return; observePullRequestSummary(environmentId, reference, current, observedAt); }, [current, environmentId, reference, observedAt]); - return ( + const summary = newestPullRequestObservation( observed, current === null || observedAt === null ? null : { summary: current, observedAt }, - )?.summary ?? current - ); + )?.summary ?? current; + return newestPullRequestSummary(summary, saved); } export const pullRequestStackAtom = createPullRequestStackAtomFamily( connectionAtomRuntime, From 7897a695e351b7e8ebaa0d8c693660291af18286 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:49:00 -0700 Subject: [PATCH 03/41] fix(web): review summary only says optional when it is (#18451) --- .../pull-requests/PullRequestReviewSheet.tsx | 2 +- .../pullRequest/PullRequestReviewForm.tsx | 75 ++++++++++--------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx index a1495eb19f51..539775aa44d0 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx @@ -154,7 +154,7 @@ export function PullRequestReviewSheet({ /> } > - {reviewing && offered.length ? ( + {reviewing && offered.length > 1 ? ( ({ value: option.value, label: option.title }))} selected={verdict?.value ?? "comment"} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewForm.tsx b/apps/web/src/components/pullRequest/PullRequestReviewForm.tsx index f4359d3dfbdd..2449b7a7c944 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewForm.tsx @@ -116,10 +116,17 @@ export function PullRequestReviewForm({ }; // Forgejo requires a summary when requesting changes, even with inline comments. - const canSubmit = (verdict: PullRequestReviewVerdict) => + const canSubmit = (verdict: PullRequestReviewVerdict, summary: string) => verdict === "request-changes" && requestChangesSummaryRequired - ? body.trim().length > 0 - : verdict === "approve" || body.trim().length > 0 || comments.length > 0; + ? summary.trim().length > 0 + : verdict === "approve" || summary.trim().length > 0 || comments.length > 0; + // The placeholder asks for exactly what the submit button is waiting on. + const placeholder = + selectedVerdict === undefined || canSubmit(selectedVerdict.value, "") + ? "Summarize your review (optional)" + : selectedVerdict.value === "request-changes" && requestChangesSummaryRequired + ? "Summarize your review (required to request changes)" + : "Summarize your review or add line comments"; return ( <> @@ -127,42 +134,42 @@ export function PullRequestReviewForm({ ref={textareaRef} rows={3} value={body} - placeholder={ - requestChangesSummaryRequired && verdicts.includes("request-changes") - ? "Summarize your review (required to request changes)" - : "Summarize your review (optional)" - } + placeholder={placeholder} aria-label="Review summary" onChange={(event) => setSummary(reviewKey, event.target.value)} /> -
- +
+ {offered.length > 1 ? ( + + ) : null} + ) : ( + + {icon} + + )} +
+ +
+ {run.localVersion !== null ? ( +
+ This app + + {localStatusLabel(run, local, now)} + +
+ ) : onRestart ? ( +
+ This app + +
+ ) : null} + {machines.map(({ machine, status }) => ( +
+
+ {machine.label} + + {RUN_STATUS_LABELS[status]} + {status !== "done" && status !== "failed" && status !== "waiting" + ? ` ${formatUpdateElapsed(now - machine.startedAt)}` + : ""} + +
+ {machine.failure !== null ? ( + {machine.failure} + ) : null} +
+ ))} + {settled && failed ? ( +
+ {view.action === "retry" ? ( + + ) : null} + +
+ ) : null} +
+
+ + ); +} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 5f756069f98e..bcee391e106c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,23 +1,12 @@ -import { useAtomValue } from "@effect/atom-react"; import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; -import { Atom } from "effect/reactivity"; import { TriangleAlertIcon } from "lucide-react"; -import { - type ComponentProps, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react"; +import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; import { ensureLocalApi } from "../../localApi"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; -import { serverEnvironment } from "../../state/server"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { canCheckForUpdate, @@ -43,13 +32,14 @@ import { shouldShowDesktopUpdateCheckIcon, } from "./DesktopUpdateStatusIcon"; import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; +import { SidebarUpdateCapsule } from "./SidebarUpdateCapsule"; import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; +import type { UpdateEverythingRun, UpdateRunLocalState } from "./updateEverything.logic"; import { - describeUpdateRun, - resolveUpdateRunMachineStatus, - type UpdateEverythingRun, -} from "./updateEverything.logic"; -import { useUpdateEverythingRun } from "./updateEverythingRun"; + clearSavedUpdateEverythingResult, + saveUpdateEverythingResult, + useUpdateEverythingRun, +} from "./updateEverythingRun"; import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything"; type SidebarUpdatePopoverChangeDetails = Parameters< @@ -202,43 +192,6 @@ async function installLocalUpdate( } } -/** Tooltip lines for a running pass: this app first, then one per machine. */ -function useUpdateRunTooltip( - run: UpdateEverythingRun | null, - state: DesktopUpdateState | null, - localReady: boolean, -): string | null { - const statusesAtom = useMemo( - () => - Atom.make((get) => - run === null - ? [] - : run.machines.map((machine) => ({ - label: machine.label, - status: resolveUpdateRunMachineStatus( - machine, - run.phase, - get(serverEnvironment.updateStateAtom(machine.environmentId)), - ), - })), - ), - [run], - ); - const statuses = useAtomValue(statusesAtom); - if (run === null) return null; - const local = - run.localVersion === null - ? null - : state?.status === "downloading" - ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}` - : run.phase === "installing" - ? "restarting" - : localReady - ? "restarts last" - : "waiting"; - return describeUpdateRun(statuses, local).join("\n"); -} - /** * The sidebar update button. With only a local app update it downloads, then * installs. When other machines' servers or any providers are behind, one @@ -279,7 +232,6 @@ function SidebarUpdateControl() { // Providers leave the list once queued, so the run keeps its own state. const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; - const runTooltip = useUpdateRunTooltip(run, state, action === "install"); const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ isAnimationLatched: isCheckAnimationLatched, isChecking: state?.status === "checking", @@ -292,18 +244,15 @@ function SidebarUpdateControl() { isDownloading, showCheckIcon, }); - const tooltip = - runTooltip !== null - ? runTooltip - : runsEverything - ? everything.summary - : showUpdateDetails - ? state - ? getDesktopUpdateButtonTooltip(state) - : "Update available" - : showCheckIcon - ? "Checking for updates…" - : "Check for updates"; + const tooltip = runsEverything + ? everything.summary + : showUpdateDetails + ? state + ? getDesktopUpdateButtonTooltip(state) + : "Update available" + : showCheckIcon + ? "Checking for updates…" + : "Check for updates"; const disabled = showCheckIcon ? true : runsEverything @@ -312,7 +261,7 @@ function SidebarUpdateControl() { ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state); const isInteractionDisabled = disabled || isActionPending || isRunningEverything; - // During a run the tooltip shows its progress instead of release notes. + // During a run the capsule shows its progress instead of release notes. const showReleaseNotesPopover = !isRunningEverything && shouldUseSidebarUpdateReleaseNotesPopover(showUpdateDetails, state); @@ -442,6 +391,65 @@ function SidebarUpdateControl() { // Web has no local app to check, so it only shows the button for remote work. if (!isElectron && !runsEverything && !isRunningEverything) return null; + const statusIcon = ( + + ); + const confirmDialog = ( + void runEverything()} + /> + ); + + if (run !== null) { + const local: UpdateRunLocalState = + run.localVersion === null + ? null + : state?.status === "downloading" + ? { status: "downloading", percent: state.downloadPercent } + : { status: "waiting" }; + return ( + + { + // Like the countdown, keep following servers still restarting. + saveUpdateEverythingResult({ + localVersion: localUpdateVersion, + updatedServers: 0, + updatedProviders: 0, + failures: [], + restarting: [], + }); + if (!(await installLocalUpdate(window.desktopBridge!))) { + clearSavedUpdateEverythingResult(); + } + }, + } + : {})} + /> + {confirmDialog} + + ); + } + const updateButton = ( ); @@ -559,15 +561,7 @@ function SidebarUpdateControl() { ) : null} - void runEverything()} - /> + {confirmDialog} ); } diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 40d8af618679..8adf2fe5aac2 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -4,15 +4,38 @@ import { describe, expect, it } from "vite-plus/test"; import { collectRestartingMachines, collectServerUpdateTargets, + closeFinishedUpdateRun, collectUpdateEverythingResult, + describeUpdateCapsule, describeUpdateEverything, getUpdateEverythingToastView, resolveUpdateRunMachineStatus, runUpdateEverything, type UpdateEverythingMachine, + type UpdateEverythingRun, type UpdateRunMachine, + type UpdateRunMachineStatus, + UPDATE_SLOW_AFTER_MS, } from "./updateEverything.logic"; +const runMachine = (overrides: Partial = {}): UpdateRunMachine => ({ + environmentId: EnvironmentId.make("alvin"), + label: "alvin", + updatesProviders: true, + server: { + environmentId: EnvironmentId.make("alvin"), + serverLabel: "alvin", + selfUpdate: "boot-service", + targetVersion: "0.0.50", + }, + handedOffVersion: null, + startedAt: 0, + result: null, + failure: null, + providerFailure: null, + ...overrides, +}); + function machine( id: string, serverVersion: string, @@ -175,14 +198,6 @@ describe("runUpdateEverything", () => { }); describe("resolveUpdateRunMachineStatus", () => { - const runMachine = (overrides: Partial = {}): UpdateRunMachine => ({ - environmentId: EnvironmentId.make("alvin"), - label: "alvin", - updatesProviders: true, - updatesServer: true, - result: null, - ...overrides, - }); const running = (stage: "downloading" | "installing" | "resuming") => ({ status: "running", stage, fromVersion: "0.0.40", targetVersion: "0.0.50" }) as const; @@ -206,6 +221,92 @@ describe("resolveUpdateRunMachineStatus", () => { expect(resolveUpdateRunMachineStatus(runMachine({ result: "failed" }), "servers", idle)).toBe( "failed", ); + // After this app relaunched, a handed-off server has no live update state. + expect( + resolveUpdateRunMachineStatus( + runMachine({ server: null, handedOffVersion: "0.0.50" }), + "servers", + idle, + ), + ).toBe("restarting"); + }); +}); + +describe("update capsule", () => { + const run = (overrides: Partial = {}): UpdateEverythingRun => ({ + localVersion: null, + phase: "servers", + restartAt: null, + machines: [], + ...overrides, + }); + const view = ( + label: string, + status: UpdateRunMachineStatus, + overrides: Partial = {}, + ) => ({ + machine: runMachine({ environmentId: EnvironmentId.make(label), label, ...overrides }), + status, + }); + + it("counts down this app's restart before anything else", () => { + expect( + describeUpdateCapsule( + run({ phase: "countdown", restartAt: 5_000, localVersion: "0.0.50" }), + [view("alvin", "restarting")], + { status: "waiting" }, + 1_200, + ), + ).toEqual({ tone: "default", label: "Restart in 4s", action: "later" }); + }); + + it("names the slowest machine once it passes the slow threshold", () => { + const machines = [ + view("bb-1", "done"), + view("zbook", "downloading", { startedAt: 60_000 }), + view("alvin", "restarting", { startedAt: 0 }), + ]; + expect(describeUpdateCapsule(run(), machines, null, 60_000)).toEqual({ + tone: "default", + label: "1 of 3 updated", + action: null, + }); + expect(describeUpdateCapsule(run(), machines, null, UPDATE_SLOW_AFTER_MS + 6 * 60_000)).toEqual( + { + tone: "slow", + label: "alvin", + slow: { status: "restarting", elapsed: "9m" }, + action: null, + }, + ); + }); + + it("offers a retry only for failed servers it can update again", () => { + expect(describeUpdateCapsule(run(), [view("alvin", "failed")], null, 0)).toEqual({ + tone: "failed", + label: "alvin failed", + action: "retry", + }); + expect( + describeUpdateCapsule( + run(), + [view("alvin", "failed", { server: null }), view("box", "failed", { server: null })], + null, + 0, + ), + ).toEqual({ tone: "failed", label: "2 failed", action: "dismiss" }); + }); + + it("ends the run only when every machine is done and this app is not installing", () => { + const done = runMachine({ result: "done" }); + expect(closeFinishedUpdateRun(run({ machines: [done] }))).toBeNull(); + expect( + closeFinishedUpdateRun(run({ machines: [done], localVersion: "0.0.50" })), + ).not.toBeNull(); + expect( + closeFinishedUpdateRun(run({ machines: [runMachine({ result: "failed" })] })), + ).not.toBeNull(); + expect(closeFinishedUpdateRun(run({ machines: [done], phase: "providers" }))).not.toBeNull(); }); }); @@ -231,6 +332,7 @@ describe("update everything summary", () => { localVersion: "0.0.50", updatedServers: 2, updatedProviders: 1, + restarting: [], failures: [ "This app: Could not download update: Network error.", "alvin: Download failed.", @@ -256,12 +358,25 @@ describe("update everything summary", () => { }); it("reports a failure when nothing updated, and nothing when nothing finished", () => { - const empty = { localVersion: null, updatedServers: 0, updatedProviders: 0 }; + const empty = { localVersion: null, updatedServers: 0, updatedProviders: 0, restarting: [] }; expect( getUpdateEverythingToastView({ ...empty, failures: ["alvin: offline"] }, "0.0.50"), ).toEqual({ type: "error", title: "Update failed", description: "alvin: offline" }); expect(getUpdateEverythingToastView({ ...empty, failures: [] }, "0.0.50")).toBeNull(); }); + + it("names servers still restarting when this app relaunched", () => { + expect( + getUpdateEverythingToastView( + { ...result, failures: [], restarting: ["alvin", "zbook"] }, + "0.0.50", + ), + ).toEqual({ + type: "success", + title: "Updated this app, 2 servers, and 1 provider", + description: "alvin and zbook are still restarting.", + }); + }); }); describe("describeUpdateEverything", () => { diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index 89314e2d9459..ac80b406b6c6 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -186,17 +186,35 @@ export interface UpdateRunMachine { readonly environmentId: EnvironmentId; readonly label: string; readonly updatesProviders: boolean; - readonly updatesServer: boolean; + /** The server update this machine runs, kept so a failure can be retried. + Null for a provider-only machine, or a server this app handed off before + it relaunched. */ + readonly server: ServerUpdateTarget | null; + /** The version a handed-off server comes back on. This app relaunched while + that server restarted, so its version is the only signal left. */ + readonly handedOffVersion: string | null; + /** When its current update started, for the elapsed time. */ + readonly startedAt: number; /** Set when every update for this machine has finished. */ readonly result: "done" | "failed" | null; + /** Why it failed, when `result` is "failed". */ + readonly failure: string | null; + /** This machine's provider failure, kept apart so a server retry does not clear it. */ + readonly providerFailure: string | null; } -/** A running "update everything" pass, kept while it runs. */ +/** + * A running "update everything" pass, kept until every machine finished. A + * failed machine keeps it open, so the sidebar can offer a retry. + */ export interface UpdateEverythingRun { - /** The version this app installs last, or null when it does not install. */ + /** The version this app installs, or null when it does not install (or the + user chose to restart later). */ readonly localVersion: string | null; - /** Providers update first. Servers update after every provider finished. */ - readonly phase: "providers" | "servers" | "installing"; + /** Providers update first, then servers. "countdown" waits `restartAt` to + relaunch this app, which can still be postponed. */ + readonly phase: "providers" | "servers" | "countdown" | "installing"; + readonly restartAt: number | null; readonly machines: ReadonlyArray; } @@ -207,14 +225,117 @@ export function resolveUpdateRunMachineStatus( serverState: ServerUpdateState, ): UpdateRunMachineStatus { if (machine.result !== null) return machine.result; + if (machine.handedOffVersion !== null) return "restarting"; if (phase === "providers" && machine.updatesProviders) return "providers"; - if (machine.updatesServer && serverState.status === "running") { + if (machine.server !== null && serverState.status === "running") { return serverState.stage === "resuming" ? "restarting" : "downloading"; } return "waiting"; } -const RUN_STATUS_LABELS: Record = { +/** A server is handed off once it restarts into its update: it finishes + without this app, so this app can relaunch. */ +export function isServerUpdateHandedOff(state: ServerUpdateState): boolean { + return state.status === "running" && state.stage === "resuming"; +} + +/** Ends the run once nothing is left to show: every machine done and this + app not installing. Failures keep it open. */ +export function closeFinishedUpdateRun(run: UpdateEverythingRun): UpdateEverythingRun | null { + return run.phase === "servers" && + run.localVersion === null && + run.machines.every((machine) => machine.result === "done") + ? null + : run; +} + +/** An update this long is slower than usual, so the sidebar points it out. */ +export const UPDATE_SLOW_AFTER_MS = 3 * 60 * 1000; + +/** Elapsed time in whole minutes, such as "6m" or "1h 4m". */ +export function formatUpdateElapsed(ms: number): string { + const minutes = Math.floor(ms / 60_000); + if (minutes < 1) return "<1m"; + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +export interface UpdateRunMachineView { + readonly machine: UpdateRunMachine; + readonly status: UpdateRunMachineStatus; +} + +/** This app's own part of a run, for the sidebar. */ +export type UpdateRunLocalState = + | { readonly status: "downloading"; readonly percent: number | null } + | { readonly status: "waiting" } + | null; + +/** What the expanded sidebar update button says, and its inline action. */ +export interface UpdateCapsuleView { + readonly tone: "default" | "slow" | "failed"; + readonly label: string; + /** For a slow machine, its status and elapsed time, such as "restarting" and "6m". */ + readonly slow?: { readonly status: string; readonly elapsed: string }; + readonly action: "later" | "retry" | "dismiss" | null; +} + +const isActive = (status: UpdateRunMachineStatus) => status !== "done" && status !== "failed"; + +/** + * The one line the expanded update button shows for a run, most urgent + * first: this app's restart countdown, a slow machine, overall progress, and + * last any failure once nothing else is running. + */ +export function describeUpdateCapsule( + run: UpdateEverythingRun, + machines: ReadonlyArray, + local: UpdateRunLocalState, + now: number, +): UpdateCapsuleView { + if (run.phase === "countdown" && run.restartAt !== null) { + const seconds = Math.max(0, Math.ceil((run.restartAt - now) / 1000)); + return { tone: "default", label: `Restart in ${seconds}s`, action: "later" }; + } + if (run.phase === "installing") { + return { tone: "default", label: "Restarting…", action: null }; + } + const active = machines.filter(({ status }) => isActive(status) && status !== "waiting"); + const slowest = active.toSorted((a, b) => a.machine.startedAt - b.machine.startedAt)[0]; + if (slowest && now - slowest.machine.startedAt >= UPDATE_SLOW_AFTER_MS) { + return { + tone: "slow", + label: slowest.machine.label, + slow: { + status: RUN_STATUS_LABELS[slowest.status], + elapsed: formatUpdateElapsed(now - slowest.machine.startedAt), + }, + action: null, + }; + } + const done = machines.filter(({ status }) => status === "done").length; + if (machines.some(({ status }) => isActive(status))) { + return { tone: "default", label: `${done} of ${machines.length} updated`, action: null }; + } + if (local?.status === "downloading") { + return { + tone: "default", + label: `Downloading${local.percent === null ? "" : ` ${Math.floor(local.percent)}%`}`, + action: null, + }; + } + const failed = machines.filter(({ status }) => status === "failed"); + if (failed.length > 0) { + return { + tone: "failed", + label: failed.length === 1 ? `${failed[0]!.machine.label} failed` : `${failed.length} failed`, + action: failed.some(({ machine }) => machine.server !== null) ? "retry" : "dismiss", + }; + } + return { tone: "default", label: `${done} of ${machines.length} updated`, action: null }; +} + +export const RUN_STATUS_LABELS: Record = { providers: "updating providers", waiting: "waiting", downloading: "downloading", @@ -223,22 +344,6 @@ const RUN_STATUS_LABELS: Record = { failed: "failed", }; -/** - * Tooltip lines for a running pass, such as "alvin: restarting". `local` is - * this app's own state, such as "downloading 40%", or null when it does not - * install. - */ -export function describeUpdateRun( - machines: ReadonlyArray<{ readonly label: string; readonly status: UpdateRunMachineStatus }>, - local: string | null, -): ReadonlyArray { - return [ - "Updating…", - ...(local === null ? [] : [`This app: ${local}`]), - ...machines.map((machine) => `${machine.label}: ${RUN_STATUS_LABELS[machine.status]}`), - ]; -} - /** What one pass did. It is saved before this app installs, so it outlives the relaunch. */ export interface UpdateEverythingResult { /** The version this app installed last, or null when it did not install. */ @@ -247,6 +352,8 @@ export interface UpdateEverythingResult { readonly updatedProviders: number; /** One line per failure, each naming its machine. */ readonly failures: ReadonlyArray; + /** Servers still restarting into their update when this app relaunched. */ + readonly restarting: ReadonlyArray; } /** Counts what a pass updated and lists each failure with its machine. */ @@ -267,6 +374,7 @@ export function collectUpdateEverythingResult(input: { localVersion: input.localVersion, updatedServers: input.servers.filter(({ outcome }) => outcome.status === "updated").length, updatedProviders: input.providers.filter((outcome) => outcome.status === "updated").length, + restarting: [], failures: [ ...input.localFailures.map((failure) => `This app: ${failure}`), ...input.servers.flatMap(({ label, outcome }) => @@ -297,13 +405,24 @@ export function getUpdateEverythingToastView( countLabel(result.updatedServers, "server"), countLabel(result.updatedProviders, "provider"), ].filter((part) => part !== null); + const restarting = + result.restarting.length === 0 + ? [] + : [ + `${joinList(result.restarting)} ${result.restarting.length === 1 ? "is" : "are"} still restarting.`, + ]; if (result.failures.length === 0) { - return parts.length === 0 ? null : { type: "success", title: `Updated ${joinList(parts)}` }; + if (parts.length === 0) return null; + return { + type: "success", + title: `Updated ${joinList(parts)}`, + ...(restarting.length === 0 ? {} : { description: restarting.join("\n") }), + }; } return { type: "error", title: parts.length === 0 ? "Update failed" : `Updated ${joinList(parts)}`, - description: result.failures.join("\n"), + description: [...result.failures, ...restarting].join("\n"), }; } diff --git a/apps/web/src/components/sidebar/updateEverythingRun.tsx b/apps/web/src/components/sidebar/updateEverythingRun.tsx index 8fe73b9da13b..1e7a24fb9819 100644 --- a/apps/web/src/components/sidebar/updateEverythingRun.tsx +++ b/apps/web/src/components/sidebar/updateEverythingRun.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { Atom } from "effect/reactivity"; @@ -8,11 +8,17 @@ import { useEffect } from "react"; import { APP_VERSION } from "~/branding"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useDesktopUpdateState } from "~/state/desktopUpdate"; +import { serverEnvironment } from "~/state/server"; +import { resolveVersionMismatch } from "~/versionSkew"; +import type { ServerUpdateOutcome, ServerUpdateTarget } from "../ServerUpdateAction"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { + closeFinishedUpdateRun, getUpdateEverythingToastView, + isServerUpdateHandedOff, type UpdateEverythingResult, type UpdateEverythingRun, + type UpdateRunMachine, } from "./updateEverything.logic"; /** The running "update everything" pass, or null. Outlives the sidebar button. */ @@ -32,24 +38,148 @@ export function setUpdateEverythingRun( appAtomRegistry.update(updateEverythingRunAtom, update); } -/** Records the result of every update for one machine in the running pass. */ -export function settleUpdateRunMachine(environmentId: EnvironmentId, failed: boolean): void { +function updateRunMachines(update: (machine: UpdateRunMachine) => UpdateRunMachine): void { + setUpdateEverythingRun( + (run) => run && closeFinishedUpdateRun({ ...run, machines: run.machines.map(update) }), + ); +} + +/** Records the result of every update for one machine. `failure` is null when + it succeeded. A server that updated is no longer retried, even when the + machine failed for its providers. The run ends once every machine is done. */ +export function settleUpdateRunMachine( + environmentId: EnvironmentId, + failure: string | null, + { + serverUpdated = false, + providerFailure, + }: { readonly serverUpdated?: boolean; readonly providerFailure?: string | null } = {}, +): void { + updateRunMachines((machine) => + machine.environmentId === environmentId + ? { + ...machine, + result: failure === null ? "done" : "failed", + failure, + ...(serverUpdated ? { server: null } : {}), + ...(providerFailure === undefined ? {} : { providerFailure }), + } + : machine, + ); +} + +/** Retries each failed server update in the run. */ +export function retryFailedUpdateRunServers( + update: ( + target: ServerUpdateTarget, + options: { readonly quiet: boolean }, + ) => Promise, +): void { + const failed = (appAtomRegistry.get(updateEverythingRunAtom)?.machines ?? []).flatMap( + (machine) => + machine.result === "failed" && machine.server !== null + ? [{ target: machine.server, providerFailure: machine.providerFailure }] + : [], + ); + const targets = failed.map(({ target }) => target); + const retried = new Set(targets.map((target) => target.environmentId)); + const startedAt = Date.now(); + updateRunMachines((machine) => + retried.has(machine.environmentId) + ? { ...machine, result: null, failure: null, startedAt } + : machine, + ); + for (const { target, providerFailure } of failed) { + void update(target, { quiet: true }).then((outcome) => + settleUpdateRunMachine( + target.environmentId, + // A skipped retry (no permission, disconnected, interrupted) did not update. + outcome.status === "updated" + ? providerFailure + : outcome.status === "failed" + ? outcome.message + : "Could not start the update", + { serverUpdated: outcome.status === "updated" }, + ), + ); + } +} + +/** Drops the finished machines, which ends a run that only kept failures. */ +export function dismissSettledUpdateRunMachines(): void { setUpdateEverythingRun( (run) => - run && { + run && + closeFinishedUpdateRun({ ...run, - machines: run.machines.map((machine) => - machine.environmentId === environmentId - ? { ...machine, result: failed ? "failed" : "done" } - : machine, - ), - }, + machines: run.machines.filter((machine) => machine.result === null), + }), ); } +/** Resolves once a server restarts into its update, or its update `finished` first. */ +export function waitForServerUpdateHandoff( + environmentId: EnvironmentId, + finished: Promise, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + queueMicrotask(() => unsubscribe()); + resolve(); + }; + const unsubscribe = appAtomRegistry.subscribe( + serverEnvironment.updateStateAtom(environmentId), + (state) => { + if (isServerUpdateHandedOff(state)) finish(); + }, + { immediate: true }, + ); + void finished.then(finish, finish); + }); +} + +let decideRestart: ((restart: boolean) => void) | null = null; + +/** + * Counts down to this app's relaunch. Resolves true when the countdown ends, + * or false when the user chose to restart later with {@link postponeUpdateRestart}. + */ +export function waitForUpdateRestart(delayMs: number): Promise { + return new Promise((resolve) => { + const decide = (restart: boolean) => { + clearTimeout(timer); + decideRestart = null; + resolve(restart); + }; + const timer = setTimeout(() => decide(true), delayMs); + decideRestart = decide; + setUpdateEverythingRun( + (run) => run && { ...run, phase: "countdown", restartAt: Date.now() + delayMs }, + ); + }); +} + +/** Keeps this app on its version. Its downloaded update waits on the update button. */ +export function postponeUpdateRestart(): void { + decideRestart?.(false); +} + const SAVED_RESULT_KEY = "t3code:update-everything-result:v1"; // A result older than this belongs to an install that never relaunched the app. const SAVED_RESULT_MAX_AGE_MS = 10 * 60 * 1000; +// Covers a background service's 30-minute trial start, like the server update itself. +const HANDOFF_TIMEOUT_MS = 32 * 60 * 1000; + +const HandedOffServer = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + targetVersion: Schema.String, + startedAt: Schema.Number, +}); +type HandedOffServer = typeof HandedOffServer.Type; const SavedResult = Schema.Struct({ savedAt: Schema.Number, @@ -57,15 +187,47 @@ const SavedResult = Schema.Struct({ updatedServers: Schema.Number, updatedProviders: Schema.Number, failures: Schema.Array(Schema.String), + // Older saved results have no handed-off servers. + handedOff: Schema.optional(Schema.Array(HandedOffServer)), }); const decodeSavedResult = Schema.decodeUnknownOption(Schema.fromJsonString(SavedResult)); -/** Keeps a result through this app's relaunch. Call it right before the install. */ +/** + * Keeps a result through this app's relaunch. Call it right before the + * install. Servers still restarting are saved too, so the sidebar keeps + * following them after the relaunch. + */ export function saveUpdateEverythingResult(result: UpdateEverythingResult): void { + const run = appAtomRegistry.get(updateEverythingRunAtom); + const handedOff = (run?.machines ?? []).flatMap((machine): HandedOffServer[] => { + if (machine.result !== null) return []; + const state = appAtomRegistry.get(serverEnvironment.updateStateAtom(machine.environmentId)); + // A desktop-managed server installs its own feed's version, which only its + // update state knows. A server followed from an earlier relaunch keeps its version. + const targetVersion = + machine.handedOffVersion ?? + (state.status === "running" ? state.targetVersion : machine.server?.targetVersion); + if (targetVersion === undefined) return []; + return [ + { + environmentId: machine.environmentId, + label: machine.label, + targetVersion, + startedAt: machine.startedAt, + }, + ]; + }); try { window.localStorage.setItem( SAVED_RESULT_KEY, - JSON.stringify({ ...result, savedAt: Date.now() } satisfies typeof SavedResult.Type), + JSON.stringify({ + savedAt: Date.now(), + localVersion: result.localVersion, + updatedServers: result.updatedServers, + updatedProviders: result.updatedProviders, + failures: result.failures, + handedOff, + } satisfies typeof SavedResult.Type), ); } catch { // Without storage the summary is lost with the relaunch; the update still runs. @@ -80,7 +242,7 @@ export function clearSavedUpdateEverythingResult(): void { } } -function takeSavedUpdateEverythingResult(): UpdateEverythingResult | null { +function takeSavedUpdateEverythingResult(): typeof SavedResult.Type | null { try { const raw = window.localStorage.getItem(SAVED_RESULT_KEY); if (raw === null) return null; @@ -111,19 +273,90 @@ export function showUpdateEverythingResult(result: UpdateEverythingResult): void ); } +/** + * Follows servers that were restarting when this app relaunched. Each is done + * once it reports its target version, or failed after the handoff timeout. + * One toast reports them all at the end. + */ +function followHandedOffServers(servers: ReadonlyArray): void { + // A server already past the timeout still shows, then fails on its first timer tick. + if (servers.length === 0) return; + setUpdateEverythingRun(() => ({ + localVersion: null, + phase: "servers", + restartAt: null, + machines: servers.map((server) => ({ + environmentId: server.environmentId, + label: server.label, + updatesProviders: false, + server: null, + handedOffVersion: server.targetVersion, + startedAt: server.startedAt, + result: null, + failure: null, + providerFailure: null, + })), + })); + let remaining = servers.length; + let updatedServers = 0; + const failures: string[] = []; + for (const server of servers) { + let settled = false; + const settle = (failure: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + queueMicrotask(() => unsubscribe()); + settleUpdateRunMachine(server.environmentId, failure); + if (failure === null) updatedServers += 1; + else failures.push(`${server.label}: ${failure}`); + remaining -= 1; + if (remaining === 0) { + showUpdateEverythingResult({ + localVersion: null, + updatedServers, + updatedProviders: 0, + failures, + restarting: [], + }); + } + }; + const timer = setTimeout( + () => settle(`Not back after ${HANDOFF_TIMEOUT_MS / 60_000} minutes`), + server.startedAt + HANDOFF_TIMEOUT_MS - Date.now(), + ); + const unsubscribe = appAtomRegistry.subscribe( + serverEnvironment.configValueAtom(server.environmentId), + (config) => { + if ( + config !== null && + resolveVersionMismatch(config.environment.serverVersion, server.targetVersion) === null + ) { + settle(null); + } + }, + { immediate: true }, + ); + } +} + let notifiedNotAppliedVersion: string | null = null; /** * Desktop launch notices for updates: the summary of a pass that ended with * this app's relaunch, and a warning when the last install did not apply. - * Mount once at the app root. + * Servers still restarting from that pass stay in the sidebar until they are + * back. Mount once at the app root. */ export function DesktopUpdateLaunchNotice() { const notAppliedVersion = useDesktopUpdateState()?.notAppliedVersion ?? null; useEffect(() => { - const result = takeSavedUpdateEverythingResult(); - if (result) showUpdateEverythingResult(result); + const saved = takeSavedUpdateEverythingResult(); + if (!saved) return; + const handedOff = saved.handedOff ?? []; + showUpdateEverythingResult({ ...saved, restarting: handedOff.map((server) => server.label) }); + followHandedOffServers(handedOff); }, []); useEffect(() => { diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index 4f1333ee2861..6a4ab4fe9c41 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -12,8 +12,9 @@ import { serverEnvironment } from "~/state/server"; import { environmentSession } from "~/state/session"; import { getProviderUpdateRunOutcome } from "../ProviderUpdateLaunchNotification.logic"; import { useProviderUpdates } from "../ProviderUpdatesAction"; -import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction"; +import { canUpdateServer, type ServerUpdateOutcome, useServerUpdate } from "../ServerUpdateAction"; import { + closeFinishedUpdateRun, collectRestartingMachines, collectServerUpdateTargets, collectUpdateEverythingResult, @@ -28,8 +29,13 @@ import { setUpdateEverythingRun, settleUpdateRunMachine, showUpdateEverythingResult, + waitForServerUpdateHandoff, + waitForUpdateRestart, } from "./updateEverythingRun"; +/** How long this app counts down before it relaunches, so the user can postpone it. */ +const UPDATE_RESTART_DELAY_MS = 5_000; + /** Where this app's own download or install reports a failure. */ export type ReportLocalUpdateError = (title: string, description: string) => void; @@ -40,7 +46,9 @@ export type ReportLocalUpdateError = (title: string, description: string) => voi * only once npm confirms that version. `run` performs the whole pass, including * this app's own download and install when given. It updates quietly, tracks * each machine in the run state, and reports one summary toast at the end. - * When this app installs, the summary waits for the relaunch. + * This app relaunches once every server restarts into its update, after a + * short countdown the user can postpone. The summary then waits for the + * relaunch, and servers still restarting are followed after it. */ export function useUpdateEverything(localUpdateVersion: string | null) { const targetVersion = localUpdateVersion ?? APP_VERSION; @@ -126,14 +134,19 @@ export function useUpdateEverything(localUpdateVersion: string | null) { const reportLocalError: ReportLocalUpdateError = (title, description) => { localFailures.push(`${title}: ${description}`); }; + const startedAt = Date.now(); const machinesById = new Map(); for (const machine of providers.machines) { machinesById.set(machine.environmentId, { environmentId: machine.environmentId, label: machine.label, updatesProviders: true, - updatesServer: false, + server: null, + handedOffVersion: null, + startedAt, result: null, + failure: null, + providerFailure: null, }); } for (const server of servers) { @@ -142,17 +155,23 @@ export function useUpdateEverything(localUpdateVersion: string | null) { environmentId: server.environmentId, label: server.serverLabel, updatesProviders: existing?.updatesProviders ?? false, - updatesServer: true, + server, + handedOffVersion: null, + startedAt, result: null, + failure: null, + providerFailure: null, }); } const localVersion = installLocal ? localUpdateVersion : null; setUpdateEverythingRun(() => ({ localVersion, phase: "providers", + restartAt: null, machines: Array.from(machinesById.values()), })); - const failedProviderMachines = new Set(); + const providerFailures = new Map(); + const finishedServers: Array<{ label: string; outcome: ServerUpdateOutcome }> = []; try { const outcome = await runUpdateEverything({ ...(downloadLocal ? { downloadLocal: () => downloadLocal(reportLocalError) } : {}), @@ -161,49 +180,94 @@ export function useUpdateEverything(localUpdateVersion: string | null) { const outcomes = runs.map((run) => { const runOutcome = getProviderUpdateRunOutcome(run); // An interrupted update did not finish either, so its row is not "done". - if (runOutcome.status !== "updated") failedProviderMachines.add(run.environmentId); + if (runOutcome.status !== "updated") { + providerFailures.set( + run.environmentId, + runOutcome.status === "failed" ? runOutcome.line : "Provider update interrupted", + ); + } return runOutcome; }); for (const machine of machinesById.values()) { - if (!machine.updatesServer) { + if (machine.server === null) { settleUpdateRunMachine( machine.environmentId, - failedProviderMachines.has(machine.environmentId), + providerFailures.get(machine.environmentId) ?? null, ); } } - setUpdateEverythingRun((run) => run && { ...run, phase: "servers" }); + const serversStartedAt = Date.now(); + setUpdateEverythingRun( + (run) => + run && { + ...run, + phase: "servers", + machines: run.machines.map((machine) => + machine.server === null ? machine : { ...machine, startedAt: serversStartedAt }, + ), + }, + ); return outcomes; }, - updateServers: () => - Promise.all( - servers.map(async (server) => { - const serverOutcome = await updateServer(server, { quiet: true }); - settleUpdateRunMachine( - server.environmentId, - serverOutcome.status === "failed" || - failedProviderMachines.has(server.environmentId), - ); - return { label: server.serverLabel, outcome: serverOutcome }; - }), - ), + updateServers: async () => { + const runs = servers.map(async (server) => { + const serverOutcome = await updateServer(server, { quiet: true }); + settleUpdateRunMachine( + server.environmentId, + serverOutcome.status === "failed" + ? serverOutcome.message + : (providerFailures.get(server.environmentId) ?? null), + { + serverUpdated: serverOutcome.status !== "failed", + providerFailure: providerFailures.get(server.environmentId) ?? null, + }, + ); + const finished = { label: server.serverLabel, outcome: serverOutcome }; + finishedServers.push(finished); + return finished; + }); + // Once a server restarts into its update it finishes on its own, + // so this app does not wait for it to come back. + await Promise.all( + servers.map((server, index) => + waitForServerUpdateHandoff(server.environmentId, runs[index]!), + ), + ); + return runs; + }, }); - const result = () => + const result = (finished: ReadonlyArray<{ label: string; outcome: ServerUpdateOutcome }>) => collectUpdateEverythingResult({ localVersion: null, localFailures, - servers: outcome.servers, + servers: finished, providers: outcome.providers, }); if (outcome.localReady && installLocal) { - setUpdateEverythingRun((run) => run && { ...run, phase: "installing" }); - saveUpdateEverythingResult({ ...result(), localVersion }); - if (await installLocal(reportLocalError)) return; - clearSavedUpdateEverythingResult(); + if (await waitForUpdateRestart(UPDATE_RESTART_DELAY_MS)) { + setUpdateEverythingRun( + (run) => run && { ...run, phase: "installing", restartAt: null }, + ); + saveUpdateEverythingResult({ ...result(finishedServers), localVersion }); + if (await installLocal(reportLocalError)) return; + clearSavedUpdateEverythingResult(); + } } - showUpdateEverythingResult(result()); - } finally { + // This app is not relaunching, so the pass waits for every server. + setUpdateEverythingRun( + (run) => + run && + closeFinishedUpdateRun({ + ...run, + phase: "servers", + restartAt: null, + localVersion: null, + }), + ); + showUpdateEverythingResult(result(await Promise.all(outcome.servers))); + } catch (error) { setUpdateEverythingRun(() => null); + throw error; } }, }; diff --git a/docs/user/updating.md b/docs/user/updating.md index 9ecac765f9f4..a863f62fdeac 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -9,12 +9,20 @@ notice. The update button at the bottom of the sidebar updates every connected machine in one step. It updates outdated providers, then servers that are behind, then -the desktop app you are using, which restarts last. Servers move to the +the desktop app you are using. Servers move to the version your desktop app is installing, or to your web app's version. A background service stays on its current release channel. A remote desktop app installs the latest release from its own channel. Hover the button to see what it will update. +While it runs, the button widens to show progress. It names a machine that is +taking longer than usual, and offers **Retry** for a server that failed. Click +it to see every machine. The desktop app restarts once each server has started +its own restart, after a short countdown. Choose **Later** to keep working and +restart from the update button when you are ready. A server still restarting +finishes on its own, and the button keeps showing it after the desktop app +restarts. + Server updates appear only after that version is published to npm. Remote desktop apps do not wait for npm. The confirmation lists each machine it skips and why, such as a machine that is offline or needs a manual update. Use the diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 330df548ba0f..b55b09d38340 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -320,6 +320,13 @@ describe("server state projection", () => { fromVersion: "0.0.30", targetVersion: "0.0.31", }); + // A prepared desktop app update restarts only after the client commits it. + expect( + serverUpdateStateForProgressEvent("0.0.30", "0.0.31", { + type: "complete", + result: { targetVersion: "0.0.31", method: "desktop-app", desktopUpdateToken: "token" }, + }).stage, + ).toBe("installing"); }); it("keeps active update state and hides stale failures after a version change", () => { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index cc2f21b7271d..243d88c11dce 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -283,6 +283,11 @@ export const waitForDesktopUpdateTarget = Effect.fn("waitForDesktopUpdateTarget" }); }); +/** + * "resuming" means the server has its update and restarts without this + * client. A desktop app update is not there yet when its prepare step + * completes: the client still has to commit it, so it stays "installing". + */ export function serverUpdateStateForProgressEvent( fromVersion: string, targetVersion: string, @@ -290,7 +295,12 @@ export function serverUpdateStateForProgressEvent( ): Extract { return { status: "running", - stage: event.type === "complete" ? "resuming" : event.stage, + stage: + event.type !== "complete" + ? event.stage + : event.result.method === "desktop-app" && event.result.desktopUpdateToken !== undefined + ? "installing" + : "resuming", fromVersion, targetVersion, }; @@ -753,11 +763,13 @@ export function createServerEnvironmentAtoms( .pipe( Stream.runForEach((event) => Effect.sync(() => { - currentStage = event.type === "complete" ? "resuming" : event.stage; - atomRegistry.set( - stateAtom, - serverUpdateStateForProgressEvent(fromVersion, targetVersion, event), + const state = serverUpdateStateForProgressEvent( + fromVersion, + targetVersion, + event, ); + currentStage = state.stage; + atomRegistry.set(stateAtom, state); }).pipe( Effect.andThen( event.type === "complete" From 45b5993a527fd51813393c3bafcddab430b3a8e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 03:19:39 -0700 Subject: [PATCH 33/41] fix(web): text generation no longer falls back to the expensive flagship model (#19722) Co-authored-by: Claude Opus 5.5 --- apps/web/src/modelSelection.test.ts | 19 +++++++++++++++++++ apps/web/src/modelSelection.ts | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index cf1bf389dd19..ca00cd9b5420 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,5 +1,6 @@ import { ANTIGRAVITY_DEFAULT_MODEL, + DEFAULT_TEXT_GENERATION_MODEL, ProviderDriverKind, ProviderInstanceId, type ServerProvider, @@ -835,6 +836,24 @@ describe("instance-scoped model selection", () => { ProviderInstanceId.make("codex"), ); }); + it("falls back to the text generation default, not the chat default, for a removed model", () => { + const codex = provider({ instanceId: "codex", models: ["gpt-6-astra", "gpt-6-luna"] }); + const providers = [ + { ...codex, models: codex.models.map((m, i) => (i === 0 ? { ...m, isDefault: true } : m)) }, + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + textGenerationModelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.6-luna", + ), + }; + + expect(resolveAppModelSelectionState(settings, providers).model).toBe( + DEFAULT_TEXT_GENERATION_MODEL, + ); + }); + it("does not select a provider that cannot generate system text", () => { const instanceId = ProviderInstanceId.make("antigravity"); const unsupported = { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 1ffd3b4be920..167f45c4c190 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -366,9 +366,19 @@ export function resolveAppModelSelectionState( selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable); if (entry) { // When the instance changed due to fallback (e.g. selected instance was disabled), - // don't carry over the old instance's model — use the fallback instance's default. + // don't carry over the old instance's model. const selectedModel = selectedEntry ? selection.model : null; + // A saved model the catalog no longer lists, or a new fallback instance, + // gets the driver's cheap text generation default. The catalog's own + // default is the flagship chat model, which is too expensive here. + const options = getAppModelOptionsForInstance(settings, entry); const model = + resolveSelectableModel(entry.driverKind, selectedModel, options) ?? + resolveSelectableModel( + entry.driverKind, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind], + options, + ) ?? resolveAppModelSelectionForInstance( entry.instanceId, settings, From cd34df59a1ba7eb48318549d628b5ec7c9b759c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 03:19:52 -0700 Subject: [PATCH 34/41] feat(web): setting to open pull request links on GitHub (#19719) Co-authored-by: Claude Opus 5.5 --- .../settings/IntegrationsSettings.tsx | 30 +++++++++++++++++++ .../settings/SettingsPanels.logic.test.ts | 2 ++ .../settings/SettingsPanels.logic.ts | 4 +++ .../components/settings/SettingsPanels.tsx | 2 ++ .../src/components/settings/settingsSearch.ts | 6 ++++ apps/web/src/lib/openPullRequestLink.ts | 8 +++-- packages/contracts/src/settings.ts | 7 +++++ 7 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a0c0a79bebc6..c375ee910a14 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -622,6 +622,35 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) ); } +function PullRequestLinksOpenInAppSetting() { + const openInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp); + const updateSettings = useUpdatePrimarySettings(); + + return ( + updateSettings({ pullRequestLinksOpenInApp: true })} + /> + ) + } + control={ + + updateSettings({ pullRequestLinksOpenInApp: Boolean(checked) }) + } + aria-label="Open pull requests in T3 Code" + /> + } + /> + ); +} + function DeviceIntegrationSettings() { const { search, environment: selected } = useSettingsScope(); const settings = useScopedSettings(); @@ -1545,6 +1574,7 @@ export function IntegrationsSettingsPanel() { ) : ( previewDefaults )} + diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index fe26ce239393..f790b47ac448 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -242,6 +242,7 @@ describe("getChangedBrowserSettingLabels", () => { browserRecordingShowMousePresses: true, browserLinkTarget: "app", browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, + pullRequestLinksOpenInApp: false, }), ).toEqual([ "Browser viewport", @@ -252,6 +253,7 @@ describe("getChangedBrowserSettingLabels", () => { "Recording mouse presses", "Open links in", "Floating preview", + "Open pull requests in T3 Code", ]); }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 232357b5482f..f24f040c2929 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -118,6 +118,7 @@ export type BrowserDefaultSettings = Pick< | "browserRecordingShowMousePresses" | "browserLinkTarget" | "browserAutoShowFloatingPreview" + | "pullRequestLinksOpenInApp" >; /** @@ -166,6 +167,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings) DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview ? ["Floating preview"] : []), + ...(settings.pullRequestLinksOpenInApp !== DEFAULT_UNIFIED_SETTINGS.pullRequestLinksOpenInApp + ? ["Open pull requests in T3 Code"] + : []), ]; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e408312cddf9..ef60c9e3c3c4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -676,6 +676,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserRecordingShowMousePresses, settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, + settings.pullRequestLinksOpenInApp, settings.appearanceContrast, settings.diffColorScheme, settings.chatWidth, @@ -855,6 +856,7 @@ export function useSettingsRestore(onRestored?: () => void) { browserRecordingShowMousePresses: DEFAULT_UNIFIED_SETTINGS.browserRecordingShowMousePresses, browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget, browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, + pullRequestLinksOpenInApp: DEFAULT_UNIFIED_SETTINGS.pullRequestLinksOpenInApp, // Re-granted like any other default. The confirmation dialog lists it by // name, so a user restoring defaults is told the agent regains access // rather than discovering it later. diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0ee55ef3785b..5747fc59d9cb 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -778,6 +778,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/integrations", searchTerms: ["links default browser in-app browser external open"], }, + { + id: "pull-request-links-open-in-app", + title: "Open pull requests in T3 Code", + to: "/settings/integrations", + searchTerms: ["pr links github browser external click default pull request view panel"], + }, { id: "browser-auto-show-floating-preview", title: "Auto-show floating preview", diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 1c701fb4f0fe..9d473c783a1e 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -8,6 +8,7 @@ import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/c import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; +import { useClientSettings } from "../hooks/useSettings"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useRightPanelStore } from "../rightPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; @@ -122,6 +123,8 @@ export function findProjectOnChangeRequestHost( * the pull requests page: a reader following a link the agent wrote is reading the thread, and * should still be reading it afterwards. Any change request opens there, not only the thread's * own, since the panel is told which one to show. + * + * With "Open pull requests in T3 Code" turned off, every link is left alone. */ export function shouldOpenPullRequestExternally( event: Pick, "metaKey" | "ctrlKey">, @@ -145,9 +148,10 @@ export function useOpenChangeRequestLink( const allProjects = useProjects(); const serverConfigs = useServerConfigs(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const openInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp); return useCallback( (event, targetUrl, targetThreadRef, targetEnvironmentId) => { - if (shouldOpenPullRequestExternally(event)) return false; + if (!openInApp || shouldOpenPullRequestExternally(event)) return false; const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; const parsed = parseChangeRequestUrl(targetUrl); @@ -243,7 +247,7 @@ export function useOpenChangeRequestLink( }); return true; }, - [allProjects, navigate, panelRef, primaryEnvironmentId, serverConfigs, threadRef], + [allProjects, navigate, openInApp, panelRef, primaryEnvironmentId, serverConfigs, threadRef], ); } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 52ebb8947c77..569fcf40cf81 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -441,6 +441,12 @@ export const ClientSettingsSchema = Schema.Struct({ TrimmedNonEmptyString, PullRequestMergeMethod, ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + /** + * Whether clicking a pull request link opens T3 Code's pull request view. + * When false, pull request links open like any other link, so they reach + * GitHub or the repository's other host. + */ + pullRequestLinksOpenInApp: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy plan mode. The composer's Build/Plan toggle was removed from the // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. @@ -1742,6 +1748,7 @@ export const ClientSettingsPatch = Schema.Struct({ pullRequestMergeMethodOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), ), + pullRequestLinksOpenInApp: Schema.optionalKey(Schema.Boolean), planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), From 00cdceb196edf661652dee6efe983c0d40662971 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 03:31:49 -0700 Subject: [PATCH 35/41] fix(web): middle-click opens pull requests in the browser, like cmd+click (#19854) Co-authored-by: Claude Opus 5.5 --- apps/web/src/browser/browserLinkTarget.test.ts | 8 ++++++++ apps/web/src/browser/browserLinkTarget.ts | 10 +++++++--- apps/web/src/browser/useOpenLink.ts | 6 +++++- apps/web/src/components/chat/ThreadDetailsPrRow.tsx | 1 + apps/web/src/components/contextChipParts.tsx | 1 + .../components/pullRequest/PullRequestDetailPanel.tsx | 8 ++++++++ apps/web/src/lib/openPullRequestLink.test.ts | 6 ++++++ apps/web/src/lib/openPullRequestLink.ts | 10 +++++++--- 8 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index a60362c43bd5..c40893e6acf5 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -67,6 +67,14 @@ describe("resolveLinkTarget", () => { canOpenInApp: true, }), ).toBe("system"); + expect( + resolveLinkTarget({ + url: "https://example.com/", + event: { metaKey: false, ctrlKey: false, button: 1 }, + preference: "app", + canOpenInApp: true, + }), + ).toBe("system"); }); it("leaves non-web schemes to the shell", () => { diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index ce79e6ac6a6a..20fce7057c17 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -15,8 +15,12 @@ import { isPreviewAvailableFor } from "./previewRuntime"; export interface ResolveLinkTargetInput { readonly url: string; - /** Cmd/Ctrl-click always goes to the system browser, whatever the default. */ - readonly event: { readonly metaKey: boolean; readonly ctrlKey: boolean }; + /** Cmd/Ctrl-click and middle-click always go to the system browser, whatever the default. */ + readonly event: { + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly button?: number | undefined; + }; readonly preference: BrowserLinkTarget; /** Whether this client has an in-app browser and a thread to open it beside. */ readonly canOpenInApp: boolean; @@ -30,7 +34,7 @@ export interface ResolveLinkTargetInput { * already treat it. */ export function resolveLinkTarget(input: ResolveLinkTargetInput): BrowserLinkTarget { - if (input.event.metaKey || input.event.ctrlKey) return "system"; + if (input.event.metaKey || input.event.ctrlKey || input.event.button === 1) return "system"; if (input.preference !== "app") return "system"; if (!input.canOpenInApp) return "system"; if (!isWebUrl(input.url)) return "system"; diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index d6d33efc6996..22e6a8cd6c0c 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -34,7 +34,11 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url: string, options?: { - readonly event?: { readonly metaKey: boolean; readonly ctrlKey: boolean }; + readonly event?: { + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly button?: number | undefined; + }; /** Thread to open beside when it is not the hook's own, e.g. a sidebar row's. */ readonly threadRef?: ScopedThreadRef | undefined; }, diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 19a3e91b5616..ff4f52a9689b 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -395,6 +395,7 @@ export function ThreadDetailsPrRow({ part={part} aria-label={openAriaLabel} onClick={onOpen} + onAuxClick={onOpen} /> } > diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index cf45749351f3..b0ddf9c2f160 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -113,6 +113,7 @@ export function PullRequestChip(props: { aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`} data-markdown-copy={props.copyMarkdown} onClick={(event) => props.onOpen(event, props.metadata.url)} + onAuxClick={(event) => props.onOpen(event, props.metadata.url)} > {props.label} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e85ac9655139..76e0c28861c0 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1876,6 +1876,10 @@ export function PullRequestDetailPanel({ ), @@ -7489,95 +7317,78 @@ export default function ChatView(props: ChatViewProps) { }, [ childThreadInputs, activePendingApproval, - activePendingUserInput, - activeThreadShell?.hasPendingUserInput, - onOpenRelatedThread, - ]); - - // Commands such as /compact and /goal clear run as their own turn. The draft - // and its attachments stay local. - const sendStandaloneCommand = useCallback( - async (text: string, failureMessage: string) => { - if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; - const context = composerRef.current?.getSendContext(); - if (!context?.providerAvailable) return; - - const threadId = activeThread.id; - const messageId = newMessageId(); - const createdAt = new Date().toISOString(); - sendInFlightRef.current = true; - beginLocalDispatch(); - setThreadError(threadId, null); - setOptimisticUserMessages((messages) => [ - ...messages, - { - id: messageId, - role: "user", - text, - runId: null, - createdAt, - updatedAt: createdAt, - streaming: false, - }, - ]); - scrollToEnd(); - try { - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode: context.interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId, - message: { messageId, role: "user", text, attachments: [] }, - modelSelection: context.selectedModelSelection, - runtimeMode, - interactionMode: context.interactionMode, - createdAt, - }, - }); - if (result._tag === "Failure") { - setOptimisticUserMessages((messages) => - messages.filter((message) => message.id !== messageId), - ); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError(threadId, error instanceof Error ? error.message : failureMessage); - } - } else { - clearUsageLimitsFor(routeThreadKey); + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThreadStable, + ]); + + // Commands such as /compact and /goal clear run as their own turn. The draft + // and its attachments stay local. + const sendStandaloneCommand = async (text: string, failureMessage: string) => { + if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; + const context = composerRef.current?.getSendContext(); + if (!context?.providerAvailable) return; + + const threadId = activeThread.id; + const messageId = newMessageId(); + const createdAt = new Date().toISOString(); + sendInFlightRef.current = true; + beginLocalDispatch(); + setThreadError(threadId, null); + setOptimisticUserMessages((messages) => [ + ...messages, + { + id: messageId, + role: "user", + text, + runId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ]); + scrollToEnd(); + try { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId, + createdAt, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: context.interactionMode, + }); + const result = + settingsResult._tag === "Failure" + ? settingsResult + : await startThreadTurn({ + environmentId, + input: { + threadId, + message: { messageId, role: "user", text, attachments: [] }, + modelSelection: context.selectedModelSelection, + runtimeMode, + interactionMode: context.interactionMode, + createdAt, + }, + }); + if (result._tag === "Failure") { + setOptimisticUserMessages((messages) => + messages.filter((message) => message.id !== messageId), + ); + resetLocalDispatch(); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError(threadId, error instanceof Error ? error.message : failureMessage); } - } finally { - sendInFlightRef.current = false; + } else { + clearUsageLimitsFor(routeThreadKey); } - }, - [ - activeThread, - beginLocalDispatch, - clientSettingsHydrated, - composerRef, - environmentId, - localCheckoutBranchMismatch, - persistThreadSettingsForNextTurn, - resetLocalDispatch, - routeThreadKey, - runtimeMode, - scrollToEnd, - sendInFlightRef, - setThreadError, - startThreadTurn, - ], - ); + } finally { + sendInFlightRef.current = false; + } + }; + const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); // A native /goal keeps the agent working across turns. Stop pauses a Codex // goal; once the thread is idle the row offers the native follow-ups. const activeGoal = activeThreadShell?.goal ?? null; @@ -7601,7 +7412,9 @@ export default function ChatView(props: ChatViewProps) { @@ -7609,14 +7422,14 @@ export default function ChatView(props: ChatViewProps) { ), }; - }, [activeGoal, activeThread, isWorking, sendStandaloneCommand]); + }, [activeGoal, activeThread, isWorking, sendStandaloneCommandStable]); const backgroundWorkBannerItem = useMemo(() => { const presentation = presentPendingBackgroundWork(activeBackgroundTasks); @@ -7659,7 +7472,7 @@ export default function ChatView(props: ChatViewProps) { onOpenRelatedThread(childThreadId)} + onClick={() => onOpenRelatedThreadStable(childThreadId)} > {item.label} @@ -7674,7 +7487,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" variant="ghost" disabled={!canOperateThread || isStoppingBackgroundWork} - onClick={() => void handleStopBackgroundWork()} + onClick={() => void handleStopBackgroundWorkStable()} > {isStoppingBackgroundWork ? "Stopping..." : "Stop"} @@ -7684,9 +7497,9 @@ export default function ChatView(props: ChatViewProps) { activeBackgroundTasks, activeThread, canOperateThread, - handleStopBackgroundWork, + handleStopBackgroundWorkStable, isStoppingBackgroundWork, - onOpenRelatedThread, + onOpenRelatedThreadStable, ]); // Settled, snoozed, and woke are thread state, not composer actions: each // gets one quiet line after the last message instead of a banner. A woken @@ -7707,7 +7520,7 @@ export default function ChatView(props: ChatViewProps) { } actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} actionDisabled={!canOperateThread || isUnsnoozing} - onAction={() => void handleUnsnoozeActiveThread()} + onAction={() => void handleUnsnoozeActiveThreadStable()} /> ) : activeThreadSettled ? ( void handleUnsettleActiveThread()} + onAction={() => void handleUnsettleActiveThreadStable()} /> ) : activeThreadWokeVisible ? ( } label="Woke from snooze" actionLabel="Dismiss" - onAction={acknowledgeActiveThreadWoke} + onAction={acknowledgeActiveThreadWokeStable} /> ) : null; }, [ - acknowledgeActiveThreadWoke, + acknowledgeActiveThreadWokeStable, activeThreadSettled, activeThreadShell?.settledAt, activeThreadShell?.snoozedUntil, activeThreadSnoozed, activeThreadWokeVisible, canOperateThread, - handleUnsettleActiveThread, - handleUnsnoozeActiveThread, + handleUnsettleActiveThreadStable, + handleUnsnoozeActiveThreadStable, isUnsettling, isUnsnoozing, nowMinute, @@ -7815,7 +7628,7 @@ export default function ChatView(props: ChatViewProps) { () => new Set(), ); const keepFullHistory = fullHistoryThreadKeys.has(routeThreadKey); - const setKeepFullHistory = useCallback((threadKey: string, keep: boolean) => { + const setKeepFullHistory = (threadKey: string, keep: boolean) => { setFullHistoryThreadKeys((current) => { if (current.has(threadKey) === keep) return current; const next = new Set(current); @@ -7823,23 +7636,18 @@ export default function ChatView(props: ChatViewProps) { else next.delete(threadKey); return next; }); - }, []); - const toggleKeepFullHistory = useCallback( - () => setKeepFullHistory(routeThreadKey, !keepFullHistory), - [keepFullHistory, routeThreadKey, setKeepFullHistory], - ); - const handleRestoreThreadBranch = useCallback(() => { + }; + const toggleKeepFullHistory = () => setKeepFullHistory(routeThreadKey, !keepFullHistory); + const toggleKeepFullHistoryStable = useStableCallback(toggleKeepFullHistory); + const handleRestoreThreadBranch = () => { if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); return; } void handleSwitchCheckoutToThread(); - }, [ - canWriteSourceControl, - gitStatusQuery.data?.hasWorkingTreeChanges, - handleSwitchCheckoutToThread, - ]); + }; + const handleRestoreThreadBranchStable = useStableCallback(handleRestoreThreadBranch); const feedbackBannerItems = useMemo( () => feedbackSubmissions.flatMap((submission) => { @@ -7929,7 +7737,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" variant="ghost" disabled={!canWriteSourceControl || isRestoringThreadBranch} - onClick={handleRestoreThreadBranch} + onClick={handleRestoreThreadBranchStable} > {isRestoringThreadBranch ? "Restoring..." : "Restore branch"} @@ -7948,7 +7756,7 @@ export default function ChatView(props: ChatViewProps) { canWriteSourceControl, feedbackBannerItems, limitRecoveryBanner, - handleRestoreThreadBranch, + handleRestoreThreadBranchStable, isRestoringThreadBranch, backgroundWorkBannerItem, childInputBannerItem, @@ -8028,7 +7836,7 @@ export default function ChatView(props: ChatViewProps) { } else if (previous && !current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { - focusComposer(); + focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); @@ -8036,31 +7844,31 @@ export default function ChatView(props: ChatViewProps) { } terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); - - const getShortcutContext = useCallback( - (eventTarget: EventTarget | null = document.activeElement) => ({ - terminalFocus: getTerminalFocusOwner() !== null, - terminalOpen: Boolean(terminalUiState.terminalOpen), - previewFocus: isPreviewFocused(), - previewOpen: previewPanelOpen, - editableFocus: isEditableFocused(eventTarget), - modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", - draftThreadRoute: routeKind === "draft", - turnRunning: phase === "running", - isWeb: !isElectron, - isDesktop: isElectron, - }), - [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase], - ); + }, [activeThreadKey, focusComposerStable, terminalUiState.terminalOpen]); + + const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen: Boolean(terminalUiState.terminalOpen), + previewFocus: isPreviewFocused(), + previewOpen: previewPanelOpen, + editableFocus: isEditableFocused(eventTarget), + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", + draftThreadRoute: routeKind === "draft", + turnRunning: phase === "running", + isWeb: !isElectron, + isDesktop: isElectron, + }); + const getShortcutContextStable = useStableCallback(getShortcutContext); const timelineSkills = activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS; const threadFindControlsRef = useRef(null); const [isThreadFindActive, setIsThreadFindActive] = useState(false); - const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []); - const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []); + const openThreadFind = () => threadFindControlsRef.current?.open(); + const openThreadFindStable = useStableCallback(openThreadFind); + const closeThreadFind = () => threadFindControlsRef.current?.close(); + const closeThreadFindStable = useStableCallback(closeThreadFind); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -8081,7 +7889,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = getShortcutContext(event.target); + const shortcutContext = getShortcutContextStable(event.target); if ( !shortcutContext.terminalFocus && @@ -8103,7 +7911,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "thread.copyReference") { event.preventDefault(); event.stopPropagation(); - if (!event.repeat) copyActiveThreadReference(); + if (!event.repeat) copyActiveThreadReferenceStable(); return; } @@ -8113,7 +7921,7 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); if (!isServerThread || !activeThreadRef || !supportsSettlement) return; if (activeThreadSettled) { - void handleUnsettleActiveThread(); + void handleUnsettleActiveThreadStable(); return; } // Same as the sidebar Settle button: settle, then open the next thread. @@ -8159,7 +7967,7 @@ export default function ChatView(props: ChatViewProps) { if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); - toggleTerminalVisibility(); + toggleTerminalVisibilityStable(); return; } @@ -8167,28 +7975,28 @@ export default function ChatView(props: ChatViewProps) { if (command === "chat.find" && isServerThread && serverConfig?.threadFind === true) { event.preventDefault(); event.stopPropagation(); - openThreadFind(); + openThreadFindStable(); return; } if (command === "rightPanel.toggle") { event.preventDefault(); event.stopPropagation(); - toggleRightPanel(); + toggleRightPanelStable(); return; } if (command === "rightPanel.toggleMaximized") { event.preventDefault(); event.stopPropagation(); - if (!event.repeat) toggleRightPanelMaximized(); + if (!event.repeat) toggleRightPanelMaximizedStable(); return; } if (command === "threadPanel.toggle") { event.preventDefault(); event.stopPropagation(); - toggleThreadPanel(); + toggleThreadPanelStable(); return; } @@ -8198,7 +8006,7 @@ export default function ChatView(props: ChatViewProps) { if (!activeRightPanelSurface) return; event.preventDefault(); event.stopPropagation(); - if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface); + if (!event.repeat) closeRightPanelSurfaceStable(activeRightPanelSurface); return; } @@ -8209,13 +8017,13 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { - splitPanelTerminal(); + splitPanelTerminalStable(); return; } if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); + setTerminalOpenStable(true); } - splitTerminal(); + splitTerminalStable(); return; } @@ -8226,13 +8034,13 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { - splitPanelTerminal("vertical"); + splitPanelTerminalStable("vertical"); return; } if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); + setTerminalOpenStable(true); } - splitTerminal("vertical"); + splitTerminalStable("vertical"); return; } @@ -8243,11 +8051,11 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { - requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); + requestClosePanelTerminalStable(activeRightPanelSurface.activeTerminalId); return; } if (!terminalUiState.terminalOpen) return; - requestCloseTerminal(terminalUiState.activeTerminalId); + requestCloseTerminalStable(terminalUiState.activeTerminalId); return; } @@ -8258,20 +8066,20 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { - addTerminalSurface(); + addTerminalSurfaceStable(); return; } if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); + setTerminalOpenStable(true); } - createNewTerminal(); + createNewTerminalStable(); return; } if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); - onToggleDiff(); + onToggleDiffStable(); return; } @@ -8305,7 +8113,7 @@ export default function ChatView(props: ChatViewProps) { (env) => env.environmentId === currentId, ); const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length]; - if (next) onEnvironmentChange(next.environmentId); + if (next) onEnvironmentChangeStable(next.environmentId); return; } @@ -8348,7 +8156,7 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (event.repeat) return; - void onInterrupt(); + void onInterruptStable(); return; } @@ -8358,7 +8166,7 @@ export default function ChatView(props: ChatViewProps) { if (!script) return; event.preventDefault(); event.stopPropagation(); - void runProjectScript(script); + void runProjectScriptStable(script); }; const dismissFind = (event: KeyboardEvent) => { if ( @@ -8370,11 +8178,11 @@ export default function ChatView(props: ChatViewProps) { isCommandPaletteOpen() ) return; - const context = getShortcutContext(event.target); + const context = getShortcutContextStable(event.target); if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return; event.preventDefault(); - closeThreadFind(); - focusComposer(); + closeThreadFindStable(); + focusComposerStable(); }; window.addEventListener("keydown", handler, true); window.addEventListener("keydown", dismissFind); @@ -8388,7 +8196,7 @@ export default function ChatView(props: ChatViewProps) { activeProjectScripts, canReadTerminal, canOperateTerminal, - addTerminalSurface, + addTerminalSurfaceStable, activeThreadRef, activeThreadPinned, activeThreadSettled, @@ -8396,51 +8204,51 @@ export default function ChatView(props: ChatViewProps) { terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, - closeRightPanelSurface, - requestCloseTerminal, - requestClosePanelTerminal, - createNewTerminal, - setTerminalOpen, - runProjectScript, - splitTerminal, - splitPanelTerminal, + closeRightPanelSurfaceStable, + requestCloseTerminalStable, + requestClosePanelTerminalStable, + createNewTerminalStable, + setTerminalOpenStable, + runProjectScriptStable, + splitTerminalStable, + splitPanelTerminalStable, keybindings, scriptKeybindings, - handleUnsettleActiveThread, + handleUnsettleActiveThreadStable, isServerThread, serverConfig?.threadFind, - onInterrupt, - onToggleDiff, + onInterruptStable, + onToggleDiffStable, pinThread, settleThread, supportsPinning, supportsSettlement, confirmAndUnpinThread, - copyActiveThreadReference, - getShortcutContext, - openThreadFind, - closeThreadFind, + copyActiveThreadReferenceStable, + getShortcutContextStable, + openThreadFindStable, + closeThreadFindStable, isThreadFindActive, - toggleRightPanel, - toggleRightPanelMaximized, - toggleThreadPanel, - toggleTerminalVisibility, + toggleRightPanelStable, + toggleRightPanelMaximizedStable, + toggleThreadPanelStable, + toggleTerminalVisibilityStable, composerRef, draftId, environmentId, envLocked, hasMultipleEnvironments, logicalProjectEnvironments, - onEnvironmentChange, + onEnvironmentChangeStable, ]); // A focused desktop browser page forwards these chords as menu actions. useEffect(() => { return window.desktopBridge?.onMenuAction((action) => { - if (action === "rightPanel.toggle") toggleRightPanel(); - else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximized(); + if (action === "rightPanel.toggle") toggleRightPanelStable(); + else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); }); - }, [toggleRightPanel, toggleRightPanelMaximized]); + }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); // Paste-to-focus: the resting composer blurs on a click into the timeline, // so a paste that follows has no editable target and would be dropped. @@ -8490,266 +8298,233 @@ export default function ChatView(props: ChatViewProps) { setPendingRevert(null); } - const onRevertToTurnCount = useCallback( - async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => { - const localApi = readLocalApi(); - if ( - !localApi || - !activeThread || - isRevertingCheckpoint || - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) - ) - return; - const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId); - const message = sourceMessage - ? { - id: sourceMessage.id, - role: sourceMessage.role, - text: sourceMessage.text, - attachments: sourceMessage.attachments, - context: sourceMessage.context, - runId: sourceMessage.runId, - streaming: false, - createdAt: DateTime.formatIso(sourceMessage.createdAt), - updatedAt: DateTime.formatIso(sourceMessage.updatedAt), - } - : undefined; - if (!message || message.role !== "user") return; + const onRevertToTurnCount = async ( + turnCount: number, + messageId: MessageId, + restoreFiles?: boolean, + ) => { + const localApi = readLocalApi(); + if ( + !localApi || + !activeThread || + isRevertingCheckpoint || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) + return; + const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId); + const message = sourceMessage + ? { + id: sourceMessage.id, + role: sourceMessage.role, + text: sourceMessage.text, + attachments: sourceMessage.attachments, + context: sourceMessage.context, + runId: sourceMessage.runId, + streaming: false, + createdAt: DateTime.formatIso(sourceMessage.createdAt), + updatedAt: DateTime.formatIso(sourceMessage.updatedAt), + } + : undefined; + if (!message || message.role !== "user") return; - if (!supportsConversationRollback) { - setThreadError( - activeThread.id, - "This provider does not support reverting conversation history. Start a new thread instead.", - ); - return; + if (!supportsConversationRollback) { + setThreadError( + activeThread.id, + "This provider does not support reverting conversation history. Start a new thread instead.", + ); + return; + } + if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { + setThreadError( + activeThread.id, + `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + ); + return; + } + if (phase === "running" || isSendBusy || isConnecting) { + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + return; + } + if (restoreFiles === undefined) { + setPendingRevert({ turnCount, messageId, routeThreadKey }); + return; + } + + useComposerDraftStore.setState((store) => ({ + rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), + })); + setThreadError(activeThread.id, null); + try { + if (composerRef.current?.hasPendingAttachments()) { + throw new Error("Wait for attachments to finish preparing before rewinding."); } - if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { - setThreadError( - activeThread.id, - `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("The environment is not connected."); + const files = await prepareRevertedMessageAttachments({ + message, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, + }); + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(composerDraftTarget); + if ( + (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + throw new Error( + "Make room for this message's attachments in the composer before rewinding.", ); - return; - } - if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; - } - if (restoreFiles === undefined) { - setPendingRevert({ turnCount, messageId, routeThreadKey }); - return; } - - useComposerDraftStore.setState((store) => ({ - rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), - })); - setThreadError(activeThread.id, null); - try { - if (composerRef.current?.hasPendingAttachments()) { - throw new Error("Wait for attachments to finish preparing before rewinding."); - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error("The environment is not connected."); - const files = await prepareRevertedMessageAttachments({ - message, + const commandId = CommandId.make(randomUUID()); + await waitForRevertedMessage(routeThreadRef, messageId, turnCount, commandId, async () => { + const result = await revertThreadCheckpoint({ environmentId, - httpBaseUrl: connection.httpBaseUrl, - createAssetUrl: createAttachmentAssetUrl, - }); - const store = useComposerDraftStore.getState(); - const draft = store.getComposerDraft(composerDraftTarget); - if ( - (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - throw new Error( - "Make room for this message's attachments in the composer before rewinding.", - ); - } - const commandId = CommandId.make(randomUUID()); - await waitForRevertedMessage(routeThreadRef, messageId, turnCount, commandId, async () => { - const result = await revertThreadCheckpoint({ - environmentId, - input: { commandId, threadId: activeThread.id, turnCount, restoreFiles }, - }); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); + input: { commandId, threadId: activeThread.id, turnCount, restoreFiles }, }); - const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? ""; - const restoredPrompt = recallableComposerPrompt(message.text); - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.length > 0 - ? `${currentPrompt}\n\n${restoredPrompt}` - : restoredPrompt; - store.setPrompt(composerDraftTarget, nextPrompt); - const images: ComposerImageAttachment[] = []; - const restoredFiles: ComposerFileAttachment[] = []; - files.forEach((file, index) => { - const attachment = { - id: randomUUID(), - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }; - if (message.attachments?.[index]?.type === "image") { - images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); - } else { - restoredFiles.push({ ...attachment, type: "file" }); - } - }); - store.addImages(composerDraftTarget, images, { allowDuplicates: true }); - store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true }); - if (currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = nextPrompt; - composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); - requestAnimationFrame(() => { - if (currentRouteThreadKeyRef.current === routeThreadKey) - composerRef.current?.focusAtEnd(); - }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }); + const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const restoredPrompt = recallableComposerPrompt(message.text); + const nextPrompt = + restoredPrompt.length === 0 + ? currentPrompt + : currentPrompt.length > 0 + ? `${currentPrompt}\n\n${restoredPrompt}` + : restoredPrompt; + store.setPrompt(composerDraftTarget, nextPrompt); + const images: ComposerImageAttachment[] = []; + const restoredFiles: ComposerFileAttachment[] = []; + files.forEach((file, index) => { + const attachment = { + id: randomUUID(), + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; + if (message.attachments?.[index]?.type === "image") { + images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); + } else { + restoredFiles.push({ ...attachment, type: "file" }); } - } catch (error) { - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to revert thread state.", - ); - } finally { - useComposerDraftStore.setState((store) => { - const remaining = new Set(store.rewindingThreadKeys); - remaining.delete(routeThreadKey); - return { rewindingThreadKeys: remaining }; - }); - } - }, - [ - activeThread, - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - composerDraftTarget, - composerRef, - createAttachmentAssetUrl, - environmentId, - isConnecting, - isRevertingCheckpoint, - isSendBusy, - phase, - revertThreadCheckpoint, - routeThreadKey, - routeThreadRef, - setThreadError, - supportsConversationRollback, - serverProjection, - ], - ); - - const onRollbackCheckpoint = useCallback( - async (input: { readonly checkpointId: string; readonly scopeId: string }) => { - if (!activeThread || isRevertingCheckpoint) return; - if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { - setThreadError( - activeThread.id, - `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, - ); - return; - } - if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; - } - const localApi = readLocalApi(); - const confirmed = - localApi == null - ? window.confirm("Roll back this thread to the selected checkpoint?") - : await localApi.dialogs.confirm( - "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.", - ); - if (!confirmed) return; - - useComposerDraftStore.setState((store) => ({ - rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), - })); - setThreadError(activeThread.id, null); - const result = await revertThreadCheckpoint({ - environmentId, - input: { - threadId: activeThread.id, - checkpointId: input.checkpointId, - scopeId: input.scopeId, - }, }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to revert thread state.", - ); + store.addImages(composerDraftTarget, images, { allowDuplicates: true }); + store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true }); + if (currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = nextPrompt; + composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); + requestAnimationFrame(() => { + if (currentRouteThreadKeyRef.current === routeThreadKey) + composerRef.current?.focusAtEnd(); + }); } + } catch (error) { + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to revert thread state.", + ); + } finally { useComposerDraftStore.setState((store) => { const remaining = new Set(store.rewindingThreadKeys); remaining.delete(routeThreadKey); return { rewindingThreadKeys: remaining }; }); - }, - [ - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - activeThread, + } + }; + const onRevertToTurnCountStable = useStableCallback(onRevertToTurnCount); + + const onRollbackCheckpoint = async (input: { + readonly checkpointId: string; + readonly scopeId: string; + }) => { + if (!activeThread || isRevertingCheckpoint) return; + if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { + setThreadError( + activeThread.id, + `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + ); + return; + } + if (phase === "running" || isSendBusy || isConnecting) { + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + return; + } + const localApi = readLocalApi(); + const confirmed = + localApi == null + ? window.confirm("Roll back this thread to the selected checkpoint?") + : await localApi.dialogs.confirm( + "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.", + ); + if (!confirmed) return; + + useComposerDraftStore.setState((store) => ({ + rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), + })); + setThreadError(activeThread.id, null); + const result = await revertThreadCheckpoint({ environmentId, - isConnecting, - isRevertingCheckpoint, - isSendBusy, - phase, - revertThreadCheckpoint, - setThreadError, - ], - ); + input: { + threadId: activeThread.id, + checkpointId: input.checkpointId, + scopeId: input.scopeId, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to revert thread state.", + ); + } + useComposerDraftStore.setState((store) => { + const remaining = new Set(store.rewindingThreadKeys); + remaining.delete(routeThreadKey); + return { rewindingThreadKeys: remaining }; + }); + }; - const onForkFromRun = useCallback( - async (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => { - if (!activeThread || activeEnvironmentUnavailable) return; - const targetThreadId = newThreadId(); - const targetThreadRef = scopeThreadRef(environmentId, targetThreadId); - const result = await forkThreadFromRun({ - environmentId, - input: { - sourceThreadId: input.sourceThreadId, - targetThreadId, - runId: input.runId, - title: `${activeThread.title} fork`, - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to fork this response.", - ); - } - return; - } - const targetThreadReady = await waitForThreadShell(targetThreadRef); - if (!targetThreadReady) { + const onForkFromRun = async (input: { + readonly sourceThreadId: ThreadId; + readonly runId: RunId; + }) => { + if (!activeThread || activeEnvironmentUnavailable) return; + const targetThreadId = newThreadId(); + const targetThreadRef = scopeThreadRef(environmentId, targetThreadId); + const result = await forkThreadFromRun({ + environmentId, + input: { + sourceThreadId: input.sourceThreadId, + targetThreadId, + runId: input.runId, + title: `${activeThread.title} fork`, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); setThreadError( activeThread.id, - "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.", + error instanceof Error ? error.message : "Failed to fork this response.", ); - return; } - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(targetThreadRef), - }); - }, - [ - activeEnvironmentUnavailable, - activeThread, - environmentId, - forkThreadFromRun, - navigate, - setThreadError, - ], - ); + return; + } + const targetThreadReady = await waitForThreadShell(targetThreadRef); + if (!targetThreadReady) { + setThreadError( + activeThread.id, + "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.", + ); + return; + } + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), + }); + }; + const onForkFromRunStable = useStableCallback(onForkFromRun); const onCompactContext = () => { if (compactDisabled) return; void sendStandaloneCommand("/compact", "Failed to compact context."); @@ -10225,245 +10000,214 @@ export default function ChatView(props: ChatViewProps) { } }; - const onRespondToApproval = useCallback( - async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { - if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - if ( - pendingApprovals.find((approval) => approval.requestId === requestId) - ?.responseCapability !== "live" - ) - return; + const onRespondToApproval = async ( + requestId: RuntimeRequestId, + decision: ProviderApprovalDecision, + ) => { + if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; + if ( + pendingApprovals.find((approval) => approval.requestId === requestId)?.responseCapability !== + "live" + ) + return; - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], + setRespondingRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await respondToThreadApproval({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + decision, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to submit approval decision.", ); - const result = await respondToThreadApproval({ + } + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; + const onRespondToApprovalStable = useStableCallback(onRespondToApproval); + + const onRespondToUserInput = async ( + requestId: RuntimeRequestId, + answers: Record, + ) => { + if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; + const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); + if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; + const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); + if (userInputResponsesInFlight.current.has(responseKey)) return; + const attachmentsByQuestionId = new Map< + string, + import("@t3tools/contracts").UserInputAttachments[string] + >(); + const providerAnswers = { ...answers }; + for (const question of pendingInput.questions) { + const target = questionAttachmentDraftId( environmentId, - input: { - threadId: activeThreadId, - requestId, - decision, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + activeThreadId, + requestId, + question.id, + ); + if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; + const draft = useComposerDraftStore.getState().getComposerDraft(target); + providerAnswers[question.id] = projectPendingUserInputAnswer( + answers[question.id], + draft?.threadContexts ?? [], + ); + const attachments = draft ? [...draft.images, ...draft.files] : []; + if (attachments.length === 0) continue; + const uploaded = getUploadedAttachments({ environmentId, images: attachments }); + if (!uploaded) { setThreadError( activeThreadId, - error instanceof Error ? error.message : "Failed to submit approval decision.", + "Wait for attachments to finish uploading, or remove failed uploads.", ); - } - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }, - [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError], - ); - - const onRespondToUserInput = useCallback( - async (requestId: RuntimeRequestId, answers: Record) => { - if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; - const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); - if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; - const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); - if (userInputResponsesInFlight.current.has(responseKey)) return; - const attachmentsByQuestionId = new Map< - string, - import("@t3tools/contracts").UserInputAttachments[string] - >(); - const providerAnswers = { ...answers }; - for (const question of pendingInput.questions) { - const target = questionAttachmentDraftId( - environmentId, - activeThreadId, - requestId, - question.id, - ); - if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; - const draft = useComposerDraftStore.getState().getComposerDraft(target); - providerAnswers[question.id] = projectPendingUserInputAnswer( - answers[question.id], - draft?.threadContexts ?? [], - ); - const attachments = draft ? [...draft.images, ...draft.files] : []; - if (attachments.length === 0) continue; - const uploaded = getUploadedAttachments({ environmentId, images: attachments }); - if (!uploaded) { - setThreadError( - activeThreadId, - "Wait for attachments to finish uploading, or remove failed uploads.", - ); - return; - } - attachmentsByQuestionId.set( - question.id, - uploaded as import("@t3tools/contracts").UserInputAttachments[string], - ); } - userInputResponsesInFlight.current.add(responseKey); + attachmentsByQuestionId.set( + question.id, + uploaded as import("@t3tools/contracts").UserInputAttachments[string], + ); + } + userInputResponsesInFlight.current.add(responseKey); - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await respondToThreadUserInput({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + answers: providerAnswers, + ...(attachmentsByQuestionId.size > 0 + ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } + : {}), + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to submit user input.", ); - const result = await respondToThreadUserInput({ - environmentId, - input: { - threadId: activeThreadId, - requestId, - answers: providerAnswers, - ...(attachmentsByQuestionId.size > 0 - ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } - : {}), - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : "Failed to submit user input.", - ); - } - userInputResponsesInFlight.current.delete(responseKey); - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }, - [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError], - ); + } + userInputResponsesInFlight.current.delete(responseKey); + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; // Closes an async question without messaging the agent. The server records // the dismissal so every client releases the composer. - const onDismissUserInput = useCallback( - async (requestId: RuntimeRequestId) => { - if (!activeThreadId) return; + const onDismissUserInput = async (requestId: RuntimeRequestId) => { + if (!activeThreadId) return; - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await dismissThreadUserInput({ + environmentId, + input: { threadId: activeThreadId, requestId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to dismiss the question.", ); - const result = await dismissThreadUserInput({ - environmentId, - input: { threadId: activeThreadId, requestId }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : "Failed to dismiss the question.", - ); - } - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }, - [activeThreadId, dismissThreadUserInput, environmentId, setThreadError], - ); + } + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; + const onDismissUserInputStable = useStableCallback(onDismissUserInput); - const setActivePendingUserInputQuestionIndex = useCallback( - (nextQuestionIndex: number) => { - if (!activePendingUserInput) { - return; - } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingRequestKey]: nextQuestionIndex, - })); - }, - [activePendingUserInput, activePendingRequestKey], - ); + const setActivePendingUserInputQuestionIndex = (nextQuestionIndex: number) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputQuestionIndexByRequestId((existing) => ({ + ...existing, + [activePendingRequestKey]: nextQuestionIndex, + })); + }; - const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionValue: string) => { - if (!activePendingUserInput) { - return; + const onSelectActivePendingUserInputOption = (questionId: string, optionValue: string) => { + if (!activePendingUserInput) { + return; + } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + if (activeThreadId) { + const store = useComposerDraftStore.getState(); + const questionTarget = questionAttachmentDraftId( + environmentId, + activeThreadId, + activePendingUserInput.requestId, + questionId, + ); + store.addThreadContexts( + composerDraftTarget, + store.getComposerDraft(questionTarget)?.threadContexts ?? [], + { appendReference: false }, + ); } - // The option replaces the custom answer. Anything typed there is the - // user's text, so it goes back to the thread draft instead of vanishing. - const displacedAnswer = - pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; - const currentPrompt = - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; - const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); - if (nextPrompt !== currentPrompt) { - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - if (activeThreadId) { - const store = useComposerDraftStore.getState(); - const questionTarget = questionAttachmentDraftId( - environmentId, - activeThreadId, - activePendingUserInput.requestId, - questionId, - ); - store.addThreadContexts( - composerDraftTarget, - store.getComposerDraft(questionTarget)?.threadContexts ?? [], - { appendReference: false }, - ); - } + } + setPendingUserInputAnswersByRequestId((existing) => { + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return existing; } - setPendingUserInputAnswersByRequestId((existing) => { - const question = - (activePendingProgress?.activeQuestion?.id === questionId - ? activePendingProgress.activeQuestion - : undefined) ?? - activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return existing; - } - - return { - ...existing, - [activePendingRequestKey]: { - ...existing[activePendingRequestKey], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingRequestKey]?.[questionId], - optionValue, - ), - }, - }; - }); - promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); - }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - activePendingRequestKey, - activeThreadId, - composerDraftTarget, - composerRef, - environmentId, - pendingUserInputAnswersByRequestId, - setComposerDraftPrompt, - ], - ); - const onChangeActivePendingUserInputCustomAnswer = useCallback( - (questionId: string, value: string) => { - if (!activePendingUserInput) { - return; - } - const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question || question.allowCustomAnswer === false) { - return; - } - promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ + return { ...existing, [activePendingRequestKey]: { ...existing[activePendingRequestKey], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingRequestKey]?.[questionId], - value, + [questionId]: togglePendingUserInputOptionSelection( question, + existing[activePendingRequestKey]?.[questionId], + optionValue, ), }, - })); - // The editor owns this selection. Resetting it before the answer renders - // can re-enter Lexical with offsets from the previous skill token. - }, + }; + }); + promptRef.current = ""; + composerRef.current?.resetCursorState({ cursor: 0 }); + }; + const onSelectActivePendingUserInputOptionStable = useStableCallback( + onSelectActivePendingUserInputOption, + ); + + const onChangeActivePendingUserInputCustomAnswer = useMemo( + () => + makePendingCustomAnswerSetter({ + activePendingUserInput, + activePendingRequestKey, + promptRef, + setPendingUserInputAnswersByRequestId, + }), [activePendingUserInput, activePendingRequestKey], ); - const onAdvanceActivePendingUserInput = useCallback(() => { + const onAdvanceActivePendingUserInput = () => { if ( !activePendingUserInput || activePendingUserInput.responseCapability === "not_resumable" || @@ -10478,21 +10222,18 @@ export default function ChatView(props: ChatViewProps) { return; } setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); - }, [ - activePendingProgress, - activePendingResolvedAnswers, - activePendingUserInput, - activePendingIsResponding, - onRespondToUserInput, - setActivePendingUserInputQuestionIndex, - ]); + }; + const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); - const onPreviousActivePendingUserInputQuestion = useCallback(() => { + const onPreviousActivePendingUserInputQuestion = () => { if (!activePendingProgress) { return; } setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); - }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + }; + const onPreviousActivePendingUserInputQuestionStable = useStableCallback( + onPreviousActivePendingUserInputQuestion, + ); async function onSubmitPlanFollowUp({ text, @@ -10642,7 +10383,7 @@ export default function ChatView(props: ChatViewProps) { return false; } - const onImplementPlanInNewThread = useCallback(async () => { + const onImplementPlanInNewThread = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || !activeThread || @@ -10780,169 +10521,128 @@ export default function ChatView(props: ChatViewProps) { } } finish(); - }, [ - activeProject, - activeProposedPlan, - activeThreadBranch, - activeThread, - beginLocalDispatch, - activeEnvironmentUnavailable, - createThread, - deleteThread, - isConnecting, - isSendBusy, - isServerThread, - navigate, - resetLocalDispatch, - defaultRuntimeMode, - startThreadTurn, - environmentId, - composerRef, - ]); + }; + const onImplementPlanInNewThreadStable = useStableCallback(onImplementPlanInNewThread); - const getModelDisabledReason = useCallback( - (instanceId: ProviderInstanceId, model: string): string | null => { - if (!activeThread) { - return null; - } - const reason = getStartedThreadModelChangeBlockReason({ - providers: providerStatuses, - hasStartedSession: activeRuntime !== null, - supportsProviderSwitchingViaHandoff, - currentModelSelection: activeThread.modelSelection, - currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, - nextModelSelection: { instanceId, model }, - }); - return reason ? `${reason.description} Start a new thread to use this model.` : null; - }, + const getModelDisabledReason = useMemo( + () => + makeModelDisabledReason( + activeThread + ? { + providers: providerStatuses, + hasStartedSession: activeRuntime !== null, + supportsProviderSwitchingViaHandoff, + currentModelSelection: activeThread.modelSelection, + currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, + } + : null, + ), [activeRuntime, activeThread, providerStatuses, supportsProviderSwitchingViaHandoff], ); - const onProviderModelSelect = useCallback( - (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => { - if (!activeThread) return; - // Look up the configured instance so model normalization and custom - // model lookup stay scoped to that exact instance. Unknown instance ids - // are rejected by returning early; the server remains authoritative too. - const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); - const resolvedDriverKind = entry?.driver ?? null; - if ( - !supportsProviderSwitchingViaHandoff && - lockedProvider !== null && - resolvedDriverKind !== null && - resolvedDriverKind !== lockedProvider - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } + const onProviderModelSelect = ( + instanceId: ProviderInstanceId, + model: string, + options?: { focusComposer?: boolean }, + ) => { + if (!activeThread) return; + // Look up the configured instance so model normalization and custom + // model lookup stay scoped to that exact instance. Unknown instance ids + // are rejected by returning early; the server remains authoritative too. + const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); + const resolvedDriverKind = entry?.driver ?? null; + if ( + !supportsProviderSwitchingViaHandoff && + lockedProvider !== null && + resolvedDriverKind !== null && + resolvedDriverKind !== lockedProvider + ) { + if (options?.focusComposer !== false) scheduleComposerFocus(); + return; + } + if ( + !supportsProviderSwitchingViaHandoff && + lockedProvider !== null && + activeRuntime?.providerInstanceId + ) { + const currentEntry = providerStatuses.find( + (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId, + ); if ( - !supportsProviderSwitchingViaHandoff && - lockedProvider !== null && - activeRuntime?.providerInstanceId + currentEntry?.continuation?.groupKey && + entry?.continuation?.groupKey && + currentEntry.continuation.groupKey !== entry.continuation.groupKey ) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - } - const resolvedModel = resolveAppModelSelectionForInstance( - instanceId, - settings, - providerStatuses, - model, - ); - if (!resolvedModel) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - // Restore this model's own remembered options; without any, start it - // from its default rather than carrying the previous model's over. - const rememberedOptions = - useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[ - resolvedModel - ]; - const nextModelSelection: ModelSelection = - rememberedOptions !== undefined && rememberedOptions.length > 0 - ? { instanceId, model: resolvedModel, options: [...rememberedOptions] } - : { instanceId, model: resolvedModel }; - const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ - providers: providerStatuses, - hasStartedSession: activeRuntime !== null, - supportsProviderSwitchingViaHandoff, - currentModelSelection: activeThread.modelSelection, - currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, - nextModelSelection, - }); - if (modelChangeBlockReason) { - toastManager.add({ - type: "warning", - title: modelChangeBlockReason.title, - description: modelChangeBlockReason.description, - }); if (options?.focusComposer !== false) scheduleComposerFocus(); return; } - setComposerDraftModelSelection( - scopeThreadRef(activeThread.environmentId, activeThread.id), - nextModelSelection, - // A complete snapshot: an absent options field means "start from the - // model default", not "keep the previous model's options". - { explicit: true, replaceOptions: true }, - ); - setStickyComposerModelSelection(nextModelSelection); + } + const resolvedModel = resolveAppModelSelectionForInstance( + instanceId, + settings, + providerStatuses, + model, + ); + if (!resolvedModel) { if (options?.focusComposer !== false) scheduleComposerFocus(); - }, - [ - activeThread, - activeRuntime, - lockedProvider, + return; + } + // Restore this model's own remembered options; without any, start it + // from its default rather than carrying the previous model's over. + const rememberedOptions = + useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[resolvedModel]; + const nextModelSelection: ModelSelection = + rememberedOptions !== undefined && rememberedOptions.length > 0 + ? { instanceId, model: resolvedModel, options: [...rememberedOptions] } + : { instanceId, model: resolvedModel }; + const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ + providers: providerStatuses, + hasStartedSession: activeRuntime !== null, supportsProviderSwitchingViaHandoff, - scheduleComposerFocus, - setComposerDraftModelSelection, - setStickyComposerModelSelection, - providerStatuses, - settings, - ], - ); - const onEnvModeChange = useCallback( - (mode: DraftThreadEnvMode) => { - if (multipleModelSelections !== null) return; - if (canOverrideServerThreadEnvMode) { - setPendingServerThreadEnvMode(mode); - scheduleComposerFocus(); - return; - } - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { - envMode: mode, - startFromOrigin: resolveNewDraftStartFromOrigin({ - envMode: mode, - newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, - }), - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), - }); - } + currentModelSelection: activeThread.modelSelection, + currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, + nextModelSelection, + }); + if (modelChangeBlockReason) { + toastManager.add({ + type: "warning", + title: modelChangeBlockReason.title, + description: modelChangeBlockReason.description, + }); + if (options?.focusComposer !== false) scheduleComposerFocus(); + return; + } + setComposerDraftModelSelection( + scopeThreadRef(activeThread.environmentId, activeThread.id), + nextModelSelection, + // A complete snapshot: an absent options field means "start from the + // model default", not "keep the previous model's options". + { explicit: true, replaceOptions: true }, + ); + setStickyComposerModelSelection(nextModelSelection); + if (options?.focusComposer !== false) scheduleComposerFocus(); + }; + const onProviderModelSelectStable = useStableCallback(onProviderModelSelect); + const onEnvModeChange = (mode: DraftThreadEnvMode) => { + if (multipleModelSelections !== null) return; + if (canOverrideServerThreadEnvMode) { + setPendingServerThreadEnvMode(mode); scheduleComposerFocus(); - }, - [ - canOverrideServerThreadEnvMode, - composerDraftTarget, - draftThread?.worktreePath, - isLocalDraftThread, - multipleModelSelections, - activeProjectSettings.settings.newWorktreesStartFromOrigin, - setPendingServerThreadEnvMode, - scheduleComposerFocus, - setDraftThreadContext, - ], - ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + envMode: mode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: mode, + newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, + }), + ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + }); + } + scheduleComposerFocus(); + }; + const onEnvModeChangeStable = useStableCallback(onEnvModeChange); // "Work locally" on the setup card: cancel the bootstrap and remember the // draft. The cancelled dispatch deletes the half-made thread and puts the @@ -10950,7 +10650,7 @@ export default function ChatView(props: ChatViewProps) { // local mode and resends. The draft is a server thread for the whole // setup (the bootstrap created it), so this keys off the route, not // `isLocalDraftThread`. - const onWorktreeSetupWorkLocally = useCallback(() => { + const onWorktreeSetupWorkLocally = () => { if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) { return; } @@ -10963,19 +10663,20 @@ export default function ChatView(props: ChatViewProps) { if (result._tag !== "Success" || !result.value.cancelled) return; setWorkLocallyResendDraftId(draftId); })(); - }, [cancelWorktreeSetup, draftId, setupTarget.environmentId, worktreeSetup]); + }; + const onWorktreeSetupWorkLocallyStable = useStableCallback(onWorktreeSetupWorkLocally); const onSendRef = useRef(onSend); onSendRef.current = onSend; const onResumeRef = useRef(onResume); onResumeRef.current = onResume; const onCompactContextRef = useRef(onCompactContext); onCompactContextRef.current = onCompactContext; - const onComposerSend = useCallback( - (...args: Parameters) => onSendRef.current(...args), - [], - ); - const onComposerResume = useCallback(() => onResumeRef.current(), []); - const onComposerCompactContext = useCallback(() => onCompactContextRef.current(), []); + const onComposerSend = (...args: Parameters) => onSendRef.current(...args); + const onComposerSendStable = useStableCallback(onComposerSend); + const onComposerResume = () => onResumeRef.current(); + const onComposerResumeStable = useStableCallback(onComposerResume); + const onComposerCompactContext = () => onCompactContextRef.current(); + const onComposerCompactContextStable = useStableCallback(onComposerCompactContext); const queuedRunsControl = useMemo( () => isServerThread && activeThread ? ( @@ -10991,14 +10692,14 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} optimisticMessages={optimisticUserMessages} editingRunId={editingQueuedRun?.runId ?? null} - onEditQueuedRun={beginEditingQueuedRun} - onCancelEdit={cancelEditingQueuedRun} + onEditQueuedRun={beginEditingQueuedRunStable} + onCancelEdit={cancelEditingQueuedRunStable} /> ) : null, [ activeThread, - beginEditingQueuedRun, - cancelEditingQueuedRun, + beginEditingQueuedRunStable, + cancelEditingQueuedRunStable, editingQueuedRun?.runId, isServerThread, keybindings, @@ -11063,35 +10764,35 @@ export default function ChatView(props: ChatViewProps) { } }; - const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { + const onExpandTimelineImage = (preview: ExpandedImagePreview) => { setExpandedImage(preview); - }, []); - const onOpenTurnDiff = useCallback( - (runId: RunId, filePath?: string) => { - if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === - "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; - useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }, - [activeThreadRef, isServerThread, onDiffPanelOpen], - ); - const onTimelineRollbackCheckpoint = useCallback( - (input: { readonly checkpointId: string; readonly scopeId: string }) => { - if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); - }, - [onRollbackCheckpoint, paintOnlyDisplayedTimeline], - ); + }; + const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage); + const onOpenTurnDiff = (runId: RunId, filePath?: string) => { + if (!isServerThread || !activeThreadRef) return; + const diffOpen = + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; + explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; + useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + }; + const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff); + const onTimelineRollbackCheckpoint = (input: { + readonly checkpointId: string; + readonly scopeId: string; + }) => { + if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); + }; + const onTimelineRollbackCheckpointStable = useStableCallback(onTimelineRollbackCheckpoint); // The revert handler is read from a ref at call-time so the callback // reference is fully stable and never busts TimelineRowCtx identity. - const onRevertToTurnCountRef = useRef(onRevertToTurnCount); - onRevertToTurnCountRef.current = onRevertToTurnCount; - const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => { + const onRevertToTurnCountRef = useRef(onRevertToTurnCountStable); + onRevertToTurnCountRef.current = onRevertToTurnCountStable; + const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => { void onRevertToTurnCountRef.current(targetTurnCount, messageId); - }, []); + }; + const onRevertTimelineTurnStable = useStableCallback(onRevertTimelineTurn); const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); const consumePendingFileDrop = useSidebarPendingFileDropStore( @@ -11153,12 +10854,12 @@ export default function ChatView(props: ChatViewProps) { launchContext={activeTerminalLaunchContext ?? null} focusRequestId={terminalFocusRequestId} keybindings={keybindings} - onAddTerminalContext={addTerminalContextToDraft} - onSplitTerminal={splitPanelTerminal} - onSplitTerminalVertical={splitPanelTerminalVertical} - onNewTerminal={addTerminalSurface} - onActiveTerminalChange={activatePanelTerminal} - onCloseTerminal={closePanelTerminal} + onAddTerminalContext={addTerminalContextToDraftStable} + onSplitTerminal={splitPanelTerminalStable} + onSplitTerminalVertical={splitPanelTerminalVerticalStable} + onNewTerminal={addTerminalSurfaceStable} + onActiveTerminalChange={activatePanelTerminalStable} + onCloseTerminal={closePanelTerminalStable} splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} @@ -11187,7 +10888,7 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. 1 - ? addPullRequestsSurface + ? addPullRequestsSurfaceStable : undefined } /> @@ -11278,8 +10979,8 @@ export default function ChatView(props: ChatViewProps) { ? renderedRightPanelSurface.revealRequestId : 0 } - onOpenFile={openFileSurface} - onPendingChange={handleFilePendingChange} + onOpenFile={openFileSurfaceStable} + onPendingChange={handleFilePendingChangeStable} selectedFilePending={ renderedRightPanelSurface.kind === "file" && pendingFileSurfaceIds.has(renderedRightPanelSurface.id) @@ -11315,10 +11016,10 @@ export default function ChatView(props: ChatViewProps) { !envLocked && canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled - ? onAutoEnvironment + ? onAutoEnvironmentStable : undefined, - onEnvironmentChange, - onEnvModeChange, + onEnvironmentChange: onEnvironmentChangeStable, + onEnvModeChange: onEnvModeChangeStable, envMode, ...(canOverrideServerThreadEnvMode ? { @@ -11329,14 +11030,14 @@ export default function ChatView(props: ChatViewProps) { startFromOrigin, onStartFromOriginChange, ...(canCheckoutPullRequestIntoThread - ? { onCheckoutPullRequestRequest: openPullRequestDialog } + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } : {}), - onComposerFocusRequest: scheduleComposerFocus, - ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}), - onRunProjectScript: runProjectScript, - onAddProjectScript: saveProjectScript, - onUpdateProjectScript: updateProjectScript, - onDeleteProjectScript: deleteProjectScript, + onComposerFocusRequest: scheduleComposerFocusStable, + ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanelStable } : {}), + onRunProjectScript: runProjectScriptStable, + onAddProjectScript: saveProjectScriptStable, + onUpdateProjectScript: updateProjectScriptStable, + onDeleteProjectScript: deleteProjectScriptStable, }; const panelToggleControlProps = { terminalAvailable: activeProject !== null, @@ -11349,9 +11050,9 @@ export default function ChatView(props: ChatViewProps) { rightPanelAvailable: activeProject !== null, rightPanelOpen, rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), - onToggleTerminal: toggleTerminalVisibility, - onToggleThreadPanel: toggleThreadPanel, - onToggleRightPanel: toggleRightPanel, + onToggleTerminal: toggleTerminalVisibilityStable, + onToggleThreadPanel: toggleThreadPanelStable, + onToggleRightPanel: toggleRightPanelStable, } satisfies PanelLayoutControlsProps; const panelToggleControls = ( ) : null} @@ -11481,11 +11182,11 @@ export default function ChatView(props: ChatViewProps) { activeThreadTitle={activeThread.title} activeProject={activeProject ?? null} parentThreadLink={parentThreadLink} - onOpenThread={onOpenRelatedThread} + onOpenThread={onOpenRelatedThreadStable} rightPanelOpen={inlineRightPanelOwnsTitleBar} - onNewThreadInProject={handleNewThreadInActiveProject} + onNewThreadInProject={handleNewThreadInActiveProjectStable} {...(activeDraftLogicalProjectKey - ? { onOpenProjectSettings: handleOpenDraftProjectSettings } + ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} /> @@ -11512,7 +11213,7 @@ export default function ChatView(props: ChatViewProps) { onDragLeave={workspaceFileDropHandlers.onDragLeave} onDrop={workspaceFileDropHandlers.onDrop} > - + {isWorkspaceFileDragActive ? (
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} - onOpenProviderSetup={openProviderSetup} + onOpenProviderSetup={openProviderSetupStable} /> {} : onForkFromRun} - onRollbackCheckpoint={onTimelineRollbackCheckpoint} + onForkFromRun={paintOnlyDisplayedTimeline ? async () => {} : onForkFromRunStable} + onRollbackCheckpoint={onTimelineRollbackCheckpointStable} supportsConversationRollback={ !paintOnlyDisplayedTimeline && supportsConversationRollback } onRevertToTurnCount={ - paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn + paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurnStable } {...(!paintOnlyDisplayedTimeline - ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage } + ? { + onUseArtifactTemplate: useArtifactTemplateStable, + onSendAppMessage: sendAppMessageStable, + } : {})} isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} + onImageExpand={onExpandTimelineImageStable} + onFileOpen={ + paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachmentStable + } onFileDownload={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment + paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachmentStable } markdownCwd={ paintOnlyDisplayedTimeline @@ -11651,14 +11364,14 @@ export default function ChatView(props: ChatViewProps) { } skills={timelineSkills} anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} - onAnchorReady={onTimelineAnchorReady} - onAnchorSizeChanged={onTimelineAnchorSizeChanged} + onAnchorReady={onTimelineAnchorReadyStable} + onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} - onIsAtEndChange={onIsAtEndChange} + onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} - onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} - onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} + onManualNavigation={cancelTimelineLiveFollowForUserNavigationStable} cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} @@ -11774,7 +11487,7 @@ export default function ChatView(props: ChatViewProps) { serverConfig?.environment.capabilities.requiredWorktreeBootstrap === true } - onMultipleModelSelectionsChange={setMultipleModelSelections} + onMultipleModelSelectionsChange={setMultipleModelSelectionsStable} composerRef={composerRef} composerDraftTarget={composerDraftTarget} environmentId={environmentId} @@ -11830,14 +11543,14 @@ export default function ChatView(props: ChatViewProps) { bannerItems={composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} - onToggleKeepFullHistory={toggleKeepFullHistory} + onToggleKeepFullHistory={toggleKeepFullHistoryStable} // With attachments or contexts aboard the pick just inserts the // text, so it sends as a prompt like the typed path would. onUsageLimitsCommand={ usageLimitsOffered && usageLimitsKey !== null && !composerHasNonPromptContent - ? openUsageLimits + ? openUsageLimitsStable : undefined } environmentUnavailable={activeEnvironmentUnavailableState} @@ -11889,48 +11602,50 @@ export default function ChatView(props: ChatViewProps) { (isGitRepo || showComposerEnvironmentIndicator) } onRestingControlsVisibilityChange={setRestingComposerControlsVisible} - getTimelineScrollableNode={getTimelineScrollableNode} - isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} + getTimelineScrollableNode={getTimelineScrollableNodeStable} + isTimelineAtLogicalEnd={isTimelineAtLogicalEndStable} timelineOverflows={timelineOverflows} - onComposerOverlayHeightChange={publishComposerOverlayHeight} - onRestingChange={onComposerRestingChange} + onComposerOverlayHeightChange={publishComposerOverlayHeightStable} + onRestingChange={onComposerRestingChangeStable} promptRef={promptRef} composerImagesRef={composerImagesRef} composerFilesRef={composerFilesRef} composerTerminalContextsRef={composerTerminalContextsRef} - onPageScrollKeyDown={onComposerPageScrollKeyDown} - onPageScrollKeyUp={onComposerPageScrollKeyUp} - onPageScrollRelease={onComposerPageScrollRelease} - onCompactContext={onComposerCompactContext} - onSend={onComposerSend} - onResume={onComposerResume} - onInterrupt={onInterrupt} - onImplementPlanInNewThread={onImplementPlanInNewThread} - onRespondToApproval={onRespondToApproval} + onPageScrollKeyDown={onComposerPageScrollKeyDownStable} + onPageScrollKeyUp={onComposerPageScrollKeyUpStable} + onPageScrollRelease={onComposerPageScrollReleaseStable} + onCompactContext={onComposerCompactContextStable} + onSend={onComposerSendStable} + onResume={onComposerResumeStable} + onInterrupt={onInterruptStable} + onImplementPlanInNewThread={onImplementPlanInNewThreadStable} + onRespondToApproval={onRespondToApprovalStable} onSelectActivePendingUserInputOption={ - onSelectActivePendingUserInputOption + onSelectActivePendingUserInputOptionStable } - onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} - onDismissActivePendingUserInput={onDismissUserInput} + onAdvanceActivePendingUserInput={ + onAdvanceActivePendingUserInputStable + } + onDismissActivePendingUserInput={onDismissUserInputStable} onPreviousActivePendingUserInputQuestion={ - onPreviousActivePendingUserInputQuestion + onPreviousActivePendingUserInputQuestionStable } onChangeActivePendingUserInputCustomAnswer={ onChangeActivePendingUserInputCustomAnswer } - onProviderModelSelect={onProviderModelSelect} - onOpenProviderSetup={openProviderSetup} + onProviderModelSelect={onProviderModelSelectStable} + onOpenProviderSetup={openProviderSetupStable} getModelDisabledReason={getModelDisabledReason} - toggleInteractionMode={toggleInteractionMode} - handleRuntimeModeChange={handleRuntimeModeChange} - handleInteractionModeChange={handleInteractionModeChange} - focusComposer={focusComposer} - scheduleComposerFocus={scheduleComposerFocus} + toggleInteractionMode={toggleInteractionModeStable} + handleRuntimeModeChange={handleRuntimeModeChangeStable} + handleInteractionModeChange={handleInteractionModeChangeStable} + focusComposer={focusComposerStable} + scheduleComposerFocus={scheduleComposerFocusStable} setThreadError={setThreadError} - onExpandImage={onExpandTimelineImage} - onFileOpen={openFileAttachment} + onExpandImage={onExpandTimelineImageStable} + onFileOpen={openFileAttachmentStable} editingQueuedAttachments={composerEditingQueuedAttachments} - onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment} + onRemoveEditingQueuedAttachment={removeEditingQueuedAttachmentStable} /> )}
@@ -11966,7 +11681,7 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} showGitControls={isGitRepo} {...(routeKind === "draft" && draftId ? { draftId } : {})} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onEnvModeChangeStable} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} envMode={envMode} @@ -11978,18 +11693,20 @@ export default function ChatView(props: ChatViewProps) { } : {})} envLocked={envLocked} - onComposerFocusRequest={scheduleComposerFocus} + onComposerFocusRequest={scheduleComposerFocusStable} {...(canCheckoutPullRequestIntoThread - ? { onCheckoutPullRequestRequest: openPullRequestDialog } + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } + : {})} + {...(hasMultipleEnvironments + ? { onEnvironmentChange: onEnvironmentChangeStable } : {})} - {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} autoEnvironmentLabel={autoEnvironmentLabel} onAutoEnvironment={ draftId && !envLocked && canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled - ? onAutoEnvironment + ? onAutoEnvironmentStable : undefined } availableEnvironments={logicalProjectEnvironments} @@ -12070,7 +11787,7 @@ export default function ChatView(props: ChatViewProps) { closePullRequestDialog(); } }} - onPrepared={handlePreparedPullRequestThread} + onPrepared={handlePreparedPullRequestThreadStable} /> ) : null} @@ -12103,7 +11820,7 @@ export default function ChatView(props: ChatViewProps) { mode="inline" open={rightPanelOpen} keybindings={keybindings} - getShortcutContext={getShortcutContext} + getShortcutContext={getShortcutContextStable} maximized={rightPanelMaximized} inlineSize={previewPanelInlineSize} surfaces={renderedRightPanelSurfaces} @@ -12114,25 +11831,25 @@ export default function ChatView(props: ChatViewProps) { desktopByTabId={activePreviewState.desktopByTabId} previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurface} - onCloseSurface={closeRightPanelSurface} + onActivate={activateRightPanelSurfaceStable} + onCloseSurface={closeRightPanelSurfaceStable} onRenameDevice={(surfaceId, title) => { if (activeThreadRef) useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} - onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} + onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} + onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} + onCloseAllSurfaces={closeAllRightPanelSurfacesStable} + onMoveSurface={moveRightPanelSurfaceStable} + onCopyFilePath={copyRightPanelFilePathStable} onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} + onAddBrowserInProfile={createBrowserSurfaceStable} + onAddTerminal={addTerminalSurfaceStable} + onAddDiff={addDiffSurfaceStable} + onAddFiles={addFilesSurfaceStable} + onAddPullRequest={addPullRequestSurfaceStable} + onAddPullRequests={addPullRequestsSurfaceStable} + onAddDevice={addDeviceSurfaceStable} browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} diffAvailable={isServerThread && isGitRepo} @@ -12148,13 +11865,13 @@ export default function ChatView(props: ChatViewProps) { { if (activeThreadRef) useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} - onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} + onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} + onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} + onCloseAllSurfaces={closeAllRightPanelSurfacesStable} + onMoveSurface={moveRightPanelSurfaceStable} + onCopyFilePath={copyRightPanelFilePathStable} onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} + onAddBrowserInProfile={createBrowserSurfaceStable} + onAddTerminal={addTerminalSurfaceStable} + onAddDiff={addDiffSurfaceStable} + onAddFiles={addFilesSurfaceStable} + onAddPullRequest={addPullRequestSurfaceStable} + onAddPullRequests={addPullRequestsSurfaceStable} + onAddDevice={addDeviceSurfaceStable} browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} diffAvailable={isServerThread && isGitRepo} @@ -12248,7 +11965,7 @@ export default function ChatView(props: ChatViewProps) { )}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 05a5e6702092..5f775123d49e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1154,6 +1154,7 @@ import { import { searchProviderSkills } from "../../providerSkillSearch"; import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; import { readEnvironmentScope } from "../../state/session"; @@ -3005,27 +3006,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Provider traits UI // ------------------------------------------------------------------ - const setPromptFromTraits = useCallback( - (nextPrompt: string) => { - if (nextPrompt === promptRef.current) { - scheduleComposerFocus(); - return; - } - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); - setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + const setPromptFromTraits = (nextPrompt: string) => { + if (nextPrompt === promptRef.current) { scheduleComposerFocus(); - }, - [ - composerDraftTarget, - promptRef, - scheduleComposerFocus, - setComposerDraftPrompt, - setComposerTrigger, - ], - ); + return; + } + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + scheduleComposerFocus(); + }; + const setPromptFromTraitsStable = useStableCallback(setPromptFromTraits); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, @@ -3037,7 +3030,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], reportedModelSelection, prompt, - onPromptChange: setPromptFromTraits, + onPromptChange: setPromptFromTraitsStable, planModeEnabled: settings.planModeEnabled, }); const providerTraitsPickerInput = { @@ -3050,7 +3043,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], reportedModelSelection, prompt, - onPromptChange: setPromptFromTraits, + onPromptChange: setPromptFromTraitsStable, planModeEnabled: settings.planModeEnabled, isComposerOwned: true, } satisfies Parameters[0]; @@ -3111,66 +3104,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Prompt helpers // ------------------------------------------------------------------ - const setPrompt = useCallback( - (nextPrompt: string) => { - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - }, - [composerDraftTarget, setComposerDraftPrompt], - ); + const setPrompt = (nextPrompt: string) => { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + }; - const addComposerImage = useCallback( - (image: ComposerImageAttachment) => addComposerDraftImages(attachmentDraftTarget, [image]), - [attachmentDraftTarget, addComposerDraftImages], - ); + const addComposerImage = (image: ComposerImageAttachment) => + addComposerDraftImages(attachmentDraftTarget, [image]); - const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => addComposerDraftImages(attachmentDraftTarget, images), - [attachmentDraftTarget, addComposerDraftImages], - ); + const addComposerImagesToDraft = (images: ComposerImageAttachment[]) => + addComposerDraftImages(attachmentDraftTarget, images); - const addComposerFilesToDraft = useCallback( - (files: ComposerFileAttachment[]) => - addComposerDraftFiles(attachmentDraftTarget, files, { - appendReference: questionAttachmentTarget === null, - }), - [addComposerDraftFiles, attachmentDraftTarget, questionAttachmentTarget], - ); + const addComposerFilesToDraft = (files: ComposerFileAttachment[]) => + addComposerDraftFiles(attachmentDraftTarget, files, { + appendReference: questionAttachmentTarget === null, + }); - const removeComposerImageFromDraft = useCallback( - (imageId: string) => { - if (questionAttachmentTarget && activePendingIsResponding) return; - releaseAttachmentUpload(imageId); - removeComposerDraftImage(attachmentDraftTarget, imageId); - }, - [ - attachmentDraftTarget, - questionAttachmentTarget, - activePendingIsResponding, - removeComposerDraftImage, - ], - ); + const removeComposerImageFromDraft = (imageId: string) => { + if (questionAttachmentTarget && activePendingIsResponding) return; + releaseAttachmentUpload(imageId); + removeComposerDraftImage(attachmentDraftTarget, imageId); + }; - const removeComposerFileFromDraft = useCallback( - (fileId: string) => { - if (questionAttachmentTarget && activePendingIsResponding) return; - // Release by the draft attachment, not the bare queue key: a hydrated - // file's upload lives server-side under its persisted attachment id. - const file = composerFilesRef.current.find((candidate) => candidate.id === fileId); - if (file) { - releaseDraftAttachment(file); - } else { - releaseAttachmentUpload(fileId); - } - removeComposerDraftFile(attachmentDraftTarget, fileId); - }, - [ - attachmentDraftTarget, - questionAttachmentTarget, - activePendingIsResponding, - composerFilesRef, - removeComposerDraftFile, - ], - ); + const removeComposerFileFromDraft = (fileId: string) => { + if (questionAttachmentTarget && activePendingIsResponding) return; + // Release by the draft attachment, not the bare queue key: a hydrated + // file's upload lives server-side under its persisted attachment id. + const file = composerFilesRef.current.find((candidate) => candidate.id === fileId); + if (file) { + releaseDraftAttachment(file); + } else { + releaseAttachmentUpload(fileId); + } + removeComposerDraftFile(attachmentDraftTarget, fileId); + }; const addComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.addTerminalContexts, @@ -3181,72 +3147,60 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); - const buildContextClipboardFragment = useCallback( - (contextIds: ReadonlyArray): string | null => { - const wanted = new Set(contextIds); - // An annotation's screenshot is referenced by the annotation record, not by the copied - // text. Pull it in so the round-trip keeps the image the annotation points at. - for (const annotation of composerPreviewAnnotations) { - if ( - wanted.has(previewAnnotationContextId(annotation.id)) && - composerImages.some((image) => image.id === annotation.id) - ) { - wanted.add(toKindScopedComposerContextId("image", annotation.id)); - } + const buildContextClipboardFragment = (contextIds: ReadonlyArray): string | null => { + const wanted = new Set(contextIds); + // An annotation's screenshot is referenced by the annotation record, not by the copied + // text. Pull it in so the round-trip keeps the image the annotation points at. + for (const annotation of composerPreviewAnnotations) { + if ( + wanted.has(previewAnnotationContextId(annotation.id)) && + composerImages.some((image) => image.id === annotation.id) + ) { + wanted.add(toKindScopedComposerContextId("image", annotation.id)); } - const records: ComposerContextRecord[] = [ - ...composerTerminalContexts - .filter((c) => wanted.has(terminalContextReference(c).contextId)) - .map(terminalContextRecord), - ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), - ...composerReviewComments - .filter((c) => wanted.has(reviewCommentContextId(c.id))) - .map(reviewCommentContextRecord), - ...composerPreviewAnnotations - .filter((a) => wanted.has(previewAnnotationContextId(a.id))) - .map((annotation) => - previewAnnotationContextRecord(annotation, { - screenshotContextId: composerImages.some((image) => image.id === annotation.id) - ? annotation.id - : undefined, - }), - ), - ...[...composerImages, ...composerFiles] - .filter((attachment) => - wanted.has(toKindScopedComposerContextId(attachment.type, attachment.id)), - ) - .flatMap((attachment) => { - const record = uploadedAttachmentContextRecord( - attachment, - uploadsByImageId[attachment.id], - ); - return record ? [record] : []; + } + const records: ComposerContextRecord[] = [ + ...composerTerminalContexts + .filter((c) => wanted.has(terminalContextReference(c).contextId)) + .map(terminalContextRecord), + ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), + ...composerReviewComments + .filter((c) => wanted.has(reviewCommentContextId(c.id))) + .map(reviewCommentContextRecord), + ...composerPreviewAnnotations + .filter((a) => wanted.has(previewAnnotationContextId(a.id))) + .map((annotation) => + previewAnnotationContextRecord(annotation, { + screenshotContextId: composerImages.some((image) => image.id === annotation.id) + ? annotation.id + : undefined, }), - ]; - if (records.length === 0) return null; - return encodeComposerContextFragment({ - version: 1, - source: { environmentId, ...(activeThread ? { threadId: activeThread.id } : {}) }, - records, - }); - }, - [ - activeThread, - composerFiles, - composerImages, - composerPreviewAnnotations, - composerReviewComments, - composerTerminalContexts, - composerThreadContexts, - environmentId, - uploadsByImageId, - ], - ); + ), + ...[...composerImages, ...composerFiles] + .filter((attachment) => + wanted.has(toKindScopedComposerContextId(attachment.type, attachment.id)), + ) + .flatMap((attachment) => { + const record = uploadedAttachmentContextRecord( + attachment, + uploadsByImageId[attachment.id], + ); + return record ? [record] : []; + }), + ]; + if (records.length === 0) return null; + return encodeComposerContextFragment({ + version: 1, + source: { environmentId, ...(activeThread ? { threadId: activeThread.id } : {}) }, + records, + }); + }; + const buildContextClipboardFragmentStable = useStableCallback(buildContextClipboardFragment); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); const loadThreadTranscript = useAtomCommand(orchestrationEnvironment.threadTranscript, { reportFailure: false, }); - const countReservedAttachments = useCallback(() => { + const countReservedAttachments = () => { const questionRequest = pendingUserInputs[0]; const otherQuestionKeys = questionAttachmentTarget && questionRequest && activeThreadId @@ -3269,272 +3223,238 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (pendingThreadImportsRef.current.get(attachmentTargetKey) ?? 0) + countQuestionAttachments(otherQuestionKeys) ); - }, [ - activeThreadId, - attachmentDraftTarget, - attachmentTargetKey, - environmentId, - getComposerDraft, - pendingUserInputs, - questionAttachmentTarget, - ]); + }; + const countReservedAttachmentsStable = useStableCallback(countReservedAttachments); /** * Bytes for a pasted image or file come back through the source environment's asset URL * (the client is the only party that can reach both) and re-enter this draft as a normal * attachment under a fresh id. The pasted chip is rewritten to that id and reads as * unresolved until the bytes land; a failed transfer says so and leaves the chip to remove. */ - const runAttachmentImport = useCallback( - async ( - record: Extract, - localId: string, - sourceEnvironmentId: EnvironmentId, - importTargetKey: string, - ) => { - const fail = (reason: string) => { - toastManager.add({ - type: "error", - title: `Couldn't bring ${record.name} into this message`, - description: `${reason} Remove the chip or attach the file again.`, - }); - }; - const sourceConnection = readPreparedConnection(sourceEnvironmentId); - if (!sourceConnection) { - fail("The environment it came from is not connected."); - return; - } - const result = await createAssetUrl({ - environmentId: sourceEnvironmentId, - input: { resource: { _tag: "attachment", attachmentId: record.attachmentId } }, + const runAttachmentImport = async ( + record: Extract, + localId: string, + sourceEnvironmentId: EnvironmentId, + importTargetKey: string, + ) => { + const fail = (reason: string) => { + toastManager.add({ + type: "error", + title: `Couldn't bring ${record.name} into this message`, + description: `${reason} Remove the chip or attach the file again.`, }); - const url = - result._tag === "Success" - ? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl) - : null; - if (!url) { - fail("The original attachment is no longer available."); - return; - } - let blob: Blob; - try { - const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - blob = await response.blob(); - } catch { - fail("Downloading it from the source failed."); - return; - } - const file = new File([blob], record.name, { type: record.mimeType || blob.type }); - // The draft these bytes belong to may have been sent or switched away from while they - // downloaded. Dropping them here keeps them out of whatever draft is open now. - if (attachmentTargetKeyRef.current !== importTargetKey) return; - const replacesFileMarker = - record.kind === "file" && - composerFilesRef.current.some( - (candidate) => - composerFileNeedsReattach(candidate) && - composerFileMatchesReattachMarker(candidate, { - name: record.name, - mimeType: file.type, - sizeBytes: file.size, - }), - ); - if ( - !replacesFileMarker && - (pendingThreadImportsRef.current.get(importTargetKey) ?? 0) > 0 && - countReservedAttachments() >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - fail("The attachment limit has been reached."); - return; - } - if (record.kind === "image") { - const accepted = addComposerImage({ - type: "image", + }; + const sourceConnection = readPreparedConnection(sourceEnvironmentId); + if (!sourceConnection) { + fail("The environment it came from is not connected."); + return; + } + const result = await createAssetUrl({ + environmentId: sourceEnvironmentId, + input: { resource: { _tag: "attachment", attachmentId: record.attachmentId } }, + }); + const url = + result._tag === "Success" + ? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl) + : null; + if (!url) { + fail("The original attachment is no longer available."); + return; + } + let blob: Blob; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + blob = await response.blob(); + } catch { + fail("Downloading it from the source failed."); + return; + } + const file = new File([blob], record.name, { type: record.mimeType || blob.type }); + // The draft these bytes belong to may have been sent or switched away from while they + // downloaded. Dropping them here keeps them out of whatever draft is open now. + if (attachmentTargetKeyRef.current !== importTargetKey) return; + const replacesFileMarker = + record.kind === "file" && + composerFilesRef.current.some( + (candidate) => + composerFileNeedsReattach(candidate) && + composerFileMatchesReattachMarker(candidate, { + name: record.name, + mimeType: file.type, + sizeBytes: file.size, + }), + ); + if ( + !replacesFileMarker && + (pendingThreadImportsRef.current.get(importTargetKey) ?? 0) > 0 && + countReservedAttachments() >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + fail("The attachment limit has been reached."); + return; + } + if (record.kind === "image") { + const accepted = addComposerImage({ + type: "image", + id: localId, + name: record.name, + mimeType: file.type, + sizeBytes: file.size, + previewUrl: URL.createObjectURL(file), + file, + }); + if (!accepted.includes(localId)) + fail("The draft rejected this attachment (duplicate or attachment limit reached)."); + } else { + const accepted = addComposerFilesToDraft([ + { + type: "file", id: localId, name: record.name, mimeType: file.type, sizeBytes: file.size, - previewUrl: URL.createObjectURL(file), file, - }); - if (!accepted.includes(localId)) - fail("The draft rejected this attachment (duplicate or attachment limit reached)."); - } else { - const accepted = addComposerFilesToDraft([ - { - type: "file", - id: localId, - name: record.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }, - ]); - if (!accepted.includes(localId)) - fail("The draft rejected this attachment (duplicate or attachment limit reached)."); - } - }, - [ - addComposerFilesToDraft, - addComposerImage, - composerFilesRef, - countReservedAttachments, - createAssetUrl, - ], - ); - const importAttachmentRecord = useCallback( - async ( - record: Extract, - localId: string, - sourceEnvironmentId: EnvironmentId, - ) => { - // The chip lands in the draft immediately while these bytes are still downloading. Count - // the transfer against its own draft so a send cannot snapshot a message whose chip has no - // attachment behind it, and so bytes for an abandoned draft never enter the next one. - const importTargetKey = attachmentTargetKey; - pendingDraftWork.begin(importTargetKey); - try { - await runAttachmentImport(record, localId, sourceEnvironmentId, importTargetKey); - } finally { - pendingDraftWork.end(importTargetKey); - } - }, - [attachmentTargetKey, runAttachmentImport], - ); + }, + ]); + if (!accepted.includes(localId)) + fail("The draft rejected this attachment (duplicate or attachment limit reached)."); + } + }; + const importAttachmentRecord = async ( + record: Extract, + localId: string, + sourceEnvironmentId: EnvironmentId, + ) => { + // The chip lands in the draft immediately while these bytes are still downloading. Count + // the transfer against its own draft so a send cannot snapshot a message whose chip has no + // attachment behind it, and so bytes for an abandoned draft never enter the next one. + const importTargetKey = attachmentTargetKey; + pendingDraftWork.begin(importTargetKey); + try { + await runAttachmentImport(record, localId, sourceEnvironmentId, importTargetKey); + } finally { + pendingDraftWork.end(importTargetKey); + } + }; /** * Brings records into this draft (paste, stash restore). Binaries are transferred only * when `sourceEnvironmentId` is given; the stash restores its own images and files. */ - const importContextRecords = useCallback( - ( - records: ReadonlyArray, - sourceEnvironmentId: EnvironmentId | null, - ): ReadonlyMap => { - const rewritten = new Map(); - const dependentAttachmentLocalIds = new Map(); - const skippedDependentAttachmentIds = new Set(); - // Resolve annotations before their dependent screenshot records even if a foreign - // clipboard producer emitted the records in a different order. - const orderedRecords = records.toSorted((left, right) => - left.kind === "preview-annotation" && right.kind !== "preview-annotation" - ? -1 - : right.kind === "preview-annotation" && left.kind !== "preview-annotation" - ? 1 - : 0, - ); - for (const candidate of orderedRecords) { - // Producer ids fold into context ids, so two different excerpts can collide. Only skip - // when the draft already holds the same payload; a colliding but different record is - // re-minted under a fresh id so both survive the paste. - const record = asKnownContextRecord(candidate); - if (!record) continue; - const existing = composerContextImportLookupIds(record).flatMap((contextId) => { - const found = composerContextRecords.get(contextId); - return found ? [found] : []; - })[0]; - const existingRecord = - existing?.kind === "terminal" - ? terminalContextRecord(existing.record) - : existing?.kind === "review-comment" - ? reviewCommentContextRecord(existing.record) - : existing?.kind === "preview-annotation" - ? previewAnnotationContextRecord(existing.record) - : existing?.kind === "thread" - ? existing.record - : existing - ? (uploadedContextRecordFromDraft(existing) ?? undefined) - : undefined; - if (existingRecord && isSameComposerContextPayload(existingRecord, record)) { + const importContextRecords = ( + records: ReadonlyArray, + sourceEnvironmentId: EnvironmentId | null, + ): ReadonlyMap => { + const rewritten = new Map(); + const dependentAttachmentLocalIds = new Map(); + const skippedDependentAttachmentIds = new Set(); + // Resolve annotations before their dependent screenshot records even if a foreign + // clipboard producer emitted the records in a different order. + const orderedRecords = records.toSorted((left, right) => + left.kind === "preview-annotation" && right.kind !== "preview-annotation" + ? -1 + : right.kind === "preview-annotation" && left.kind !== "preview-annotation" + ? 1 + : 0, + ); + for (const candidate of orderedRecords) { + // Producer ids fold into context ids, so two different excerpts can collide. Only skip + // when the draft already holds the same payload; a colliding but different record is + // re-minted under a fresh id so both survive the paste. + const record = asKnownContextRecord(candidate); + if (!record) continue; + const existing = composerContextImportLookupIds(record).flatMap((contextId) => { + const found = composerContextRecords.get(contextId); + return found ? [found] : []; + })[0]; + const existingRecord = + existing?.kind === "terminal" + ? terminalContextRecord(existing.record) + : existing?.kind === "review-comment" + ? reviewCommentContextRecord(existing.record) + : existing?.kind === "preview-annotation" + ? previewAnnotationContextRecord(existing.record) + : existing?.kind === "thread" + ? existing.record + : existing + ? (uploadedContextRecordFromDraft(existing) ?? undefined) + : undefined; + if (existingRecord && isSameComposerContextPayload(existingRecord, record)) { + if (record.kind === "preview-annotation" && record.screenshotContextId) { + skippedDependentAttachmentIds.add(record.screenshotContextId); + } + continue; + } + const conflicts = existing !== undefined; + switch (record.kind) { + case "terminal": { + const threadId = activeThread?.id ?? activeThreadId; + if (!threadId) break; + const imported = terminalContextDraftFromRecord(record, threadId); + const draft = conflicts ? { ...imported, id: randomUUID() } : imported; + addComposerDraftTerminalContexts(composerDraftTarget, [draft], { + appendReference: false, + }); + rewritten.set(record.contextId, terminalContextReference(draft).contextId); + break; + } + case "review-comment": { + const imported = reviewCommentFromRecord(record); + const comment = conflicts ? { ...imported, id: randomUUID() } : imported; + addComposerDraftReviewComment(composerDraftTarget, comment, { + appendReference: false, + }); + rewritten.set(record.contextId, reviewCommentContextId(comment.id)); + break; + } + case "element": + case "preview-annotation": { + const imported = + record.kind === "element" + ? elementContextToPreviewAnnotation(record, randomUUID(), new Date().toISOString()) + : previewAnnotationFromRecord(record); + const annotation = conflicts ? { ...imported, id: randomUUID() } : imported; if (record.kind === "preview-annotation" && record.screenshotContextId) { - skippedDependentAttachmentIds.add(record.screenshotContextId); + dependentAttachmentLocalIds.set(record.screenshotContextId, annotation.id); } - continue; + addComposerDraftPreviewAnnotation(composerDraftTarget, annotation, { + appendReference: false, + }); + rewritten.set(record.contextId, previewAnnotationContextId(annotation.id)); + break; } - const conflicts = existing !== undefined; - switch (record.kind) { - case "terminal": { - const threadId = activeThread?.id ?? activeThreadId; - if (!threadId) break; - const imported = terminalContextDraftFromRecord(record, threadId); - const draft = conflicts ? { ...imported, id: randomUUID() } : imported; - addComposerDraftTerminalContexts(composerDraftTarget, [draft], { - appendReference: false, - }); - rewritten.set(record.contextId, terminalContextReference(draft).contextId); - break; - } - case "review-comment": { - const imported = reviewCommentFromRecord(record); - const comment = conflicts ? { ...imported, id: randomUUID() } : imported; - addComposerDraftReviewComment(composerDraftTarget, comment, { - appendReference: false, - }); - rewritten.set(record.contextId, reviewCommentContextId(comment.id)); - break; - } - case "element": - case "preview-annotation": { - const imported = - record.kind === "element" - ? elementContextToPreviewAnnotation(record, randomUUID(), new Date().toISOString()) - : previewAnnotationFromRecord(record); - const annotation = conflicts ? { ...imported, id: randomUUID() } : imported; - if (record.kind === "preview-annotation" && record.screenshotContextId) { - dependentAttachmentLocalIds.set(record.screenshotContextId, annotation.id); - } - addComposerDraftPreviewAnnotation(composerDraftTarget, annotation, { - appendReference: false, - }); - rewritten.set(record.contextId, previewAnnotationContextId(annotation.id)); - break; - } - case "thread": { - // The agent can only read threads on its own server; a pasted foreign one is dropped. - if (record.environmentId !== environmentId) break; - addComposerDraftThreadContexts(attachmentDraftTarget, [record], { - appendReference: false, - }); + case "thread": { + // The agent can only read threads on its own server; a pasted foreign one is dropped. + if (record.environmentId !== environmentId) break; + addComposerDraftThreadContexts(attachmentDraftTarget, [record], { + appendReference: false, + }); + rewritten.set(record.contextId, record.contextId); + break; + } + case "image": + case "file": { + if (skippedDependentAttachmentIds.has(record.contextId)) break; + if (sourceEnvironmentId === null && !conflicts) { rewritten.set(record.contextId, record.contextId); break; } - case "image": - case "file": { - if (skippedDependentAttachmentIds.has(record.contextId)) break; - if (sourceEnvironmentId === null && !conflicts) { - rewritten.set(record.contextId, record.contextId); - break; - } - if (sourceEnvironmentId === null) break; - const localId = dependentAttachmentLocalIds.get(record.contextId) ?? randomUUID(); - rewritten.set(record.contextId, toKindScopedComposerContextId(record.kind, localId)); - void importAttachmentRecord(record, localId, sourceEnvironmentId); - break; - } - default: - break; + if (sourceEnvironmentId === null) break; + const localId = dependentAttachmentLocalIds.get(record.contextId) ?? randomUUID(); + rewritten.set(record.contextId, toKindScopedComposerContextId(record.kind, localId)); + void importAttachmentRecord(record, localId, sourceEnvironmentId); + break; } + default: + break; } - return rewritten; - }, - [ - activeThread, - activeThreadId, - addComposerDraftPreviewAnnotation, - addComposerDraftReviewComment, - addComposerDraftTerminalContexts, - addComposerDraftThreadContexts, - composerContextRecords, - attachmentDraftTarget, - composerDraftTarget, - environmentId, - importAttachmentRecord, - ], - ); - const importContextFragment = useCallback( - (fragment: ComposerContextClipboardFragment): ReadonlyMap => - importContextRecords(fragment.records, fragment.source.environmentId), - [importContextRecords], - ); + } + return rewritten; + }; + const importContextFragment = ( + fragment: ComposerContextClipboardFragment, + ): ReadonlyMap => + importContextRecords(fragment.records, fragment.source.environmentId); + const importContextFragmentStable = useStableCallback(importContextFragment); // ------------------------------------------------------------------ // Sync refs back to parent @@ -3797,7 +3717,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: prompt change // ------------------------------------------------------------------ - const expandComposerForEditorChange = useCallback(() => { + const expandComposerForEditorChange = () => { // Editor changes win over the momentum tail of the active scroll gesture. suppressActiveComposerScrollGesture( composerScrollGestureRef.current, @@ -3805,7 +3725,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) COMPOSER_SCROLL_GESTURE_RESET_MS, ); setIsComposerScrollCollapsed(false); - }, [setIsComposerScrollCollapsed]); + }; + const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); /** * Payloads for chips the prompt no longer references. History undo restores the @@ -3822,260 +3743,216 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) previewAnnotations: new Map(), }); - const onPromptChange = useCallback( - ( - nextPrompt: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - contextIds: string[], - ) => { - expandComposerForEditorChange(); - if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { - if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); - onChangeActivePendingUserInputCustomAnswer( - activePendingProgress.activeQuestion.id, - nextPrompt, - nextCursor, - expandedCursor, - cursorAdjacentToMention, - ); - return; - } - onTypingGuardDraftChange(); - promptRef.current = nextPrompt; - setPrompt(nextPrompt); - // Any edit ends browsing, even one later undone by hand: typing a - // character and deleting it leaves the text equal to the recall, and - // ArrowDown must move the caret then, not clear the composer. - if (promptHistoryPositionRef.current?.recalled !== nextPrompt) { - promptHistoryPositionRef.current = null; - } - const referenced = new Set(contextIds); - const retained = removedContextPayloadsRef.current; - - // An undone delete brings the reference back; restore the payload it points at. - const liveTerminalIds = new Set( - composerTerminalContexts.map((context) => terminalContextReference(context).contextId), + const onPromptChange = ( + nextPrompt: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + contextIds: string[], + ) => { + expandComposerForEditorChange(); + if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { + if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); - const restoredTerminals = [...referenced].flatMap((contextId) => { - if (liveTerminalIds.has(contextId)) return []; - const context = retained.terminals.get(contextId); - return context ? [context] : []; - }); - const nextTerminals = [ - ...composerTerminalContexts.filter((context) => - referenced.has(terminalContextReference(context).contextId), - ), - ...restoredTerminals, - ]; - for (const context of composerTerminalContexts) { - const contextId = terminalContextReference(context).contextId; - if (!referenced.has(contextId)) retained.terminals.set(contextId, context); - } - if ( - nextTerminals.length !== composerTerminalContexts.length || - restoredTerminals.length > 0 - ) { - setComposerDraftTerminalContexts(composerDraftTarget, nextTerminals); - } - - const liveThreadIds = new Set( - composerThreadContexts.map((record) => record.contextId), + onChangeActivePendingUserInputCustomAnswer( + activePendingProgress.activeQuestion.id, + nextPrompt, + nextCursor, + expandedCursor, + cursorAdjacentToMention, ); - const restoredThreads = [...referenced].flatMap((contextId) => { - if (liveThreadIds.has(contextId)) return []; - const record = retained.threads.get(contextId); - return record ? [record] : []; - }); - const nextThreads = [ - ...composerThreadContexts.filter((record) => referenced.has(record.contextId)), - ...restoredThreads, - ]; - for (const record of composerThreadContexts) { - if (!referenced.has(record.contextId)) retained.threads.set(record.contextId, record); - } - if (nextThreads.length !== composerThreadContexts.length || restoredThreads.length > 0) { - setComposerDraftThreadContexts(composerDraftTarget, nextThreads); - } + return; + } + onTypingGuardDraftChange(); + promptRef.current = nextPrompt; + setPrompt(nextPrompt); + // Any edit ends browsing, even one later undone by hand: typing a + // character and deleting it leaves the text equal to the recall, and + // ArrowDown must move the caret then, not clear the composer. + if (promptHistoryPositionRef.current?.recalled !== nextPrompt) { + promptHistoryPositionRef.current = null; + } + const referenced = new Set(contextIds); + const retained = removedContextPayloadsRef.current; - for (const comment of composerReviewComments) { - const contextId = reviewCommentContextId(comment.id); - if (!referenced.has(contextId)) { - retained.reviewComments.set(contextId, comment); - removeComposerDraftReviewComment(composerDraftTarget, comment.id); - } + // An undone delete brings the reference back; restore the payload it points at. + const liveTerminalIds = new Set( + composerTerminalContexts.map((context) => terminalContextReference(context).contextId), + ); + const restoredTerminals = [...referenced].flatMap((contextId) => { + if (liveTerminalIds.has(contextId)) return []; + const context = retained.terminals.get(contextId); + return context ? [context] : []; + }); + const nextTerminals = [ + ...composerTerminalContexts.filter((context) => + referenced.has(terminalContextReference(context).contextId), + ), + ...restoredTerminals, + ]; + for (const context of composerTerminalContexts) { + const contextId = terminalContextReference(context).contextId; + if (!referenced.has(contextId)) retained.terminals.set(contextId, context); + } + if (nextTerminals.length !== composerTerminalContexts.length || restoredTerminals.length > 0) { + setComposerDraftTerminalContexts(composerDraftTarget, nextTerminals); + } + + const liveThreadIds = new Set(composerThreadContexts.map((record) => record.contextId)); + const restoredThreads = [...referenced].flatMap((contextId) => { + if (liveThreadIds.has(contextId)) return []; + const record = retained.threads.get(contextId); + return record ? [record] : []; + }); + const nextThreads = [ + ...composerThreadContexts.filter((record) => referenced.has(record.contextId)), + ...restoredThreads, + ]; + for (const record of composerThreadContexts) { + if (!referenced.has(record.contextId)) retained.threads.set(record.contextId, record); + } + if (nextThreads.length !== composerThreadContexts.length || restoredThreads.length > 0) { + setComposerDraftThreadContexts(composerDraftTarget, nextThreads); + } + + for (const comment of composerReviewComments) { + const contextId = reviewCommentContextId(comment.id); + if (!referenced.has(contextId)) { + retained.reviewComments.set(contextId, comment); + removeComposerDraftReviewComment(composerDraftTarget, comment.id); } - const liveReviewIds = new Set( - composerReviewComments.map((comment) => reviewCommentContextId(comment.id)), - ); - for (const contextId of referenced) { - if (liveReviewIds.has(contextId)) continue; - const comment = retained.reviewComments.get(contextId); - if (comment) { - addComposerDraftReviewComment(composerDraftTarget, comment, { appendReference: false }); - } + } + const liveReviewIds = new Set( + composerReviewComments.map((comment) => reviewCommentContextId(comment.id)), + ); + for (const contextId of referenced) { + if (liveReviewIds.has(contextId)) continue; + const comment = retained.reviewComments.get(contextId); + if (comment) { + addComposerDraftReviewComment(composerDraftTarget, comment, { appendReference: false }); } + } - const attachmentChanges = reconcileAttachmentContextReferences({ - referencedContextIds: referenced, - files: composerFiles, - images: composerImages, - previewAnnotations: composerPreviewAnnotations, - retained: removedAttachmentContextPayloadsRef.current, + const attachmentChanges = reconcileAttachmentContextReferences({ + referencedContextIds: referenced, + files: composerFiles, + images: composerImages, + previewAnnotations: composerPreviewAnnotations, + retained: removedAttachmentContextPayloadsRef.current, + }); + for (const annotationId of attachmentChanges.annotationIdsToRemove) { + // Keep the upload queue entry alive: undo restores the image that owns it. + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); + } + for (const restored of attachmentChanges.annotationsToRestore) { + if (restored.image) addComposerDraftImages(attachmentDraftTarget, [restored.image]); + addComposerDraftPreviewAnnotation(composerDraftTarget, restored.annotation, { + appendReference: false, }); - for (const annotationId of attachmentChanges.annotationIdsToRemove) { - // Keep the upload queue entry alive: undo restores the image that owns it. - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); - } - for (const restored of attachmentChanges.annotationsToRestore) { - if (restored.image) addComposerDraftImages(attachmentDraftTarget, [restored.image]); - addComposerDraftPreviewAnnotation(composerDraftTarget, restored.annotation, { - appendReference: false, - }); - } - for (const fileId of attachmentChanges.filesToRemove) { - // The retained File and upload are still sendable if the editor restores the chip. - removeComposerDraftFile(attachmentDraftTarget, fileId); - } - if (attachmentChanges.filesToRestore.length > 0) { - addComposerDraftFiles(attachmentDraftTarget, attachmentChanges.filesToRestore); - } - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); - }, - [ - activePendingProgress?.activeQuestion, - expandComposerForEditorChange, - onTypingGuardDraftChange, - pendingUserInputs.length, - onChangeActivePendingUserInputCustomAnswer, - promptRef, - setPrompt, - setComposerTrigger, - composerDraftTarget, - composerTerminalContexts, - setComposerDraftTerminalContexts, - composerThreadContexts, - setComposerDraftThreadContexts, - composerReviewComments, - composerPreviewAnnotations, - composerImages, - composerFiles, - removeComposerDraftReviewComment, - removeComposerDraftPreviewAnnotation, - removeComposerDraftFile, - addComposerDraftReviewComment, - addComposerDraftPreviewAnnotation, - addComposerDraftImages, - addComposerDraftFiles, - attachmentDraftTarget, - ], - ); + } + for (const fileId of attachmentChanges.filesToRemove) { + // The retained File and upload are still sendable if the editor restores the chip. + removeComposerDraftFile(attachmentDraftTarget, fileId); + } + if (attachmentChanges.filesToRestore.length > 0) { + addComposerDraftFiles(attachmentDraftTarget, attachmentChanges.filesToRestore); + } + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + ); + }; + const onPromptChangeStable = useStableCallback(onPromptChange); // ------------------------------------------------------------------ // Callbacks: prompt replacement / menu // ------------------------------------------------------------------ - const applyPromptReplacement = useCallback( - ( - rangeStart: number, - rangeEnd: number, - replacement: string, - options?: { - expectedText?: string; - expandedCursorAfterReplace?: number; - focusEditorAfterReplace?: boolean; - citationComment?: { - start: number; - sourceAnchor: AssistantCitationSourceAnchor; - insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; - }; - }, - ): boolean => { - if ( - activePendingUserInput && - activePendingProgress?.activeQuestion?.allowCustomAnswer === false - ) { - return false; - } - const currentText = promptRef.current; - const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); - const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); - if ( - options?.expectedText !== undefined && - currentText.slice(safeStart, safeEnd) !== options.expectedText - ) { - return false; - } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor( - next.text, - options?.expandedCursorAfterReplace ?? next.cursor, - isLiteralPendingAnswer, - ); - const nextExpandedCursor = expandCollapsedComposerCursor( + const applyPromptReplacement = ( + rangeStart: number, + rangeEnd: number, + replacement: string, + options?: { + expectedText?: string; + expandedCursorAfterReplace?: number; + focusEditorAfterReplace?: boolean; + citationComment?: { + start: number; + sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; + }; + }, + ): boolean => { + if ( + activePendingUserInput && + activePendingProgress?.activeQuestion?.allowCustomAnswer === false + ) { + return false; + } + const currentText = promptRef.current; + const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); + const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); + if ( + options?.expectedText !== undefined && + currentText.slice(safeStart, safeEnd) !== options.expectedText + ) { + return false; + } + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const nextCursor = collapseExpandedComposerCursor( + next.text, + options?.expandedCursorAfterReplace ?? next.cursor, + isLiteralPendingAnswer, + ); + const nextExpandedCursor = expandCollapsedComposerCursor( + next.text, + nextCursor, + isLiteralPendingAnswer, + ); + if (options?.citationComment) { + composerEditorRef.current?.requestCitationComment({ + previousValue: currentText, + value: next.text, + citationStart: options.citationComment.start, + sourceAnchor: options.citationComment.sourceAnchor, + insertedSpaces: options.citationComment.insertedSpaces, + }); + } + promptRef.current = next.text; + const activePendingQuestion = activePendingProgress?.activeQuestion; + if (activePendingQuestion && activePendingUserInput) { + onChangeActivePendingUserInputCustomAnswer( + activePendingQuestion.id, next.text, nextCursor, + nextExpandedCursor, isLiteralPendingAnswer, ); - if (options?.citationComment) { - composerEditorRef.current?.requestCitationComment({ - previousValue: currentText, - value: next.text, - citationStart: options.citationComment.start, - sourceAnchor: options.citationComment.sourceAnchor, - insertedSpaces: options.citationComment.insertedSpaces, - }); - } - promptRef.current = next.text; - const activePendingQuestion = activePendingProgress?.activeQuestion; - if (activePendingQuestion && activePendingUserInput) { - onChangeActivePendingUserInputCustomAnswer( - activePendingQuestion.id, - next.text, - nextCursor, - nextExpandedCursor, - isLiteralPendingAnswer, - ); - } else { - setPrompt(next.text); - onTypingGuardDraftChange(); - } - setComposerCursor(nextCursor); - setComposerTrigger( - isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), - ); - if (options?.focusEditorAfterReplace !== false) { - window.requestAnimationFrame(() => { - // Type-to-focus routes only the first key through here; once the - // controlled update focuses the editor, later keys land natively. - // Skip the deferred caret placement when the draft has moved on, - // or it drags the caret back behind what was typed since. - if (promptRef.current !== next.text) return; - composerEditorRef.current?.focusAt(nextCursor); - }); - } - return true; - }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - isLiteralPendingAnswer, - onChangeActivePendingUserInputCustomAnswer, - onTypingGuardDraftChange, - promptRef, - setPrompt, - setComposerTrigger, - ], - ); + } else { + setPrompt(next.text); + onTypingGuardDraftChange(); + } + setComposerCursor(nextCursor); + setComposerTrigger( + isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), + ); + if (options?.focusEditorAfterReplace !== false) { + window.requestAnimationFrame(() => { + // Type-to-focus routes only the first key through here; once the + // controlled update focuses the editor, later keys land natively. + // Skip the deferred caret placement when the draft has moved on, + // or it drags the caret back behind what was typed since. + if (promptRef.current !== next.text) return; + composerEditorRef.current?.focusAt(nextCursor); + }); + } + return true; + }; + const applyPromptReplacementStable = useStableCallback(applyPromptReplacement); - const readComposerSnapshot = useCallback((): { + const readComposerSnapshot = (): { value: string; cursor: number; expandedCursor: number; @@ -4095,9 +3972,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ), contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current), }; - }, [composerCursor, isLiteralPendingAnswer, promptRef]); + }; + const readComposerSnapshotStable = useStableCallback(readComposerSnapshot); - const resolveActiveComposerTrigger = useCallback((): { + const resolveActiveComposerTrigger = (): { snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; } => { @@ -4108,200 +3986,167 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? null : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), }; - }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); + }; const { onUsageLimitsCommand } = props; - const onSelectComposerItem = useCallback( - (item: ComposerCommandItem) => { - if (composerSelectLockRef.current) return; - composerSelectLockRef.current = true; - window.requestAnimationFrame(() => { - composerSelectLockRef.current = false; + const onSelectComposerItem = (item: ComposerCommandItem) => { + if (composerSelectLockRef.current) return; + composerSelectLockRef.current = true; + window.requestAnimationFrame(() => { + composerSelectLockRef.current = false; + }); + const { snapshot, trigger } = resolveActiveComposerTrigger(); + if (!trigger) return; + if (item.type === "path") { + const replacement = `${serializeComposerFileLink(item.path)} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), }); - const { snapshot, trigger } = resolveActiveComposerTrigger(); - if (!trigger) return; - if (item.type === "path") { - const replacement = `${serializeComposerFileLink(item.path)} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; + if (applied) { + setComposerHighlightedItemId(null); } - if (item.type === "slash-command") { - if (item.command === "model") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - focusEditorAfterReplace: false, - }); - if (applied) { - setComposerHighlightedItemId(null); - setIsComposerModelPickerOpen(true); - } - return; - } - if (!planModeUiEnabled) return; - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + return; + } + if (item.type === "slash-command") { + if (item.command === "model") { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, }); if (applied) { setComposerHighlightedItemId(null); + setIsComposerModelPickerOpen(true); } return; } - if (item.type === "provider-slash-command") { - if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - focusEditorAfterReplace: false, - }); - if (applied) { - setComposerHighlightedItemId(null); - onUsageLimitsCommand(); - } - return; - } - const replacement = `/${item.command.name} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; + if (!planModeUiEnabled) return; + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); } - if (item.type === "skill") { - const replacement = `$${item.skill.name} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); + return; + } + if (item.type === "provider-slash-command") { + if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); if (applied) { setComposerHighlightedItemId(null); + onUsageLimitsCommand(); } return; } - if (item.type === "pull-request") { - if ( - trigger.kind !== "pull-request" || - !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id) - ) { - return; - } - const comment = buildPullRequestReferenceContext(item.pullRequest); - const replacement = `${formatInlineContextReference( - reviewCommentContextReference(comment), - )} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - addComposerDraftReviewComment(composerDraftTarget, comment, { - appendReference: false, - }); - setComposerHighlightedItemId(null); - } - return; + const replacement = `/${item.command.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); } - if (item.type === "thread") { - if (trigger.kind !== "path") return; - const shell = readThreadShell(item.thread); - if (!shell) return; - const record = threadContextRecord(item.thread, shell.title); - const replacement = `${formatInlineContextReference(threadContextReference(record))} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - addComposerDraftThreadContexts(attachmentDraftTarget, [record], { - appendReference: false, - }); - setComposerHighlightedItemId(null); - } + return; + } + if (item.type === "skill") { + const replacement = `$${item.skill.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "pull-request") { + if ( + trigger.kind !== "pull-request" || + !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id) + ) { return; } - }, - [ - addComposerDraftReviewComment, - addComposerDraftThreadContexts, - applyPromptReplacement, - attachmentDraftTarget, - composerDraftTarget, - handleInteractionModeChange, - planModeUiEnabled, - onUsageLimitsCommand, - resolveActiveComposerTrigger, - ], - ); + const comment = buildPullRequestReferenceContext(item.pullRequest); + const replacement = `${formatInlineContextReference( + reviewCommentContextReference(comment), + )} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + addComposerDraftReviewComment(composerDraftTarget, comment, { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "thread") { + if (trigger.kind !== "path") return; + const shell = readThreadShell(item.thread); + if (!shell) return; + const record = threadContextRecord(item.thread, shell.title); + const replacement = `${formatInlineContextReference(threadContextReference(record))} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + addComposerDraftThreadContexts(attachmentDraftTarget, [record], { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } + }; + const onSelectComposerItemStable = useStableCallback(onSelectComposerItem); - const onComposerMenuItemHighlighted = useCallback( - (itemId: string | null) => { - setComposerHighlightedItemId(itemId); - setComposerHighlightedSearchKey(composerMenuSearchKey); - }, - [composerMenuSearchKey], - ); + const onComposerMenuItemHighlighted = (itemId: string | null) => { + setComposerHighlightedItemId(itemId); + setComposerHighlightedSearchKey(composerMenuSearchKey); + }; + const onComposerMenuItemHighlightedStable = useStableCallback(onComposerMenuItemHighlighted); - const nudgeComposerMenuHighlight = useCallback( - (key: "ArrowDown" | "ArrowUp") => { - if (composerMenuItems.length === 0) return; - const highlightedIndex = composerMenuItems.findIndex( - (item) => item.id === composerHighlightedItemId, - ); - const normalizedIndex = - highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; - const offset = key === "ArrowDown" ? 1 : -1; - const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; - const nextItem = composerMenuItems[nextIndex]; - setComposerHighlightedItemId(nextItem?.id ?? null); - }, - [composerHighlightedItemId, composerMenuItems], - ); + const nudgeComposerMenuHighlight = (key: "ArrowDown" | "ArrowUp") => { + if (composerMenuItems.length === 0) return; + const highlightedIndex = composerMenuItems.findIndex( + (item) => item.id === composerHighlightedItemId, + ); + const normalizedIndex = highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; + const offset = key === "ArrowDown" ? 1 : -1; + const nextIndex = + (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; + const nextItem = composerMenuItems[nextIndex]; + setComposerHighlightedItemId(nextItem?.id ?? null); + }; - const blurMobileComposerAfterSend = useCallback(() => { + const blurMobileComposerAfterSend = () => { if (!isMobileViewport) return; if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -4312,9 +4157,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeElement.blur(); } setIsComposerFocused(false); - }, [isMobileViewport, setIsComposerFocused]); + }; - const shouldBlurMobileComposerOnSubmit = useCallback(() => { + const shouldBlurMobileComposerOnSubmit = () => { if (!isMobileViewport) return false; if ( isSendBusy || @@ -4330,120 +4175,87 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } return showPlanFollowUpPrompt || composerSendState.hasSendableContent; - }, [ - activePendingProgress, - activePendingResolvedAnswers, - composerSendState.hasSendableContent, - environmentUnavailable, - isConnecting, - isMobileViewport, - isSendBusy, - isSendDisabled, - noProviderAvailable, - phase, - showPlanFollowUpPrompt, - ]); + }; - const submitComposer = useCallback( - ( - event?: { preventDefault: () => void }, - dispatchMode?: ComposerDispatchMode, - submissionIntent?: ComposerSubmissionIntent, - ) => { - if ( - noProviderAvailable || - isSendDisabled || - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) - ) { - event?.preventDefault(); - return; - } - // A send while a pasted image is still compressing would strand that - // image: the turn snapshot wouldn't include it, and it would surface - // in the *next* draft instead. Only oversized images hit this — small - // files clear the pending counter within a microtask. - if ( - activeThreadId && - (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 - ) { - event?.preventDefault(); - toastManager.add({ - type: "info", - title: "Still compressing a pasted image.", - description: "Send again once its thumbnail appears.", - }); - return; - } - // A pasted chip's bytes arrive over the network, so the same hazard applies for longer: - // sending now would snapshot a chip with no attachment behind it. - if (pendingDraftWork.has(attachmentTargetKey)) { - event?.preventDefault(); - toastManager.add({ - type: "info", - title: "Still bringing attached context into this message.", - description: "Send again once its chip resolves.", - }); - return; - } - const submission = submitComposerDraft({ - prompt: promptRef.current, - submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", - event, - onSend: (sendEvent) => { - // ChatView reports its final composed-input preflight through the - // composer handle before its first asynchronous send step. - providerInputRejectedRef.current = false; - onSend( - sendEvent, - dispatchMode ?? - resolveComposerDispatchMode({ - running: phase === "running", - alternateModifier: false, - activeTurnDefault: settings.followUpBehavior, - }), - submissionIntent, - ); - return !providerInputRejectedRef.current; - }, + const submitComposer = ( + event?: { preventDefault: () => void }, + dispatchMode?: ComposerDispatchMode, + submissionIntent?: ComposerSubmissionIntent, + ) => { + if ( + noProviderAvailable || + isSendDisabled || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) { + event?.preventDefault(); + return; + } + // A send while a pasted image is still compressing would strand that + // image: the turn snapshot wouldn't include it, and it would surface + // in the *next* draft instead. Only oversized images hit this — small + // files clear the pending counter within a microtask. + if (activeThreadId && (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) { + event?.preventDefault(); + toastManager.add({ + type: "info", + title: "Still compressing a pasted image.", + description: "Send again once its thumbnail appears.", }); - setComposerSubmissionError(submission.validationMessage); - if (!submission.didDispatch) return; - onTypingGuardSend(); - if (shouldBlurMobileComposerOnSubmit()) { - blurMobileComposerAfterSend(); - } - }, - [ - activeThreadId, - activePendingProgress, - attachmentTargetKey, - onTypingGuardSend, - blurMobileComposerAfterSend, - environmentId, - isSendDisabled, - noProviderAvailable, - onSend, - settings.followUpBehavior, - phase, - promptRef, - shouldBlurMobileComposerOnSubmit, - ], - ); - const handleSubmitMessage = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - submitComposer( - event, - resolveComposerDispatchMode({ - running: phase === "running", - alternateModifier: event.metaKey || event.ctrlKey, - activeTurnDefault: settings.followUpBehavior, - }), - ); - }, - [phase, settings.followUpBehavior, submitComposer], - ); - const submitCitationAndSend = useCallback(() => { + return; + } + // A pasted chip's bytes arrive over the network, so the same hazard applies for longer: + // sending now would snapshot a chip with no attachment behind it. + if (pendingDraftWork.has(attachmentTargetKey)) { + event?.preventDefault(); + toastManager.add({ + type: "info", + title: "Still bringing attached context into this message.", + description: "Send again once its chip resolves.", + }); + return; + } + const submission = submitComposerDraft({ + prompt: promptRef.current, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + event, + onSend: (sendEvent) => { + // ChatView reports its final composed-input preflight through the + // composer handle before its first asynchronous send step. + providerInputRejectedRef.current = false; + onSend( + sendEvent, + dispatchMode ?? + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: false, + activeTurnDefault: settings.followUpBehavior, + }), + submissionIntent, + ); + return !providerInputRejectedRef.current; + }, + }); + setComposerSubmissionError(submission.validationMessage); + if (!submission.didDispatch) return; + onTypingGuardSend(); + if (shouldBlurMobileComposerOnSubmit()) { + blurMobileComposerAfterSend(); + } + }; + const submitComposerStable = useStableCallback(submitComposer); + const handleSubmitMessage = (event: React.MouseEvent) => { + event.preventDefault(); + submitComposer( + event, + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: event.metaKey || event.ctrlKey, + activeTurnDefault: settings.followUpBehavior, + }), + ); + }; + const handleSubmitMessageStable = useStableCallback(handleSubmitMessage); + const submitCitationAndSend = () => { submitComposer( undefined, resolveComposerDispatchMode({ @@ -4452,18 +4264,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeTurnDefault: settings.followUpBehavior, }), ); - }, [phase, settings.followUpBehavior, submitComposer]); + }; + const submitCitationAndSendStable = useStableCallback(submitCitationAndSend); // A question or approval took the editor while a citation comment was // unsaved. Keep the comment in the prompt that comes back afterward. - const saveCitationDraftToPrompt = useCallback( - (replacedValue: string, savedValue: string) => { - if (replacedValue !== prompt) return; - if (promptRef.current === replacedValue) promptRef.current = savedValue; - setPrompt(savedValue); - }, - [prompt, promptRef, setPrompt], - ); - const compactThreadContext = useCallback(() => { + const saveCitationDraftToPrompt = (replacedValue: string, savedValue: string) => { + if (replacedValue !== prompt) return; + if (promptRef.current === replacedValue) promptRef.current = savedValue; + setPrompt(savedValue); + }; + const saveCitationDraftToPromptStable = useStableCallback(saveCitationDraftToPrompt); + const compactThreadContext = () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || compactDisabled || @@ -4478,20 +4289,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } onCompactContext(); - }, [ - activePendingApproval, - activeThreadId, - compactDisabled, - composerDraftTarget, - environmentId, - isConnecting, - isSendBusy, - noProviderAvailable, - onCompactContext, - pendingUserInputs.length, - phase, - ]); - const expandMobileComposer = useCallback(() => { + }; + const compactThreadContextStable = useStableCallback(compactThreadContext); + const expandMobileComposer = () => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -4512,7 +4312,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mobileComposerExpandInFlightRef.current = false; }); }); - }, [setIsComposerFocused]); + }; + const expandMobileComposerStable = useStableCallback(expandMobileComposer); // ------------------------------------------------------------------ // Prompt history (ArrowUp / ArrowDown) @@ -4530,68 +4331,55 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHistoryPositionRef.current = null; }, [promptHistoryTargetKey]); - const replacePromptFromHistory = useCallback( - (nextPrompt: string) => { - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); - setComposerTrigger(null); - setComposerHighlightedItemId(null); - }, - [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger], - ); + const replacePromptFromHistory = (nextPrompt: string) => { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + }; - const navigatePromptHistory = useCallback( - (direction: "backward" | "forward", event: KeyboardEvent): boolean => { - if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { - return false; - } - if (isComposerApprovalState || pendingUserInputs.length > 0) return false; - // A composer holding an image, file, picked element, preview - // annotation, or review comment is not empty. Recalling text into it - // would send the old prompt with the new context, which is never what - // ArrowUp meant. - if ( - composerImagesRef.current.length > 0 || - composerFilesRef.current.length > 0 || - composerTerminalContextsRef.current.length > 0 || - composerPreviewAnnotations.length > 0 || - composerReviewComments.length > 0 - ) { - return false; - } - // A typed draft with no active recall can never step, so skip the - // layout read and the entry build for that common case. - if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { - return false; - } - const editor = composerEditorRef.current; - if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { - return false; - } - const step = stepComposerPromptHistory({ - direction, - entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), - position: promptHistoryPositionRef.current, - currentPrompt: promptRef.current, - }); - if (!step) return false; - promptHistoryPositionRef.current = step.position; - replacePromptFromHistory(step.prompt); - return true; - }, - [ - composerTerminalContextsRef, - composerFilesRef, - composerImagesRef, - composerPreviewAnnotations.length, - composerReviewComments.length, - isComposerApprovalState, - pendingUserInputs.length, - promptRef, - replacePromptFromHistory, - ], - ); + const navigatePromptHistory = ( + direction: "backward" | "forward", + event: KeyboardEvent, + ): boolean => { + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { + return false; + } + if (isComposerApprovalState || pendingUserInputs.length > 0) return false; + // A composer holding an image, file, picked element, preview + // annotation, or review comment is not empty. Recalling text into it + // would send the old prompt with the new context, which is never what + // ArrowUp meant. + if ( + composerImagesRef.current.length > 0 || + composerFilesRef.current.length > 0 || + composerTerminalContextsRef.current.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 + ) { + return false; + } + // A typed draft with no active recall can never step, so skip the + // layout read and the entry build for that common case. + if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { + return false; + } + const editor = composerEditorRef.current; + if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + return false; + } + const step = stepComposerPromptHistory({ + direction, + entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + position: promptHistoryPositionRef.current, + currentPrompt: promptRef.current, + }); + if (!step) return false; + promptHistoryPositionRef.current = step.position; + replacePromptFromHistory(step.prompt); + return true; + }; // ------------------------------------------------------------------ // Callbacks: command key @@ -4701,7 +4489,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, []); /** Briefly highlight the badge so the save registers without a flourish. */ - const pulseStashBadge = useCallback(() => { + const pulseStashBadge = () => { stashPulseKeyRef.current += 1; setStashPulse({ key: stashPulseKeyRef.current, active: true }); if (stashPulseTimeoutRef.current !== null) { @@ -4711,334 +4499,318 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) stashPulseTimeoutRef.current = null; setStashPulse((current) => ({ ...current, active: false })); }, 1200); - }, []); - - const restoreStashEntry = useCallback( - async (menuEntry: PromptStashEntry) => { - const filesToVerify = menuEntry.files ?? []; - if (filesToVerify.some((file) => file.environmentId !== environmentId)) { - toastManager.add({ - type: "error", - title: "Stashed files belong to another environment", - description: "Restore this prompt in the environment that received its files.", - }); - return; - } - setIsStashMenuOpen(false); + }; - // The server sweeps pending uploads after 24 hours, so ask before - // reattaching. An expired upload restores as a needs-reattach row - // instead of a reference the next send would fail to verify. Verify - // BEFORE taking: the take removes the entry from durable storage, and a - // tab closed during this await must still find it there after reload. - const verifications = await Promise.all( - filesToVerify.map((file) => - verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }), - ), - ); - const expiredAttachmentIds = new Set( - filesToVerify - .filter((_, index) => verifications[index]?.status === "missing") - .map((file) => file.attachmentId), - ); + const restoreStashEntry = async (menuEntry: PromptStashEntry) => { + const filesToVerify = menuEntry.files ?? []; + if (filesToVerify.some((file) => file.environmentId !== environmentId)) { + toastManager.add({ + type: "error", + title: "Stashed files belong to another environment", + description: "Restore this prompt in the environment that received its files.", + }); + return; + } + setIsStashMenuOpen(false); - // A thread switch during the verify await would mix the new thread's - // prompt with this invocation's captured target. Nothing was taken yet, - // so abort and leave the entry restorable where the user now is. - if ( - isRevertingCheckpointRef.current || - composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current - ) { - return; - } + // The server sweeps pending uploads after 24 hours, so ask before + // reattaching. An expired upload restores as a needs-reattach row + // instead of a reference the next send would fail to verify. Verify + // BEFORE taking: the take removes the entry from durable storage, and a + // tab closed during this await must still find it there after reload. + const verifications = await Promise.all( + filesToVerify.map((file) => + verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }), + ), + ); + const expiredAttachmentIds = new Set( + filesToVerify + .filter((_, index) => verifications[index]?.status === "missing") + .map((file) => file.attachmentId), + ); - // The take is also the double-activation guard (click + Enter): the - // second caller finds the entry gone and stops here. - const { entry, durable } = takeStashEntry(menuEntry.id); - if (!entry) return; - if (!durable) { - toastManager.add({ - type: "warning", - title: "Restored prompt may reappear in the stash", - description: - "Browser storage rejected the update, so this entry could still be there after a reload.", - data: { hideCopyButton: true }, - }); - } + // A thread switch during the verify await would mix the new thread's + // prompt with this invocation's captured target. Nothing was taken yet, + // so abort and leave the entry restorable where the user now is. + if ( + isRevertingCheckpointRef.current || + composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current + ) { + return; + } - const rewrittenContextIds = entry.records - ? importContextRecords(entry.records, null) - : new Map(); - const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => { - const contextId = rewrittenContextIds.get(reference.contextId); - return contextId - ? formatInlineContextReference({ - ...reference, - contextId, - kind: reference.kind === "element" ? "preview-annotation" : reference.kind, - }) - : reference.source; + // The take is also the double-activation guard (click + Enter): the + // second caller finds the entry gone and stops here. + const { entry, durable } = takeStashEntry(menuEntry.id); + if (!entry) return; + if (!durable) { + toastManager.add({ + type: "warning", + title: "Restored prompt may reappear in the stash", + description: + "Browser storage rejected the update, so this entry could still be there after a reload.", + data: { hideCopyButton: true }, }); - const currentPrompt = promptRef.current; - // An image-only stash must not append blank lines to whatever is - // already in the composer. - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.trim().length - ? `${currentPrompt.replace(/\s+$/, "")}\n\n${restoredPrompt}` - : restoredPrompt; - let promptChanged = nextPrompt !== currentPrompt; - if (promptChanged) { - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); - setComposerTrigger(null); - } + } - let unrestoredFileNames: string[] = []; - const expiredFileNames: string[] = []; - let restoredFileCount = 0; - const stashedFiles = entry.files ?? []; - if (stashedFiles.length > 0) { - const composerFilesNow = composerFilesRef.current; - const existingFileIds = new Set(composerFilesNow.map((file) => file.id)); - const retainedUploadIds = new Set( - composerFilesNow.flatMap((file) => - file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [], - ), - ); - const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey)); - const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach); - const restoredMarkerIds = new Set(); - const duplicateFiles: PersistedComposerFileAttachment[] = []; - const markerReplacements: ComposerFileAttachment[] = []; - const appendedFiles: ComposerFileAttachment[] = []; - for (const file of stashedFiles) { - const expired = expiredAttachmentIds.has(file.attachmentId); - const key = composerFileDedupKey(file); - const restored: ComposerFileAttachment = { - type: "file", - id: file.id, - name: file.name, - mimeType: file.mimeType, - sizeBytes: file.sizeBytes, - file: null, - ...(file.source ? { source: file.source } : {}), - // An expired upload carries no ids, so it hydrates as a - // needs-reattach row and the "Attach again" flow takes over. - ...(expired - ? {} - : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }), - }; - if (existingFileIds.has(file.id)) { - if (!expired && !retainedUploadIds.has(file.attachmentId)) { - duplicateFiles.push(file); - } - continue; - } - const reattachMarker = reattachMarkers.find( - (marker) => - !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file), - ); - if (reattachMarker) { - restoredMarkerIds.add(reattachMarker.id); - existingFileIds.add(file.id); - existingFileKeys.add(key); - if (expired) { - expiredFileNames.push(file.name); - } else { - retainedUploadIds.add(file.attachmentId); - markerReplacements.push(restored); - } - continue; - } - if (existingFileKeys.has(key)) { - if (!expired && !retainedUploadIds.has(file.attachmentId)) { - duplicateFiles.push(file); - } - continue; + const rewrittenContextIds = entry.records + ? importContextRecords(entry.records, null) + : new Map(); + const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => { + const contextId = rewrittenContextIds.get(reference.contextId); + return contextId + ? formatInlineContextReference({ + ...reference, + contextId, + kind: reference.kind === "element" ? "preview-annotation" : reference.kind, + }) + : reference.source; + }); + const currentPrompt = promptRef.current; + // An image-only stash must not append blank lines to whatever is + // already in the composer. + const nextPrompt = + restoredPrompt.length === 0 + ? currentPrompt + : currentPrompt.trim().length + ? `${currentPrompt.replace(/\s+$/, "")}\n\n${restoredPrompt}` + : restoredPrompt; + let promptChanged = nextPrompt !== currentPrompt; + if (promptChanged) { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + } + + let unrestoredFileNames: string[] = []; + const expiredFileNames: string[] = []; + let restoredFileCount = 0; + const stashedFiles = entry.files ?? []; + if (stashedFiles.length > 0) { + const composerFilesNow = composerFilesRef.current; + const existingFileIds = new Set(composerFilesNow.map((file) => file.id)); + const retainedUploadIds = new Set( + composerFilesNow.flatMap((file) => + file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [], + ), + ); + const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey)); + const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach); + const restoredMarkerIds = new Set(); + const duplicateFiles: PersistedComposerFileAttachment[] = []; + const markerReplacements: ComposerFileAttachment[] = []; + const appendedFiles: ComposerFileAttachment[] = []; + for (const file of stashedFiles) { + const expired = expiredAttachmentIds.has(file.attachmentId); + const key = composerFileDedupKey(file); + const restored: ComposerFileAttachment = { + type: "file", + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + file: null, + ...(file.source ? { source: file.source } : {}), + // An expired upload carries no ids, so it hydrates as a + // needs-reattach row and the "Attach again" flow takes over. + ...(expired + ? {} + : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }), + }; + if (existingFileIds.has(file.id)) { + if (!expired && !retainedUploadIds.has(file.attachmentId)) { + duplicateFiles.push(file); } + continue; + } + const reattachMarker = reattachMarkers.find( + (marker) => + !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file), + ); + if (reattachMarker) { + restoredMarkerIds.add(reattachMarker.id); existingFileIds.add(file.id); existingFileKeys.add(key); if (expired) { expiredFileNames.push(file.name); } else { retainedUploadIds.add(file.attachmentId); + markerReplacements.push(restored); } - appendedFiles.push(restored); + continue; } - const capacity = remainingComposerAttachmentSlots( - composerImagesRef.current.length + composerFilesNow.length, - pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, - ); - // Marker replacements reuse their marker's slot; only appended files - // consume capacity. - const filesToAppend = appendedFiles.slice(0, capacity); - const skippedFiles = appendedFiles.slice(capacity); - unrestoredFileNames = skippedFiles.map((file) => file.name); - // A non-durable take can resurrect the stash entry after a reload; - // deleting these uploads would leave it pointing at nothing. - if (durable) { - for (const file of duplicateFiles) { + if (existingFileKeys.has(key)) { + if (!expired && !retainedUploadIds.has(file.attachmentId)) { + duplicateFiles.push(file); + } + continue; + } + existingFileIds.add(file.id); + existingFileKeys.add(key); + if (expired) { + expiredFileNames.push(file.name); + } else { + retainedUploadIds.add(file.attachmentId); + } + appendedFiles.push(restored); + } + const capacity = remainingComposerAttachmentSlots( + composerImagesRef.current.length + composerFilesNow.length, + pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, + ); + // Marker replacements reuse their marker's slot; only appended files + // consume capacity. + const filesToAppend = appendedFiles.slice(0, capacity); + const skippedFiles = appendedFiles.slice(capacity); + unrestoredFileNames = skippedFiles.map((file) => file.name); + // A non-durable take can resurrect the stash entry after a reload; + // deleting these uploads would leave it pointing at nothing. + if (durable) { + for (const file of duplicateFiles) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId, + attachmentId: file.attachmentId, + }); + } + for (const file of skippedFiles) { + if (file.uploadedAttachmentId) { releasePersistedAttachmentUpload({ id: file.id, environmentId, - attachmentId: file.attachmentId, + attachmentId: file.uploadedAttachmentId, }); } - for (const file of skippedFiles) { - if (file.uploadedAttachmentId) { - releasePersistedAttachmentUpload({ - id: file.id, - environmentId, - attachmentId: file.uploadedAttachmentId, - }); - } - } } - const restoredFiles = [...markerReplacements, ...filesToAppend]; - if (restoredFiles.length > 0) { - addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true }); - const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt; - if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) { - promptRef.current = restoredFilePrompt; - setComposerCursor( - collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length), - ); - setComposerTrigger(null); - promptChanged = true; - } - restoredFileCount = filesToAppend.length; + } + const restoredFiles = [...markerReplacements, ...filesToAppend]; + if (restoredFiles.length > 0) { + addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true }); + const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt; + if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) { + promptRef.current = restoredFilePrompt; + setComposerCursor( + collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length), + ); + setComposerTrigger(null); + promptChanged = true; } + restoredFileCount = filesToAppend.length; } + } - let unrestoredImageNames: string[] = []; - if (entry.attachments.length > 0) { - const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); - // The draft store also dedupes by mimeType+sizeBytes+name, so filter - // on the same key here. Counting a duplicate against capacity would - // burn a slot the store then refuses to fill, pushing a genuinely - // unique image into the overflow list for nothing. - const existingDedupKeys = new Set( - composerImagesRef.current.map( - (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`, + let unrestoredImageNames: string[] = []; + if (entry.attachments.length > 0) { + const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); + // The draft store also dedupes by mimeType+sizeBytes+name, so filter + // on the same key here. Counting a duplicate against capacity would + // burn a slot the store then refuses to fill, pushing a genuinely + // unique image into the overflow list for nothing. + const existingDedupKeys = new Set( + composerImagesRef.current.map( + (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`, + ), + ); + const capacity = remainingComposerAttachmentSlots( + composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount, + pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, + ); + const pending = entry.attachments.filter( + (attachment) => + !existingIds.has(attachment.id) && + !existingDedupKeys.has( + `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`, ), - ); - const capacity = remainingComposerAttachmentSlots( - composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount, - pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, - ); - const pending = entry.attachments.filter( - (attachment) => - !existingIds.has(attachment.id) && - !existingDedupKeys.has( - `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`, - ), - ); - // Anything past the attachment limit cannot be restored. The entry is - // already out of the queue, so report the overflow by name instead of - // discarding it silently. - unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); - const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); - if (restoredImages.length > 0) { - addComposerDraftImages(composerDraftTarget, restoredImages); - } + ); + // Anything past the attachment limit cannot be restored. The entry is + // already out of the queue, so report the overflow by name instead of + // discarding it silently. + unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); + const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); + if (restoredImages.length > 0) { + addComposerDraftImages(composerDraftTarget, restoredImages); } + } - // Deliberately no model/provider restore: the stash exists to carry a - // prompt across threads and providers, so whatever the composer has - // selected right now stays selected. - - // Each cause gets its own sentence so "too large" is never blamed for a - // file that actually failed to decode, or for one the composer simply - // had no room to take back. - const missingImageReasons: string[] = []; - if (entry.droppedImageNames.length > 0) { - missingImageReasons.push( - `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, - ); - } - if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { - missingImageReasons.push( - `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, - ); - } - if (unrestoredImageNames.length > 0) { - missingImageReasons.push( - `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, - ); - } - if (unrestoredFileNames.length > 0) { - missingImageReasons.push( - `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, - ); - } - if (expiredFileNames.length > 0) { - missingImageReasons.push( - `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`, - ); - } - if (missingImageReasons.length > 0) { - toastManager.add({ - type: "warning", - title: "Some attachments were not restored", - description: missingImageReasons.join(" "), - }); - } + // Deliberately no model/provider restore: the stash exists to carry a + // prompt across threads and providers, so whatever the composer has + // selected right now stays selected. + + // Each cause gets its own sentence so "too large" is never blamed for a + // file that actually failed to decode, or for one the composer simply + // had no room to take back. + const missingImageReasons: string[] = []; + if (entry.droppedImageNames.length > 0) { + missingImageReasons.push( + `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + ); + } + if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { + missingImageReasons.push( + `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, + ); + } + if (unrestoredImageNames.length > 0) { + missingImageReasons.push( + `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (unrestoredFileNames.length > 0) { + missingImageReasons.push( + `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (expiredFileNames.length > 0) { + missingImageReasons.push( + `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`, + ); + } + if (missingImageReasons.length > 0) { + toastManager.add({ + type: "warning", + title: "Some attachments were not restored", + description: missingImageReasons.join(" "), + }); + } - // Only yank the caret to the end when text was actually inserted; - // restoring images alone should leave the user where they were typing. - if (promptChanged) { - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAtEnd(); - }); - } - }, - [ - addComposerDraftFiles, - addComposerDraftImages, - composerDraftTarget, - composerFilesRef, - composerImagesRef, - environmentId, - promptRef, - setComposerDraftPrompt, - setComposerTrigger, - takeStashEntry, - importContextRecords, - ], - ); + // Only yank the caret to the end when text was actually inserted; + // restoring images alone should leave the user where they were typing. + if (promptChanged) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + } + }; + const restoreStashEntryStable = useStableCallback(restoreStashEntry); - const deleteStashEntry = useCallback( - (entry: PromptStashEntry) => { - const { entry: removed, durable } = takeStashEntry(entry.id); - if (!stashQueue.some((candidate) => candidate.id !== entry.id)) { - setIsStashMenuOpen(false); - } - if (durable && removed) { - for (const file of removed.files ?? []) { - releasePersistedAttachmentUpload({ - id: file.id, - environmentId: file.environmentId, - attachmentId: file.attachmentId, - }); - } - } - if (!durable) { - toastManager.add({ - type: "warning", - title: "Stash entry may come back", - description: - "Browser storage rejected the delete, so this prompt could reappear after a reload.", - data: { hideCopyButton: true }, + const deleteStashEntry = (entry: PromptStashEntry) => { + const { entry: removed, durable } = takeStashEntry(entry.id); + if (!stashQueue.some((candidate) => candidate.id !== entry.id)) { + setIsStashMenuOpen(false); + } + if (durable && removed) { + for (const file of removed.files ?? []) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: file.environmentId, + attachmentId: file.attachmentId, }); } - }, - [stashQueue, takeStashEntry], - ); + } + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stash entry may come back", + description: + "Browser storage rejected the delete, so this prompt could reappear after a reload.", + data: { hideCopyButton: true }, + }); + } + }; + const deleteStashEntryStable = useStableCallback(deleteStashEntry); - const stashCurrentPrompt = useCallback(async () => { + const stashCurrentPrompt = async () => { // Stashing clears the draft. A pasted attachment still downloading would then land in the // emptied composer instead of travelling with the entry it belongs to. if (pendingDraftWork.has(attachmentTargetKeyRef.current)) { @@ -5264,38 +5036,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // snapshot's ⌘S until the composer remounts. stashInFlightRef.current.delete(snapshotKey); } - }, [ - clearComposerDraftPromptAndImages, - clearComposerDraftTerminalContexts, - setComposerDraftPrompt, - setComposerTrigger, - composerDraftTarget, - composerFilesRef, - composerImagesRef, - composerTerminalContextsRef, - composerReviewComments, - composerPreviewAnnotations, - removeComposerDraftReviewComment, - removeComposerDraftPreviewAnnotation, - environmentId, - finalizeStashEntryImages, - promptRef, - pulseStashBadge, - restoreStashEntry, - stashEntryToQueue, - ]); + }; + const stashCurrentPromptStable = useStableCallback(stashCurrentPrompt); - const toggleStashMenu = useCallback(() => { + const toggleStashMenu = () => { if (isComposerCollapsedMobile) { expandMobileComposer(); setIsStashMenuOpen(true); return; } setIsStashMenuOpen((open) => !open); - }, [expandMobileComposer, isComposerCollapsedMobile]); - const toggleTasksDrawer = useCallback(() => { + }; + const toggleStashMenuStable = useStableCallback(toggleStashMenu); + const toggleTasksDrawer = () => { setIsTasksDrawerOpen((open) => !open); - }, []); + }; + const toggleTasksDrawerStable = useStableCallback(toggleTasksDrawer); const hasBannerItems = props.bannerItems.length > 0; const hasBlockingComposerTopDrawer = activePendingApproval !== null || pendingUserInputs.length > 0; @@ -5308,7 +5064,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const inlineTasksBadge = showInlineTasksBadge ? ( @@ -5862,7 +5618,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isComposerApprovalState || projectSelectionRequired || activePendingProgress !== null) { return; } - void stashCurrentPrompt(); + void stashCurrentPromptStable(); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); @@ -5873,7 +5629,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, pendingUserInputs.length, projectSelectionRequired, - stashCurrentPrompt, + stashCurrentPromptStable, isRevertingCheckpoint, terminalOpen, ]); @@ -6261,94 +6017,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event.stopPropagation(); }; - const insertComposerText = useCallback( - ( - text: string, - position: "cursor" | "end", - options?: { - ensureLeadingBoundary?: boolean; - citationCommentAnchor?: AssistantCitationSourceAnchor; - clipboardData?: DataTransfer; - }, - ): boolean => { - if ( - text.length === 0 || - isConnecting || - isComposerApprovalState || - pendingUserInputs.length > 0 || - projectSelectionRequired || - (options?.citationCommentAnchor && !composerEditorRef.current) - ) { - return false; - } - if (options?.clipboardData) { - text = importPastedComposerText(options.clipboardData, importContextFragment); - } - const prompt = promptRef.current; - const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length; - const needsLeadingSpace = - (options?.ensureLeadingBoundary ?? false) && - cursor > 0 && - !/\s/.test(prompt[cursor - 1] ?? ""); - const rangeEnd = extendReplacementRangeForTrailingSpace(prompt, cursor, text); - return applyPromptReplacement( - cursor, - rangeEnd, - needsLeadingSpace ? ` ${text}` : text, - options?.citationCommentAnchor - ? { - citationComment: { - start: cursor + (needsLeadingSpace ? 1 : 0), - sourceAnchor: options.citationCommentAnchor, - // An existing space after the cursor is consumed and re-emitted, so it stays. - insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, - }, - focusEditorAfterReplace: false, - } - : undefined, - ); - }, - [ - applyPromptReplacement, - isComposerApprovalState, - isConnecting, - pendingUserInputs.length, - projectSelectionRequired, - promptRef, - readComposerSnapshot, - importContextFragment, - ], - ); - - const insertComposerTextAtEnd = useCallback( - (text, options) => { - const inserted = insertComposerText(text, "end", options); - if (inserted && isComposerCollapsedMobile) { - // The expanded editor is hidden at phone widths, so its scheduled - // focus cannot expand the composer by itself. Reveal it before the - // focus frame runs to preserve type-to-focus and external inserts. - expandMobileComposer(); - } - return inserted; + const insertComposerText = ( + text: string, + position: "cursor" | "end", + options?: { + ensureLeadingBoundary?: boolean; + citationCommentAnchor?: AssistantCitationSourceAnchor; + clipboardData?: DataTransfer; }, - [expandMobileComposer, insertComposerText, isComposerCollapsedMobile], - ); + ): boolean => { + if ( + text.length === 0 || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + (options?.citationCommentAnchor && !composerEditorRef.current) + ) { + return false; + } + if (options?.clipboardData) { + text = importPastedComposerText(options.clipboardData, importContextFragment); + } + const prompt = promptRef.current; + const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && + cursor > 0 && + !/\s/.test(prompt[cursor - 1] ?? ""); + const rangeEnd = extendReplacementRangeForTrailingSpace(prompt, cursor, text); + return applyPromptReplacement( + cursor, + rangeEnd, + needsLeadingSpace ? ` ${text}` : text, + options?.citationCommentAnchor + ? { + citationComment: { + start: cursor + (needsLeadingSpace ? 1 : 0), + sourceAnchor: options.citationCommentAnchor, + // An existing space after the cursor is consumed and re-emitted, so it stays. + insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, + }, + focusEditorAfterReplace: false, + } + : undefined, + ); + }; + const insertComposerTextStable = useStableCallback(insertComposerText); + + const insertComposerTextAtEnd: ChatComposerHandle["insertTextAtEnd"] = (text, options) => { + const inserted = insertComposerText(text, "end", options); + if (inserted && isComposerCollapsedMobile) { + // The expanded editor is hidden at phone widths, so its scheduled + // focus cannot expand the composer by itself. Reveal it before the + // focus frame runs to preserve type-to-focus and external inserts. + expandMobileComposer(); + } + return inserted; + }; + const insertComposerTextAtEndStable = useStableCallback(insertComposerTextAtEnd); // Context produced by other panels (diff comments, preview picks) asks the store to place // its chip; while this composer is mounted for the draft, that means the caret. - const insertContextReferencesAtCaret = useCallback( - (references: ReadonlyArray): boolean => - insertComposerText(`${references.map(formatInlineContextReference).join(" ")} `, "cursor", { - ensureLeadingBoundary: true, - }), - [insertComposerText], - ); + const insertContextReferencesAtCaret = ( + references: ReadonlyArray, + ): boolean => + insertComposerText(`${references.map(formatInlineContextReference).join(" ")} `, "cursor", { + ensureLeadingBoundary: true, + }); + const insertContextReferencesAtCaretStable = useStableCallback(insertContextReferencesAtCaret); const setContextInsertionHandler = useComposerDraftStore( (store) => store.setContextInsertionHandler, ); useEffect(() => { - return setContextInsertionHandler(composerDraftTarget, insertContextReferencesAtCaret); - }, [composerDraftTarget, insertContextReferencesAtCaret, setContextInsertionHandler]); + return setContextInsertionHandler(composerDraftTarget, insertContextReferencesAtCaretStable); + }, [composerDraftTarget, insertContextReferencesAtCaretStable, setContextInsertionHandler]); // File-tree drags land as mentions. Handled in the capture phase so the // editor never sees the drop; the load-bearing rules (native stop, "move" @@ -6380,7 +6123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) await importComposerThreadAttachment({ targetKey, pendingImports: pendingThreadImportsRef.current, - countReservedAttachments, + countReservedAttachments: countReservedAttachmentsStable, load: async () => { const result = await loadThreadTranscript({ environmentId: ref.environmentId, @@ -6443,12 +6186,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const text = `${records .map((record) => formatInlineContextReference(threadContextReference(record))) .join(" ")} `; - const snapshot = readComposerSnapshot(); + const snapshot = readComposerSnapshotStable(); const cursor = snapshot.expandedCursor; const leadingSpace = cursor > 0 && !/\s/.test(snapshot.value[cursor - 1] ?? ""); const inserted = canAnswer && - applyPromptReplacement( + applyPromptReplacementStable( cursor, extendReplacementRangeForTrailingSpace(snapshot.value, cursor, text), leadingSpace ? ` ${text}` : text, @@ -6464,7 +6207,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftThreadContexts(questionAttachmentTarget, records, { appendReference: false, }); - if (isComposerCollapsedMobile) expandMobileComposer(); + if (isComposerCollapsedMobile) expandMobileComposerStable(); } else { addComposerDraftThreadContexts(composerDraftTarget, records); } @@ -6490,15 +6233,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.addEventListener("dragend", onWindowDragEnd); return () => window.removeEventListener("dragend", onWindowDragEnd); }, [isDragOverComposer]); - const handleInterruptPrimaryAction = useCallback(() => { + const handleInterruptPrimaryAction = () => { void onInterrupt(); - }, [onInterrupt]); - const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { + }; + const handleInterruptPrimaryActionStable = useStableCallback(handleInterruptPrimaryAction); + const handleImplementPlanInNewThreadPrimaryAction = () => { void onImplementPlanInNewThread(); - }, [onImplementPlanInNewThread]); + }; + const handleImplementPlanInNewThreadPrimaryActionStable = useStableCallback( + handleImplementPlanInNewThreadPrimaryAction, + ); // The phone composer collapses when the editor loses focus. Desktop only // rests on a timeline scroll, so losing focus there changes nothing. - const scheduleComposerCollapseCheck = useCallback(() => { + const scheduleComposerCollapseCheck = () => { if (!isMobileViewport || mobileComposerExpandInFlightRef.current) { return; } @@ -6525,7 +6272,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setIsComposerFocused(false); }); - }, [isMobileViewport, setIsComposerFocused]); + }; useEffect(() => { return () => { @@ -6544,7 +6291,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Imperative handle // ------------------------------------------------------------------ - const openModelPicker = useCallback(() => { + const openModelPicker = () => { if (composerControlsHidden) { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -6554,7 +6301,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerFocused(true); } setIsComposerModelPickerOpen(true); - }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]); + }; + const openModelPickerStable = useStableCallback(openModelPicker); useImperativeHandle( composerRef, @@ -6610,7 +6358,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); continue; } - insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, { + insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { ensureLeadingBoundary: true, }); } @@ -6619,7 +6367,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 || pendingDraftWork.has(attachmentTargetKey), - insertTextAtEnd: insertComposerTextAtEnd, + insertTextAtEnd: insertComposerTextAtEndStable, pasteTextAtEnd: (text: string, options) => { const bypassAutoAttachment = options?.bypassAutoAttachment === true || @@ -6638,17 +6386,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; }, citeAssistantText: (citation, sourceAnchor) => - insertComposerText( + insertComposerTextStable( formatAssistantCitationForComposer(citation, citation.comment), "cursor", { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor }, ), - openModelPicker, + openModelPicker: openModelPickerStable, toggleModelPicker: () => { if (isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); } else { - openModelPicker(); + openModelPickerStable(); } }, openControl: (command) => { @@ -6673,14 +6421,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) trigger.focus({ preventScroll: true }); trigger.click(); }, - compactContext: compactThreadContext, + compactContext: compactThreadContextStable, isModelPickerOpen: () => isComposerModelPickerOpen, isCaretAtStart: () => { const range = composerEditorRef.current?.readSelectionRange(); return range !== undefined && range.start === 0 && range.end === 0; }, readSnapshot: () => { - return readComposerSnapshot(); + return readComposerSnapshotStable(); }, resetCursorState: (options?: { cursor?: number; @@ -6706,7 +6454,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, addTerminalContext: (selection: TerminalContextSelection) => { if (!activeThread || isChoiceOnlyPendingQuestion || isLiteralPendingAnswer) return; - const snapshot = readComposerSnapshot(); + const snapshot = readComposerSnapshotStable(); const context = { id: randomUUID(), threadId: activeThread.id, @@ -6787,8 +6535,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, composerTerminalContexts, insertComposerDraftTerminalContext, - insertComposerText, - insertComposerTextAtEnd, + insertComposerTextStable, + insertComposerTextAtEndStable, promptRef, composerImagesRef, composerFilesRef, @@ -6804,10 +6552,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isLiteralPendingAnswer, pendingUserInputs.length, projectSelectionRequired, - applyPromptReplacement, + applyPromptReplacementStable, isComposerModelPickerOpen, - openModelPicker, - readComposerSnapshot, + openModelPickerStable, + readComposerSnapshotStable, resetComposerTrigger, setComposerTrigger, selectedModel, @@ -6823,7 +6571,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderModels, interactionMode, planModeUiEnabled, - compactThreadContext, + compactThreadContextStable, restoreAfterTimelineReachedEnd, getTimelineScrollableNode, isTimelineAtLogicalEnd, @@ -6836,7 +6584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return (
{ const target = event.target; if (isInsideRestingComposerControlScope(target)) return; @@ -7039,7 +6787,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", )} onPointerDown={(event) => event.preventDefault()} - onClick={expandMobileComposer} + onClick={expandMobileComposerStable} aria-label="Write custom answer" > {activePendingProgress?.customAnswer || "Write custom answer"} @@ -7066,9 +6814,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasSendableContent={false} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} + onInterrupt={handleInterruptPrimaryActionStable} onImplementPlanInNewThread={ - handleImplementPlanInNewThreadPrimaryAction + handleImplementPlanInNewThreadPrimaryActionStable } /> ) : null} @@ -7086,7 +6834,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeTasksProgress && activeTaskSteps ? ( @@ -7095,7 +6843,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) @@ -7108,7 +6856,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) menuOpen={isStashMenuOpen} pulseKey={stashPulse.key} pulsing={stashPulse.active} - onToggleMenu={toggleStashMenu} + onToggleMenu={toggleStashMenuStable} /> ) : null} @@ -7145,7 +6893,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} - onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposer} + onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposerStable} disabled={isChoiceOnlyPendingQuestion} aria-label="Expand composer" > @@ -7215,8 +6963,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelPickerOpen: false, }, })} - onRestore={restoreStashEntry} - onDelete={deleteStashEntry} + onRestore={restoreStashEntryStable} + onDelete={deleteStashEntryStable} onClose={() => setIsStashMenuOpen(false)} /> @@ -7241,8 +6989,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) triggerKind={composerTriggerKind} emptyStateText={composerMenuEmptyState} activeItemId={activeComposerMenuItem?.id ?? null} - onHighlightedItemChange={onComposerMenuItemHighlighted} - onSelect={onSelectComposerItem} + onHighlightedItemChange={onComposerMenuItemHighlightedStable} + onSelect={onSelectComposerItemStable} /> )} @@ -7682,8 +7430,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } cursor={composerCursor} contextRecords={composerContextRecords} - buildContextClipboardFragment={buildContextClipboardFragment} - importContextFragment={importContextFragment} + buildContextClipboardFragment={buildContextClipboardFragmentStable} + importContextFragment={importContextFragmentStable} skills={selectedProviderSkills} containerClassName={cn(isComposerResting && "min-w-0 flex-1")} className={cn( @@ -7696,14 +7444,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerResting && "flex items-center overflow-hidden whitespace-nowrap leading-8", )} - onChange={onPromptChange} - onVisibleSelectionChange={expandComposerForEditorChange} + onChange={onPromptChangeStable} + onVisibleSelectionChange={expandComposerForEditorChangeStable} onCommandKeyDown={onComposerCommandKey} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} - onCitationSubmitAndSend={submitCitationAndSend} - onCitationDraftSave={saveCitationDraftToPrompt} + onCitationSubmitAndSend={submitCitationAndSendStable} + onCitationDraftSave={saveCitationDraftToPromptStable} onPaste={onComposerPaste} placeholder={ isComposerApprovalState @@ -7757,8 +7505,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasSendableContent={false} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onInterrupt={handleInterruptPrimaryActionStable} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryActionStable} /> ) : null} @@ -7884,11 +7632,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) canResume={showResumeAction} preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting} isEditingQueuedMessage={isEditingQueuedMessage} - onSubmitMessage={handleSubmitMessage} + onSubmitMessage={handleSubmitMessageStable} onResume={onResume} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onInterrupt={handleInterruptPrimaryActionStable} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryActionStable} compactBeforeSendTokens={props.resumeCompactionTokens} keepFullHistory={props.keepFullHistory} onToggleKeepFullHistory={props.onToggleKeepFullHistory} @@ -7896,7 +7644,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactDisabled || noProviderAvailable || isSendBusy || isConnecting } compactDisabledReason={resolvedCompactDisabledReason} - {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} + {...(compactCommandAvailable + ? { onCompactContext: compactThreadContextStable } + : {})} /> diff --git a/docs/internals/web-ui.md b/docs/internals/web-ui.md index 8f8213cc7dc3..8eba6c044959 100644 --- a/docs/internals/web-ui.md +++ b/docs/internals/web-ui.md @@ -37,3 +37,19 @@ The composer banners are the reference. Build new UI the same way. Compose or extend a component instead of copying its markup and classes. When a call site repeats a treatment another one already uses, make it a variant or a slot on the shared component. A new composer banner or notice uses the `ComposerBanner` slots. + +## Handlers in large components + +V8 gives every closure created in one render a single shared scope. A memoized handler keeps +the scope of the render that created it, and that scope holds the other memoized values of that +render. When handlers in one component change at different times, each keeps an older scope that +holds older handlers, and old renders, with their server config and thread data, stay in memory +for as long as the component is mounted. React Compiler's memoized closures do the same. + +In large, long-lived components such as `ChatView` and `ChatComposer`, write handlers as plain +functions and wrap them with [`useStableCallback`](../../apps/web/src/hooks/useStableCallback.ts) +only where they leave the component: props, hook dependency arrays, and effect or memo bodies. +Calls inside the component keep the plain function, so work that resumes after an `await` still +acts on the render it started in. A function that a child calls during render, or after an +`await`, comes from a module-level factory memoized on its inputs, so its closure keeps only +those inputs. From 0b68a4a9c36e90341491bad07196a8c123fd4e33 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:36:06 +0530 Subject: [PATCH 37/41] fix(server): steering no longer ends running subagents (#19210) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 312 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.testkit.ts | 5 + .../Adapters/ClaudeAdapterV2.ts | 119 +++++++ ...laudeAutomaticDelivery.integration.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 14 +- .../SteeringCompletion.integration.test.ts | 151 +++++---- 6 files changed, 520 insertions(+), 82 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index edaafff081e2..868e7fa0bd62 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1035,6 +1035,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1196,6 +1197,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1317,6 +1319,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1544,6 +1547,7 @@ describe("ClaudeAdapterV2 attachments", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1690,6 +1694,7 @@ describe("ClaudeAdapterV2 attachments", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1786,6 +1791,7 @@ describe("ClaudeAdapterV2 native fork", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2079,6 +2085,7 @@ describe("ClaudeAdapterV2 native session identity", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2340,6 +2347,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly stopTask?: ( taskId: string, ) => Effect.Effect; + readonly backgroundTasks?: ( + toolUseId: string, + ) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; readonly subagentLaunchToolUseId?: string; readonly nativeSessionId?: string; @@ -2420,6 +2430,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, close: Queue.shutdown(processMessages), @@ -2460,6 +2471,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.sync(() => { permissionModeChanges.push(mode); }), + backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, @@ -5014,6 +5026,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends @@ -5275,6 +5288,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), @@ -10367,6 +10381,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. @@ -10555,6 +10570,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -10795,6 +10811,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -10994,6 +11011,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -11175,6 +11193,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -11312,6 +11331,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -13910,6 +13930,298 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { backgrounding: "succeeds" }, + { backgrounding: "fails" }, + { backgrounding: "never answers" }, + { backgrounding: "answers after the steer went out" }, + { backgrounding: "answers after the turn ended" }, + { backgrounding: "answers after the subagent ran again" }, + ] as const)( + "moves the root's foreground subagents to the background before a steer ($backgrounding)", + ({ backgrounding }) => + Effect.scoped( + Effect.gen(function* () { + const FOREGROUND_TOOL_USE_ID = "toolu-steer-foreground"; + const NESTED_TOOL_USE_ID = "toolu-steer-nested"; + const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; + const calls: Array = []; + // What happens while the move is still unanswered. + let beforeAnswer: Effect.Effect = Effect.void; + const harness = yield* makeWakeHarnessWithOptions({ + backgroundTasks: (toolUseId) => + Effect.sync(() => calls.push(toolUseId)).pipe( + Effect.andThen( + backgrounding === "answers after the turn ended" || + backgrounding === "answers after the subagent ran again" + ? Effect.suspend(() => beforeAnswer).pipe(Effect.as(true)) + : backgrounding === "fails" + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "backgroundTasks", + cause: new Error("Background tasks are disabled in this session."), + }), + ) + : backgrounding === "never answers" + ? Effect.never + : backgrounding === "answers after the steer went out" + ? Effect.sleep("6 seconds").pipe(Effect.as(true)) + : Effect.succeed(true), + ), + ), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attemptId = RunAttemptId.make("attempt-steer-foreground-subagent"); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId, + text: "Audit the repo with a subagent.", + attachments: [], + }); + yield* harness.runtime.startTurn(input); + if (backgrounding === "answers after the turn ended") { + beforeAnswer = Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a10", result: "Done." }), + ).pipe( + Effect.andThen(awaitUntil(() => harness.terminalEvents().length === 1, "turn end")), + ); + } + const foregroundTaskStarted = (task: { + readonly spawnDepth?: number; + readonly taskId: string; + readonly toolUseId: string; + readonly uuid: string; + }) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: task.taskId, + tool_use_id: task.toolUseId, + description: task.taskId, + subagent_type: "general-purpose", + is_backgrounded: false, + task_type: "local_agent", + prompt: "Audit.", + ...(task.spawnDepth === undefined ? {} : { spawn_depth: task.spawnDepth }), + uuid: task.uuid, + session_id: WAKE_NATIVE_SESSION, + }); + for (const frame of [ + foregroundTaskStarted({ + taskId: "task-steer-finished", + toolUseId: FINISHED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a01", + }), + makeSubagentNotificationFrame({ + taskId: "task-steer-finished", + toolUseId: FINISHED_TOOL_USE_ID, + summary: "Finished before the steer.", + uuid: "00000000-0000-4000-8000-000000000a02", + }), + foregroundTaskStarted({ + taskId: "task-steer-foreground", + toolUseId: FOREGROUND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a03", + }), + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_steer_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Check." }, + }, + ], + }, + parent_tool_use_id: FOREGROUND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a04", + session_id: WAKE_NATIVE_SESSION, + }), + foregroundTaskStarted({ + taskId: "task-steer-nested", + toolUseId: NESTED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a05", + }), + // Nested too, though its launch never reached the adapter. + foregroundTaskStarted({ + taskId: "task-steer-deep", + toolUseId: "toolu-steer-deep", + spawnDepth: 2, + uuid: "00000000-0000-4000-8000-000000000a11", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + const latestSubagent = (taskId: string) => + harness.events + .flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId + ? [event.subagent] + : [], + ) + .at(-1); + yield* awaitUntil( + () => + latestSubagent("task-steer-nested")?.status === "running" && + latestSubagent("task-steer-deep")?.status === "running" && + latestSubagent("task-steer-foreground")?.status === "running" && + latestSubagent("task-steer-finished")?.status === "completed", + "subagents registered", + ); + + const offered = harness.offeredMessages.length; + const steer = harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-steer-foreground-subagent"), + text: "Also check the docs.", + attachments: [], + }, + }); + const RERUN_TOOL_USE_ID = "toolu-steer-rerun"; + if (backgrounding === "answers after the subagent ran again") { + // It ends and a SendMessage runs it again, in the foreground. + beforeAnswer = harness + .offerAndWait( + makeSubagentNotificationFrame({ + taskId: "task-steer-foreground", + toolUseId: FOREGROUND_TOOL_USE_ID, + summary: "First run done.", + uuid: "00000000-0000-4000-8000-000000000a12", + }), + ) + .pipe( + Effect.andThen( + harness.offerAndWait( + foregroundTaskStarted({ + taskId: "task-steer-foreground", + toolUseId: RERUN_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a13", + }), + ), + ), + ); + } + const steering = yield* Effect.forkScoped(steer); + if ( + backgrounding === "never answers" || + backgrounding === "answers after the steer went out" + ) { + yield* awaitUntil(() => calls.length === 1, "background request"); + assert.equal(harness.offeredMessages.length, offered); + yield* TestClock.adjust("5 seconds"); + } + const steered = yield* Fiber.await(steering); + + // Only the root's running subagent moves: the nested ones follow + // their owner and the finished one has nothing left to move. + assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); + if (backgrounding === "answers after the turn ended") { + // The steer is refused, so the orchestrator starts its follow-up turn. + assert.isTrue(Exit.isFailure(steered)); + assert.equal(harness.offeredMessages.length, offered); + return; + } + // A refused or unanswered move still lets the steer through. + assert.isTrue(Exit.isSuccess(steered)); + assert.equal(harness.offeredMessages.length, offered + 1); + assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); + if (backgrounding === "never answers") { + // Later steers wait on the move still in flight instead of + // piling up another request for the same run. + for (let repeat = 1; repeat <= 2; repeat++) { + const again = yield* Effect.forkScoped(steer); + // The steer's 5 second wait starts once it reaches it; move the + // clock until the steer has gone out. + for (let tick = 0; tick < 100 && again.pollUnsafe() === undefined; tick++) { + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + } + assert.isTrue(Exit.isSuccess(yield* Fiber.await(again))); + assert.equal(harness.offeredMessages.length, offered + 1 + repeat); + } + assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); + } + if (backgrounding === "answers after the steer went out") { + yield* TestClock.adjust("1 second"); + } else if ( + backgrounding !== "succeeds" && + backgrounding !== "answers after the subagent ran again" + ) { + return; + } + // The move answered for the first run; the second runs in the + // foreground, so its end is the parent's tool result, not a wake. + const reran = backgrounding === "answers after the subagent ran again"; + + // The moved subagent outlives the steered turn. When it ends during + // the user's next turn, Claude's wake after that turn names it. + for (const frame of [ + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a06", + result: "", + terminalReason: "aborted_tools", + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a07", result: "Docs too." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "steered turn terminal"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-steer-next-turn"), + text: "Anything else?", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: "task-steer-foreground", + toolUseId: reran ? RERUN_TOOL_USE_ID : FOREGROUND_TOOL_USE_ID, + summary: "AUDITED", + uuid: "00000000-0000-4000-8000-000000000a08", + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a09", result: "No." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 2, "next turn terminal"); + yield* Queue.offer(harness.sdkMessages, wakeTurnInit); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "subagent wake"); + assert.equal( + harness.continuationRequests[0]?.notification?.summary, + reran ? undefined : 'Subagent "task-steer-foreground" finished', + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + describe("native goals", () => { const syntheticFrame = (uuid: string, text: string) => claudeSdkFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index c8fa9e25b944..9f75ffb90ee1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -243,6 +243,7 @@ interface ClaudeSubagentFoundFrame { type ClaudeOutboundFrame = | { readonly type: "query.stop_task"; readonly taskId: string } + | { readonly type: "query.background_tasks"; readonly toolUseId: string } | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame @@ -843,6 +844,10 @@ function makeReplayQueryRunner( replayEffect(() => { assertNextOutboundFrame({ type: "query.stop_task", taskId }); }), + backgroundTasks: (toolUseId) => + replayEffect(() => { + assertNextOutboundFrame({ type: "query.background_tasks", toolUseId }); + }).pipe(Effect.as(true)), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c84aff8913c8..92c3b44ec583 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -96,6 +96,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -425,6 +426,10 @@ export interface ClaudeAgentSdkQuerySession { mode: PermissionMode, ) => Effect.Effect; readonly stopTask: (taskId: string) => Effect.Effect; + /** Moves the foreground task one tool call started to the background; false when none matched. */ + readonly backgroundTasks: ( + toolUseId: string, + ) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -588,6 +593,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly taskId: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.background_tasks"; + readonly toolUseId: string; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -921,6 +934,19 @@ export const layerQueryRunner: Layer.Layer< }), ), ), + backgroundTasks: (toolUseId) => + Effect.tryPromise({ + try: () => queryRuntime.backgroundTasks(toolUseId), + catch: (cause) => queryRunnerError(cause, "backgroundTasks"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { type: "query.background_tasks", toolUseId }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -3604,6 +3630,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // the turn that launched the subagent (wake drain, later turns) resolve // their subagent through this index into sessionSubagentsByTaskId. const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map()); + // Requests that move a running subagent to the background before a + // steer, by the tool call that started its run. Claude may never + // answer one, so a later steer waits on it rather than asking again. + const backgroundMovesByToolUseId = new Map>(); // A subagent's tool calls can outlive the turn that started them, so // they live here rather than on a turn context: a turn that ends while // one runs neither fails it nor hides it from the turn that follows. @@ -9007,6 +9037,82 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); + // A steer is sent with priority "now", which cancels the root turn's + // running tool calls. A subagent the root launched in the foreground is + // one of them, so it moves to the background first and keeps running; + // its result arrives as a task notification like any background agent. + // Subagents a subagent launched stay put: they follow their owner. + const backgroundForegroundSubagents = Effect.fnUntraced(function* ( + live: ClaudeLiveQueryContext, + ) { + const foreground: Array<{ + readonly taskId: string; + readonly toolUseId: string; + }> = []; + for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { + const toolUseId = subagent.runToolUseId; + if ( + toolUseId === null || + subagent.task.status !== "running" || + subagent.task.parentNodeId !== subagent.rootNodeId || + live.subagentsFromEarlierProcesses.has(subagent) || + // A nested launch whose owner was not known yet falls back to + // the root as its parent node; its spawn depth still marks it. + (yield* isNestedSubagentTask(taskId)) + ) { + continue; + } + foreground.push({ taskId, toolUseId }); + } + // Backgrounding is refused when the session disables background + // tasks, and the SDK puts no deadline on the request. Either way the + // steer still goes through, as it did before. A move answered after + // the steer stopped waiting for it still marks the subagent, and a + // later steer waits on that same move instead of asking again. + for (const [toolUseId, move] of backgroundMovesByToolUseId) { + if (move.pollUnsafe() !== undefined) backgroundMovesByToolUseId.delete(toolUseId); + } + const moves: Array> = []; + for (const { taskId, toolUseId } of foreground) { + const inFlight = backgroundMovesByToolUseId.get(toolUseId); + if (inFlight !== undefined) { + moves.push(inFlight); + continue; + } + const move = yield* live.query.backgroundTasks(toolUseId).pipe( + // Now a background subagent: its end wakes the root like one + // Claude started in the background. One that already ended + // while the move was answered has nothing left to report. + Effect.flatMap((moved) => + moved + ? Ref.get(sessionSubagentsByTaskId).pipe( + Effect.flatMap((registry) => + registry.get(taskId)?.task.status === "running" && + // Not a later run of it that started meanwhile. + registry.get(taskId)?.runToolUseId === toolUseId + ? Ref.update(backgroundedSubagentTaskIds, (current) => + new Set(current).add(taskId), + ) + : Effect.void, + ), + ) + : Effect.void, + ), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { + taskId, + toolUseId, + cause, + }), + ), + Effect.forkIn(sessionScope), + ); + backgroundMovesByToolUseId.set(toolUseId, move); + moves.push(move); + } + yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption("5 seconds")); + }); + const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2SteerInput) { const existing = yield* Ref.get(queryContext); @@ -9034,6 +9140,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fileSystem, skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd), }); + yield* backgroundForegroundSubagents(existing); + // The turn can end while the moves are answered. A steer offered + // then would start a new turn the orchestrator never asked for, so + // the steer fails and the orchestrator starts the follow-up turn. + if ( + (yield* Ref.get(activeTurn))?.providerTurnId !== turnInput.providerTurnId || + (yield* Ref.get(queryContext)) !== existing + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`, + }); + } yield* Ref.update(steeredTurns, (current) => { const next = new Set(current); next.add(turnInput.providerTurnId); diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 3fd034abe4f2..308c02fef03b 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -161,6 +161,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"), close: Effect.void, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c7b322f2c492..a0e1c1fc7889 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4079,10 +4079,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - // A selection the provider applies on its next turn restarts the run when - // the provider can restart it. Otherwise the steer joins the running turn - // and the selection waits for the next one, rather than failing the steer. - const turnCapabilities = session.providerSession.capabilities.turns; + // A selection the provider applies on its next turn joins the running + // turn as a steer and waits for the next turn. Restarting the turn for it + // would end the turn's native subagents, which a steer must never do. const selectionMustApplyNow = selectionChanged && (providerInstanceChanged || selectionTransition?.type !== "apply_on_next_turn"); @@ -4092,12 +4091,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: input.command.threadId, providerInstanceId: targetRun.providerInstanceId, capabilities: session.providerSession.capabilities, - forceRestart: - input.forceRestart || - selectionMustApplyNow || - (selectionChanged && - turnCapabilities.supportsInterrupt && - turnCapabilities.supportsSteeringByInterruptRestart), + forceRestart: input.forceRestart || selectionMustApplyNow, }), ); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 730b72feea6b..f99328d1fc66 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -405,8 +405,9 @@ it.effect.each( ), ); -// Claude and Pi steer live but cannot interrupt-and-restart, so a changed -// selection waits for the next turn as the thread's saved selection. +// A changed selection the provider applies on its next turn waits there as the +// thread's saved selection. That holds for a provider that can restart the +// turn (Codex) too: restarting it would end the turn's native subagents. const runSelection = { instanceId, model: "test-model", @@ -417,7 +418,10 @@ const composerSelection = { options: [...runSelection.options, { id: "fastMode", value: false }], }; -const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* (name: string) { +const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* ( + name: string, + canRestart = false, +) { const cwd = yield* checkpointWorkspace(name); const events = yield* Queue.unbounded(); const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; @@ -427,7 +431,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* turns: { ...CodexProviderCapabilitiesV2.turns, supportsActiveSteering: true, - supportsSteeringByInterruptRestart: false, + supportsSteeringByInterruptRestart: canRestart, }, }; const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { @@ -560,79 +564,82 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* return { events, started, steered, layer, startFirstTurn }; }); -it.effect("steers a changed turn-scoped selection into a provider that cannot restart", () => - Effect.scoped( - Effect.gen(function* () { - const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( - "steering-selection-change", - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = yield* startFirstTurn; - const steer = (id: string, selection: ModelSelection) => - orchestrator - .dispatch({ - type: "message.dispatch", - commandId: CommandId.make(id), - threadId, - messageId: MessageId.make(`message:${id}`), - text: id, - attachments: [], - modelSelection: selection, - dispatchMode: { type: "steer_active", targetRunId: started[0]!.runId }, - createdBy: "user", - creationSource: "web", - }) - .pipe(Effect.andThen(worker.drain())); +it.effect.each([{ canRestart: false }, { canRestart: true }])( + "steers a changed turn-scoped selection into the running turn (canRestart=$canRestart)", + ({ canRestart }) => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + `steering-selection-change-${canRestart}`, + canRestart, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const steer = (id: string, selection: ModelSelection) => + orchestrator + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(`message:${id}`), + text: id, + attachments: [], + modelSelection: selection, + dispatchMode: { type: "steer_active", targetRunId: started[0]!.runId }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.andThen(worker.drain())); - yield* steer("steer-changed", composerSelection); - const changed = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(steered, ["steer-changed"]); - assert.equal(started.length, 1); - assert.lengthOf(changed.attempts, 1); - assert.equal(changed.runs[0]?.status, "running"); - assert.deepEqual(changed.runs[0]?.modelSelection, runSelection); - assert.deepEqual(changed.thread.modelSelection, composerSelection); + yield* steer("steer-changed", composerSelection); + const changed = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed"]); + assert.equal(started.length, 1); + assert.lengthOf(changed.attempts, 1); + assert.equal(changed.runs[0]?.status, "running"); + assert.deepEqual(changed.runs[0]?.modelSelection, runSelection); + assert.deepEqual(changed.thread.modelSelection, composerSelection); - // Choosing the running run's selection again replaces the saved choice. - yield* steer("steer-reverted", runSelection); - const reverted = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(steered, ["steer-changed", "steer-reverted"]); - assert.lengthOf(reverted.attempts, 1); - assert.deepEqual(reverted.thread.modelSelection, runSelection); + // Choosing the running run's selection again replaces the saved choice. + yield* steer("steer-reverted", runSelection); + const reverted = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed", "steer-reverted"]); + assert.lengthOf(reverted.attempts, 1); + assert.deepEqual(reverted.thread.modelSelection, runSelection); - // The saved choice moves to another instance while the run keeps going. - // Steering with the run's selection brings the thread's instance back too. - const otherSelection = { - instanceId: ProviderInstanceId.make("codex-work"), - model: "other", - }; - const sink = yield* EventSink.EventSinkV2; - yield* sink.write({ - events: [ - { - id: EventId.make("switched-away"), - type: "thread.provider-switched", - threadId, - providerInstanceId: otherSelection.instanceId, - occurredAt: yield* DateTime.now, - payload: { - ...reverted.thread, + // The saved choice moves to another instance while the run keeps going. + // Steering with the run's selection brings the thread's instance back too. + const otherSelection = { + instanceId: ProviderInstanceId.make("codex-work"), + model: "other", + }; + const sink = yield* EventSink.EventSinkV2; + yield* sink.write({ + events: [ + { + id: EventId.make("switched-away"), + type: "thread.provider-switched", + threadId, providerInstanceId: otherSelection.instanceId, - modelSelection: otherSelection, + occurredAt: yield* DateTime.now, + payload: { + ...reverted.thread, + providerInstanceId: otherSelection.instanceId, + modelSelection: otherSelection, + }, }, - }, - ], - }); - yield* steer("steer-back", runSelection); - const back = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(steered, ["steer-changed", "steer-reverted", "steer-back"]); - assert.equal(back.thread.providerInstanceId, instanceId); - assert.deepEqual(back.thread.modelSelection, runSelection); - }).pipe(Effect.provide(layer)); - }), - ), + ], + }); + yield* steer("steer-back", runSelection); + const back = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed", "steer-reverted", "steer-back"]); + assert.equal(back.thread.providerInstanceId, instanceId); + assert.deepEqual(back.thread.modelSelection, runSelection); + }).pipe(Effect.provide(layer)); + }), + ), ); it.effect("starts a steer that missed the turn on the saved next-turn selection", () => From e6bbfdb9ffe8334bed1c9b992f828f04d30db7ea Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:39:50 +0530 Subject: [PATCH 38/41] fix(web,mobile): sent queued messages say "from queue", not "queued" (#19849) Co-authored-by: PR Batch Tester Co-authored-by: Rodrigo Brechard Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadFeed.tsx | 10 ++++++---- .../features/threads/userMessageIntentBadge.test.ts | 13 +++++++++---- .../src/features/threads/userMessageIntentBadge.ts | 11 +++++++++-- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 5ab042dba125..d47466ecc382 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1313,10 +1313,12 @@ function renderFeedEntry( {intentBadge ? ( - Alert.alert("Message history", intentBadge.explanation)} className={cn( "rounded-full border px-1.5 py-0.5", intentBadge.tone === "queued" @@ -1334,7 +1336,7 @@ function renderFeedEntry( > {intentBadge.label} - + ) : null} {entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel} diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts index f29c4bba1643..c38ab3f66ca9 100644 --- a/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts +++ b/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts @@ -8,18 +8,21 @@ describe("user message intent badge", () => { expect(resolveUserMessageIntentBadge("turn_start")).toBeNull(); }); - it("labels messages waiting behind the active turn", () => { + it("explains the queued history without claiming delivery", () => { expect(resolveUserMessageIntentBadge("queued_turn")).toEqual({ - label: "queued", - accessibilityLabel: "Queued behind the active turn", + label: "from queue", + accessibilityLabel: "Queued while the agent was busy", + explanation: + "This badge records that the message was queued. It stays visible after the message leaves the queue.", tone: "queued", }); }); - it("labels messages that steer the active turn", () => { + it("explains the intent to steer without claiming delivery", () => { expect(resolveUserMessageIntentBadge("steer")).toEqual({ label: "steer", accessibilityLabel: "Steered the active turn", + explanation: "This message was intended to steer the agent while its turn was still running.", tone: "steer", }); }); @@ -28,6 +31,8 @@ describe("user message intent badge", () => { expect(resolveUserMessageIntentBadge("promoted_queued_to_steer")).toEqual({ label: "queued → steer", accessibilityLabel: "Originally queued, then promoted to steer the active turn", + explanation: + "This message was originally queued, then promoted to steer the agent while its turn was still running.", tone: "steer", }); }); diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.ts index c72c1c342d83..c234fb1734d8 100644 --- a/apps/mobile/src/features/threads/userMessageIntentBadge.ts +++ b/apps/mobile/src/features/threads/userMessageIntentBadge.ts @@ -3,6 +3,7 @@ import type { OrchestrationV2UserMessageInputIntent } from "@t3tools/contracts"; export interface UserMessageIntentBadgePresentation { readonly label: string; readonly accessibilityLabel: string; + readonly explanation: string; readonly tone: "queued" | "steer"; } @@ -12,20 +13,26 @@ export function resolveUserMessageIntentBadge( switch (intent) { case "queued_turn": return { - label: "queued", - accessibilityLabel: "Queued behind the active turn", + label: "from queue", + accessibilityLabel: "Queued while the agent was busy", + explanation: + "This badge records that the message was queued. It stays visible after the message leaves the queue.", tone: "queued", }; case "steer": return { label: "steer", accessibilityLabel: "Steered the active turn", + explanation: + "This message was intended to steer the agent while its turn was still running.", tone: "steer", }; case "promoted_queued_to_steer": return { label: "queued → steer", accessibilityLabel: "Originally queued, then promoted to steer the active turn", + explanation: + "This message was originally queued, then promoted to steer the agent while its turn was still running.", tone: "steer", }; case "turn_start": diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d490b980e5f0..3ac4952e89c8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2654,7 +2654,7 @@ function UserMessageIntentMarker({ const presentation = intent === "queued_turn" ? { - label: "Queued", + label: "From queue", icon: null, } : intent === "promoted_queued_to_steer" @@ -2682,7 +2682,7 @@ function UserMessageIntentMarker({ {intent === "queued_turn" - ? "Queued behind the active turn" + ? "Queued while the agent was busy" : intent === "promoted_queued_to_steer" ? "Originally queued, then promoted to steer the active turn" : "Steered the active turn"} From 42da166d131017335a1158d0b379dd68b8985796 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 06:08:49 -0700 Subject: [PATCH 39/41] fix(server): OTLP exports say which machine and build they came from (#19370) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/app/DesktopObservability.ts | 5 +++++ apps/server/src/config.test.ts | 18 ++++++++++++++++++ apps/server/src/config.ts | 12 ++++++++++-- apps/server/src/observability/Observability.ts | 3 ++- apps/server/src/serverLogger.ts | 4 +++- 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/config.test.ts diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 6a44bce6c567..89ed63002059 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -4,6 +4,7 @@ import { makeTraceSink, type SignalExport, } from "@t3tools/shared/observability"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { @@ -625,12 +626,16 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // `host.name` keeps machines apart on a shared collector; one set in + // `OTEL_RESOURCE_ATTRIBUTES` wins, since explicit attributes override it. const resource = { serviceName: "t3code-desktop", + serviceVersion: environment.appVersion, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", "service.mode": environment.isDevelopment ? "development" : "packaged", + "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), }, }; diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts new file mode 100644 index 000000000000..d21a96c9f415 --- /dev/null +++ b/apps/server/src/config.test.ts @@ -0,0 +1,18 @@ +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { assert, describe, it } from "@effect/vitest"; + +import * as ServerConfig from "./config.ts"; + +describe("otlpResource", () => { + const resource = (resourceAttributes: Record) => + ServerConfig.otlpResource( + { mode: "web", otelEnvironment: { ...OtelEnvironment.none, resourceAttributes } }, + "box-1", + ); + + it("names the host, unless OTEL_RESOURCE_ATTRIBUTES already does", () => { + assert.equal(resource({}).attributes["host.name"], "box-1"); + assert.equal(resource({ "host.name": "from-env" }).attributes["host.name"], "from-env"); + assert.equal(resource({ "host.name": "" }).attributes["host.name"], "box-1"); + }); +}); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ad2d182a3fad..d832d9dd5751 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import packageJson from "../package.json" with { type: "json" }; export const DEFAULT_PORT = 3773; @@ -118,14 +119,21 @@ export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config) /** * Resource attributes shared by every OTLP exporter, so traces, metrics, and - * logs report the same service identity to the collector. + * logs report the same service identity to the collector. `host.name` keeps + * servers apart when several export to one collector; a `host.name` set in + * `OTEL_RESOURCE_ATTRIBUTES` wins, since explicit attributes override it. */ -export const otlpResource = (config: ServerConfig["Service"]) => ({ +export const otlpResource = ( + config: Pick, + hostName: string, +) => ({ serviceName: "t3code-server", + serviceVersion: packageJson.version, attributes: { "service.namespace": "t3code", "service.runtime": "t3-server", "service.mode": config.mode, + "host.name": config.otelEnvironment.resourceAttributes["host.name"] || hostName, }, }); diff --git a/apps/server/src/observability/Observability.ts b/apps/server/src/observability/Observability.ts index 9ae9875749b2..80ef1f84b70c 100644 --- a/apps/server/src/observability/Observability.ts +++ b/apps/server/src/observability/Observability.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as HttpObservability from "@t3tools/shared/httpObservability"; import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import * as SharedObservability from "@t3tools/shared/observability"; @@ -25,7 +26,7 @@ export const layer = Layer.unwrap( // The trace serializer stays in the returned context because the browser // trace forwarder exports on the same signal. const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol); - const resource = ServerConfig.otlpResource(config); + const resource = ServerConfig.otlpResource(config, yield* HostProcess.Hostname); const attribution = yield* ResourceAttribution.ResourceAttribution; const layerTraceReferences = Layer.mergeAll( diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 4dee5b7d1b63..35909c2d1e03 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,6 +11,7 @@ import * as ServerConfig from "./config.ts"; export const layer = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const hostName = yield* HostProcess.Hostname; const layerMinimumLogLevel = Layer.succeed(References.MinimumLogLevel, config.logLevel); const logs = config.otlpLogsExport; @@ -20,7 +22,7 @@ export const layer = Effect.gen(function* () { url: config.otlpLogsUrl, exportInterval: `${logs.exportIntervalMs} millis`, headers: logs.headers, - resource: ServerConfig.otlpResource(config), + resource: ServerConfig.otlpResource(config, hostName), }); // `Logger.layer` writes the whole logger set rather than adding to it, so From 6d8f04145d0707a3c03df39756e7e5162536f2ac Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 15 Oct 2026 11:07:23 -0400 Subject: [PATCH 40/41] test(web): storage settings fixture includes the fork's cloud machine cleanup field Upstream's new StorageSettings test builds a full storageCleanup object, and the fork's schema also requires cloudMachinesAfterDays. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/settings/StorageSettings.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/settings/StorageSettings.test.tsx b/apps/web/src/components/settings/StorageSettings.test.tsx index 13b027ad31cb..c422c5390b48 100644 --- a/apps/web/src/components/settings/StorageSettings.test.tsx +++ b/apps/web/src/components/settings/StorageSettings.test.tsx @@ -85,6 +85,7 @@ beforeEach(() => { worktreeUnchanged: false, browserArtifactsAfterDays: null, logsAfterDays: null, + cloudMachinesAfterDays: null, }, projectSettingsOverrides: {}, }; From c6b5d9ad049f5280e94f597ae9db706a9d50fc1a Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 15 Oct 2026 11:15:56 -0400 Subject: [PATCH 41/41] test(server): fork Claude session fakes implement backgroundTasks Upstream added backgroundTasks to ClaudeAgentSdkQuerySession. The fork's own fakes in the account switch, background-work-after-limit and wake tests stub it the way upstream's fakes do. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 4 ++++ .../orchestration-v2/ClaudeBackgroundWorkAfterLimit.test.ts | 1 + apps/server/src/provider/ProviderAccountSwitch.test.ts | 1 + 3 files changed, 6 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 25b1147408fc..a6fd7998c196 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6664,6 +6664,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -6803,6 +6804,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -7170,6 +7172,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Deferred.succeed(interruptStarted, undefined), }); @@ -7232,6 +7235,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Deferred.succeed(interruptStarted, undefined), }); diff --git a/apps/server/src/orchestration-v2/ClaudeBackgroundWorkAfterLimit.test.ts b/apps/server/src/orchestration-v2/ClaudeBackgroundWorkAfterLimit.test.ts index ea9ebfed6c1d..38c9fad31787 100644 --- a/apps/server/src/orchestration-v2/ClaudeBackgroundWorkAfterLimit.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeBackgroundWorkAfterLimit.test.ts @@ -104,6 +104,7 @@ const runtime = (cli: Ref.Ref, cwd: string) => setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.asVoid(close), diff --git a/apps/server/src/provider/ProviderAccountSwitch.test.ts b/apps/server/src/provider/ProviderAccountSwitch.test.ts index 928894d26118..2571af74dc01 100644 --- a/apps/server/src/provider/ProviderAccountSwitch.test.ts +++ b/apps/server/src/provider/ProviderAccountSwitch.test.ts @@ -138,6 +138,7 @@ const claudeDriver = ( setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void,