From 89ee692bf0436505d008c1d70215e70836eba4e2 Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Sat, 8 Aug 2026 14:02:51 -0700 Subject: [PATCH 01/23] fix(web): persist diff view mode (#5731) --- apps/web/src/components/DiffPanel.tsx | 4 ++-- apps/web/src/diffPanelStore.test.ts | 28 ++++++++++++++++++++++++++- apps/web/src/diffPanelStore.ts | 7 +++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 76191e6d4d7..a62f5edd4df 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -76,7 +76,6 @@ import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -312,7 +311,8 @@ export default function DiffPanel({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode); + const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts index 607b7c8d580..7e7c95921f0 100644 --- a/apps/web/src/diffPanelStore.test.ts +++ b/apps/web/src/diffPanelStore.test.ts @@ -7,7 +7,33 @@ import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelSt const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); describe("diffPanelStore", () => { - beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} })); + beforeEach(() => + useDiffPanelStore.setState({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + diffRenderMode: "stacked", + }), + ); + + it("keeps the selected render mode in panel and persisted state", async () => { + useDiffPanelStore.getState().setDiffRenderMode("split"); + + expect(useDiffPanelStore.getState().diffRenderMode).toBe("split"); + expect( + useDiffPanelStore.persist.getOptions().partialize?.(useDiffPanelStore.getState()), + ).toMatchObject({ diffRenderMode: "split" }); + + const { name, storage } = useDiffPanelStore.persist.getOptions(); + if (!name) throw new Error("Expected diff panel persistence to have a storage name"); + const persisted = await storage?.getItem(name); + expect(persisted?.state).toMatchObject({ diffRenderMode: "split" }); + + useDiffPanelStore.setState({ diffRenderMode: "stacked" }); + if (persisted) await storage?.setItem(name, persisted); + await useDiffPanelStore.persist.rehydrate(); + + expect(useDiffPanelStore.getState().diffRenderMode).toBe("split"); + }); it("defaults each thread to branch changes when the working tree is clean", () => { expect( diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 56b5ad23fec..ebb560a2383 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -10,12 +10,16 @@ export type DiffPanelSelection = | { kind: "unstaged" } | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; +export type DiffRenderMode = "stacked" | "split"; + const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; interface DiffPanelStoreState { byThreadKey: Record; branchBaseRefByThreadKey: Record; + diffRenderMode: DiffRenderMode; + setDiffRenderMode: (mode: DiffRenderMode) => void; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; @@ -33,6 +37,8 @@ export const useDiffPanelStore = create()( (set) => ({ byThreadKey: {}, branchBaseRefByThreadKey: {}, + diffRenderMode: "stacked", + setDiffRenderMode: (diffRenderMode) => set({ diffRenderMode }), selectGitScope: (ref, scope) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -126,6 +132,7 @@ export const useDiffPanelStore = create()( partialize: (state) => ({ byThreadKey: state.byThreadKey, branchBaseRefByThreadKey: state.branchBaseRefByThreadKey, + diffRenderMode: state.diffRenderMode, }), }, ), From c2f8cb7ca1576afd70294b98dfe1de9be17aacae Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:20:06 -0400 Subject: [PATCH 02/23] feat(web): show how many subagents are running at a glance (#5745) Co-authored-by: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 7 ++++++ apps/web/src/components/RightPanelTabs.tsx | 21 ++++++++++++++++- .../components/chat/PanelLayoutControls.tsx | 23 +++++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b510d457fd..26a5c41ded5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5913,6 +5913,11 @@ function ChatViewContent(props: ChatViewProps) { rightPanelAvailable={activeProject !== null} rightPanelOpen={rightPanelOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} + // Suppressed while the Agents surface is visible: the roster itself is + // on screen, so the toggle badge would be pointing at nothing. + liveAgentCount={ + rightPanelOpen && activeRightPanelSurface?.kind === "agents" ? 0 : agentPanelModel.liveCount + } onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} /> @@ -6419,6 +6424,7 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} @@ -6447,6 +6453,7 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b9345ab8c3c..0b1700e7349 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -48,6 +48,8 @@ interface RightPanelTabsProps { browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + /** Running + waiting subagents; badges the Agents card in the empty state. */ + liveAgentCount: number; children: ReactNode; } @@ -96,6 +98,7 @@ function RightPanelEmptyState(props: { browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + liveAgentCount: number; }) { const actions = [ { @@ -105,6 +108,7 @@ function RightPanelEmptyState(props: { available: props.browserAvailable, disabledReason: SURFACE_DISABLED_REASONS.browser, onClick: props.onAddBrowser, + badgeCount: 0, }, { label: "Terminal", @@ -113,6 +117,7 @@ function RightPanelEmptyState(props: { available: true, disabledReason: null, onClick: props.onAddTerminal, + badgeCount: 0, }, { label: "Files", @@ -121,6 +126,7 @@ function RightPanelEmptyState(props: { available: props.filesAvailable, disabledReason: SURFACE_DISABLED_REASONS.files, onClick: props.onAddFiles, + badgeCount: 0, }, { label: "Diff", @@ -129,6 +135,7 @@ function RightPanelEmptyState(props: { available: props.diffAvailable, disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, + badgeCount: 0, }, { label: "Agents", @@ -137,6 +144,7 @@ function RightPanelEmptyState(props: { available: true, disabledReason: null, onClick: props.onAddAgents, + badgeCount: props.liveAgentCount, }, ] as const; @@ -154,7 +162,17 @@ function RightPanelEmptyState(props: { const Icon = action.icon; const content = ( <> - + + + {action.badgeCount > 0 ? ( + + {action.badgeCount} + + ) : null} + {action.label} {action.description} @@ -498,6 +516,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} + liveAgentCount={props.liveAgentCount} /> ) : ( props.children diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index deb7b3ee9af..c61826f677d 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -11,6 +11,8 @@ interface PanelLayoutControlsProps { rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; + /** Running + waiting subagents in this thread; badges the right panel toggle. */ + liveAgentCount: number; onToggleTerminal: () => void; onToggleRightPanel: () => void; } @@ -22,6 +24,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, + liveAgentCount, onToggleTerminal, onToggleRightPanel, }: PanelLayoutControlsProps) { @@ -59,18 +62,34 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ className="shrink-0 [-webkit-app-region:no-drag]" pressed={rightPanelOpen} onPressedChange={onToggleRightPanel} - aria-label="Toggle right panel" + aria-label={ + liveAgentCount > 0 + ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` + : "Toggle right panel" + } variant="ghost" size="sm" disabled={!rightPanelAvailable} > + {liveAgentCount > 0 ? ( + + {liveAgentCount} + + ) : null} } /> {rightPanelAvailable - ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}` + ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}${ + liveAgentCount > 0 + ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` + : "" + }` : "Right panel is unavailable"} From be01b287b92b4686023a5e213078a2f51b3c1880 Mon Sep 17 00:00:00 2001 From: naMqe Date: Sun, 9 Aug 2026 02:02:57 +0200 Subject: [PATCH 03/23] fix(web): add missing cursor-pointer styling to dropdowns and interactive buttons (#5716) --- apps/web/src/components/RightPanelTabs.tsx | 10 +++++----- .../components/settings/DiagnosticsSettings.tsx | 14 +++++++------- .../settings/ResourceTelemetryDiagnostics.tsx | 8 ++++---- apps/web/src/components/ui/combobox.tsx | 2 +- apps/web/src/components/ui/menu.tsx | 6 +++--- apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/components/usage/UsagePage.tsx | 8 ++++---- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 0b1700e7349..5fa0d6c1c36 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -185,7 +185,7 @@ function RightPanelEmptyState(props: { key={action.label} type="button" onClick={action.onClick} - className="flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:border-border hover:bg-accent/60 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5" + className="cursor-pointer flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:border-border hover:bg-accent/60 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5" > {content} @@ -413,7 +413,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAuxClick={(event) => handleTabAuxClick(event, surface)} onContextMenu={(event) => void handleTabContextMenu(event, surface)} className={cn( - "group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", @@ -421,7 +421,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { > @@ -173,7 +173,7 @@ export function UsagePage() { type="button" onClick={() => setMetric(option)} className={cn( - "px-2.5 py-1 text-[10px] tracking-wide uppercase", + "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", option === metric ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground", @@ -233,7 +233,7 @@ export function UsagePage() { type="button" onClick={() => setBreakdown(option)} className={cn( - "px-2.5 py-1 text-[10px] tracking-wide uppercase", + "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", option === breakdown ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground", From e70cdb478d34342d13ba4f433992394bf7303c1d Mon Sep 17 00:00:00 2001 From: Gabe Fletcher Date: Sat, 8 Aug 2026 20:24:42 -0400 Subject: [PATCH 04/23] fix(server): stop Claude resume handshakes from completing turns that never ran (#5710) Co-authored-by: t3-turbo-simulation Co-authored-by: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 167 +++++++++++++----- .../Layers/ProviderRuntimeIngestion.ts | 10 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 69 ++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 34 ++-- 4 files changed, 214 insertions(+), 66 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b4468bd4c6d..258aa010e3e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -16,6 +16,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, + type OrchestrationCommand, ProjectId, ProviderItemId, type ServerSettings, @@ -256,57 +257,52 @@ describe("ProviderRuntimeIngestion", () => { scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); + const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-provider-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), + await dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-provider-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }); + await dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + await dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed"), + threadId: ThreadId.make("thread-1"), + session: { threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + status: "ready", + providerName: "codex", runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-seed"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: createdAt, - lastError: null, - }, - createdAt, - }), - ); + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }); provider.setSession({ provider: ProviderDriverKind.make("codex"), status: "ready", @@ -318,6 +314,7 @@ describe("ProviderRuntimeIngestion", () => { return { engine, + dispatch, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, @@ -843,6 +840,82 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("rejects an untargeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A turn start is pending: the session reads "starting" with no active + // turn tracked yet. This is the window the Claude resume handshake's + // phantom (turn.completed with no turnId) used to slip through, stomping + // "starting" back to "ready" for a turn that never existed. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-untargeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-untargeted"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + status: "completed", + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + + it("accepts a targeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A completion that names its turn still lands even when no active turn + // is tracked (e.g. its turn.started was lost). Only untargeted + // completions are rejected. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-targeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-targeted-late"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late"), + status: "completed", + }); + + await waitForThread(harness.readModel, (thread) => thread.session?.status === "ready"); + }); + it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 40307cd9f25..03253797242 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1532,8 +1532,14 @@ const make = Effect.gen(function* () { if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // If no active turn is tracked, accept completion scoped to this thread. - return true; + // No active turn tracked: accept only completions that name their + // turn (covers a real completion whose turn.started was lost). An + // untargeted completion cannot prove it belongs to any turn this + // thread ran — the known emitter was the Claude resume handshake + // (system/init + result(num_turns: 0)), which is not a turn at + // all — and applying it here stomps the "starting" lifecycle + // state while a turn start is pending. + return eventTurnId !== undefined; default: return true; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d3d768b5384..711b0f6f6aa 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -978,6 +978,75 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not emit turn.completed for a result with no active turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Collect through session.exited so the window after the second result + // is deterministically inside the collection: both results are queued + // after sendTurn returns and drain in order on the one stream consumer. + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 1, + session_id: "sdk-session-1", + uuid: "result-real", + } as unknown as SDKMessage); + + // Second result with no turn in flight — the shape the resume + // handshake (system/init + result(num_turns: 0)) delivers, and the + // same completeTurn branch every no-turnState result lands in. This + // used to emit an untargeted turn.completed; it must emit nothing. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 0, + usage: { input_tokens: 0, output_tokens: 0 }, + session_id: "sdk-session-1", + uuid: "result-handshake", + } as unknown as SDKMessage); + + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const completions = runtimeEvents.filter((event) => event.type === "turn.completed"); + // Exactly one completion — the real turn's, targeted at its turn id. + // The buggy branch produced a second, untargeted one here. + assert.equal(completions.length, 1); + const completed = completions[0]; + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 92445522cc4..00839c455b4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2248,24 +2248,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: result ?? { status }, }); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, + // A result with no local turn is never a turn this adapter started: + // real turns get turnState in sendTurn, and assistant messages that + // arrive outside a turn auto-start a synthetic one. What lands here is + // the resume handshake (system/init + result(num_turns: 0)), a late + // result for a turn already completed locally (steer auto-close, + // stream teardown), or a stream failure with no turn in flight. The + // untargeted turn.completed this branch used to emit carried no turnId, + // so ingestion could not attribute it — and whenever the projection had + // no active turn (a pending turn start included) it flipped the session + // lifecycle for a turn that never existed. Keep the usage emission, + // drop the lifecycle event, and leave a tripwire so the upstream + // trigger stays measurable in the field. + yield* Effect.logInfo("claude.turn.result-without-active-turn", { threadId: context.session.threadId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } - : {}), - ...(errorMessage ? { errorMessage } : {}), - }, - providerRefs: {}, + status, + numTurns: result?.num_turns, + hasUsage: result?.usage !== undefined, + ...(errorMessage ? { errorMessage } : {}), }); return; } From 49964e38c02ca26783449e56a3083b124a1d04c8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:32:10 -0400 Subject: [PATCH 05/23] chore: vouch gfsaaser24 (#5761) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index c74a0dc48ff..28752c66444 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -15,6 +15,7 @@ github:chrisdeeming github:chuks-qua github:cursoragent github:gbarros-dev +github:gfsaaser24 github:github-actions[bot] github:hwanseoc github:jamesx0416 From 7b2cf4374f5d92cc01eff482ad1af92ab7a87f41 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:44:28 -0400 Subject: [PATCH 06/23] chore: vouch saphid (#5763) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 28752c66444..c3d617665fe 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -27,6 +27,7 @@ github:Noojuno github:notkainoa github:PatrickBauer github:realAhmedRoach +github:saphid github:shiroyasha9 github:StiensWout github:Yash-Singh1 From 89c320df0b0884a8c4df1cf596564c6bc725eb54 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:52:06 -0400 Subject: [PATCH 07/23] fix(server): stop Codex threads with queued follow-ups (#5762) --- .../CodexCollabRuntime.integration.test.ts | 49 +++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 13 +++-- .../testFixtures/codexCollabMockPeer.mjs | 35 ++++++++++--- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 3a02c45b23f..38e0e0a7b2c 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -245,4 +245,53 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + turnIds: [activeTurnId, queuedTurnId], + expectedActiveTurnId: activeTurnId, + notifications: [], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-queued-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + yield* runtime.sendTurn({ input: "queued follow-up" }); + yield* runtime.interruptTurn(); + + const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string }); + assert.deepEqual(interrupts.at(-1), { + threadId: ROOT, + turnId: activeTurnId, + }); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 57a1162dd08..58c012bd63e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -814,13 +814,13 @@ function currentProviderThreadId(session: ProviderSession): string | undefined { function updateSession( sessionRef: Ref.Ref, - updates: Partial, + updates: Partial | ((session: ProviderSession) => Partial), ): Effect.Effect { return Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* Ref.update(sessionRef, (session) => ({ ...session, - ...updates, + ...(typeof updates === "function" ? updates(session) : updates), updatedAt, })); }); @@ -1782,11 +1782,14 @@ export const makeCodexSessionRuntime = ( ), ); const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, { + yield* updateSession(sessionRef, (session) => ({ status: "running", - activeTurnId: turnId, + // Codex accepts follow-ups while the current turn is still + // running. The response contains the queued turn id, but + // turn/interrupt only accepts the id that is active now. + activeTurnId: session.activeTurnId ?? turnId, ...(normalizedModel ? { model: normalizedModel } : {}), - }); + })); const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); return { threadId: options.threadId, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 59580d2c7e6..f06e984c9aa 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -16,6 +16,7 @@ const fixture = JSON.parse( const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +let turnStartCount = 0; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -43,14 +44,20 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { - write({ id, result: fixture.responses.turnStart }); + const turnId = script.turnIds?.[turnStartCount]; + const turn = turnId + ? { ...fixture.responses.turnStart.turn, id: turnId } + : fixture.responses.turnStart.turn; + turnStartCount += 1; + write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; - const turn = fixture.responses.turnStart.turn; - write({ - jsonrpc: "2.0", - method: "turn/started", - params: { threadId: rootThreadId, turn }, - }); + if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { + write({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: rootThreadId, turn }, + }); + } for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } @@ -75,6 +82,20 @@ rl.on("line", (line) => { `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`, `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`, ); + if ( + script.expectedActiveTurnId && + message.params?.threadId === script.rootThreadId && + message.params?.turnId !== script.expectedActiveTurnId + ) { + write({ + id, + error: { + code: -32000, + message: `expected active turn id ${message.params?.turnId} but found ${script.expectedActiveTurnId}`, + }, + }); + return; + } if (script.failInterruptFor && script.failInterruptFor === target) { write({ id, error: { code: -32000, message: "thread already closed" } }); return; From 70c423a5e48ab34d8b1a033e798ab378b48dde5d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:31:20 -0400 Subject: [PATCH 08/23] fix(web): usage page loses the cost quality panel, gains a back button (#5756) Co-authored-by: Claude Fable 5 --- apps/web/src/components/usage/UsagePage.tsx | 269 ++++++++++---------- 1 file changed, 128 insertions(+), 141 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 417e490d37c..91e659ad5dd 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,5 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { RefreshCwIcon } from "lucide-react"; +import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; +import { ArrowLeftIcon, RefreshCwIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "../../lib/utils"; @@ -27,6 +28,9 @@ export function UsagePage() { const [windowDays, setWindowDays] = useState(30); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "day">("model"); + const canGoBack = useCanGoBack(); + const navigate = useNavigate(); + const router = useRouter(); // Recomputed only when the window length changes, so a re-render does not // shift the range and refetch every environment. @@ -57,11 +61,27 @@ export function UsagePage() {
-
-

Usage

-

- {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} -

+
+ +
+

Usage

+

+ {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} +

+
@@ -222,140 +242,116 @@ export function UsagePage() { /> -
-
-
-

Breakdown

-
- {(["model", "day"] as const).map((option) => ( - - ))} -
+
+
+

Breakdown

+
+ {(["model", "day"] as const).map((option) => ( + + ))}
+
- {breakdown === "model" ? ( - - - - - - - + {breakdown === "model" ? ( +
ModelCostShareTokens
+ + + + + + + + + + {merged.models.length === 0 ? ( + + - - - {merged.models.length === 0 ? ( - - + + + + - ) : ( - merged.models.map((model) => ( - - - - - - - )) - )} - -
ModelCostShareTokens
+ No activity in this window. +
- No activity in this window. + ) : ( + merged.models.map((model) => ( +
+ + + {model.model} + + + {formatUsd(model.costUsd)} + + {formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)}
- - - {model.model} - - - {formatUsd(model.costUsd)} - - {formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
- ) : ( - - - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - + )) + )} + +
Day - {PROVIDER_LABEL[provider]} - TotalTokens
+ ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + - - - {recentDays.length === 0 ? ( - - + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + - ) : ( - recentDays.map((day) => ( - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - - - )) - )} - -
Day + {PROVIDER_LABEL[provider]} + TotalTokens
+ No activity in this window. +
- No activity in this window. + ) : ( + recentDays.map((day) => ( +
{formatDayShort(day.day)} + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + + {formatUsd(day.costUsd)} + + {formatTokens(day.totalTokens)}
{formatDayShort(day.day)} - {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} - - {formatUsd(day.costUsd)} - - {formatTokens(day.totalTokens)} -
- )} -
- -
-

Cost quality

-
- - - - -
-
+ )) + )} + + + )}
)} @@ -394,15 +390,6 @@ function Metric({ ); } -function QualityRow({ label, value }: { readonly label: string; readonly value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - /** * Says plainly when the totals are incomplete: an environment still answering, * one that failed, or one whose transcripts another environment already From a6c9b41f902fba2a4137806c09e829935e91baac Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:43:53 -0400 Subject: [PATCH 09/23] feat(server): agents can now open the images you paste into chat (#5757) Co-authored-by: Claude Fable 5 --- .../providerService.integration.test.ts | 2 + .../src/provider/Layers/ClaudeAdapter.ts | 12 +++- .../provider/Layers/ProviderService.test.ts | 65 +++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 41 ++++++++++-- 4 files changed, 113 insertions(+), 7 deletions(-) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index c57d289f992..6089d22d9aa 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -23,6 +23,7 @@ import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; +import * as ServerConfig from "../src/config.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; @@ -93,6 +94,7 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer 0 ? { extraArgs } : {}), ...(mcpSession ? { @@ -4148,7 +4156,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.additional_directories": additionalDirectories, "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..7334cd01972 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -55,11 +55,15 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); +const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( + Layer.provide(NodeServices.layer), +); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -292,6 +296,7 @@ function makeProviderServiceLayer() { Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -343,6 +348,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -402,6 +408,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -486,6 +493,7 @@ it.effect( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -556,6 +564,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -611,6 +620,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -671,6 +681,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -737,6 +748,7 @@ it.effect( ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -796,6 +808,7 @@ it.effect( ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -927,6 +940,54 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("appends attachment file paths to the turn input text", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-attach"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-attach"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 123, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "use this screenshot", + attachments: [attachment], + }); + + const turnInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(typeof turnInput.input, "string"); + const turnText = turnInput.input ?? ""; + assert.equal(turnText.startsWith("use this screenshot"), true); + assert.include(turnText, '[Attached image "screenshot.png" is saved at: '); + assert.equal(turnText.endsWith(`${attachment.id}.png]`), true); + + // An attachment-only turn stays valid and the injected line becomes the + // whole input text, so the agent still learns the path. + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + attachments: [attachment], + }); + const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + + yield* provider.stopSession({ threadId: session.threadId }); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1307,6 +1368,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1345,6 +1407,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1413,6 +1476,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1446,6 +1510,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index d0acc1039c3..2ac00873df9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -35,6 +35,8 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Stream from "effect/Stream"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import * as ServerConfig from "../../config.ts"; import { increment, providerMetricAttributes, @@ -203,6 +205,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const serverConfig = yield* ServerConfig.ServerConfig; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical @@ -665,16 +668,44 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); - const input = { - ...parsed, - attachments: parsed.attachments ?? [], - }; - if (!input.input && input.attachments.length === 0) { + const attachments = parsed.attachments ?? []; + if (!parsed.input && attachments.length === 0) { return yield* toValidationError( "ProviderService.sendTurn", "Either input text or at least one attachment is required", ); } + + // Adapters inline attachment pixels into the model prompt, but the model's + // tools cannot dereference pixels. Appending the on-disk path is what lets + // a turn like "include this screenshot in the PR" copy the actual file. + // This runs after schema decode, so the appended lines are exempt from the + // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so + // the overhead is bounded. Unresolvable ids are skipped here and surface + // as adapter errors when the file is read for inlining. + const attachmentPathLines = attachments.flatMap((attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + return attachmentPath === null + ? [] + : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; + }); + const inputTextWithAttachmentPaths = + attachmentPathLines.length === 0 + ? parsed.input + : [parsed.input, attachmentPathLines.join("\n")] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); + + const input = { + ...parsed, + ...(inputTextWithAttachmentPaths !== undefined + ? { input: inputTextWithAttachmentPaths } + : {}), + attachments, + }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, From 5208bdeb0db95063091a29f97af3548436c5f291 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:46:27 -0400 Subject: [PATCH 10/23] fix(web): pinned reorder no longer reshuffles while writes land (#5767) Co-authored-by: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 70 +++++++++++++++++++---------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75c580402b1..f6cd9f7b175 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2269,22 +2269,28 @@ export default function Sidebar() { ); // Drag-to-reorder for the pinned block. A drop computes ONE fractional key // for the moved thread and sends it to that thread's own server (see - // planPinnedReorder for the keyless-neighbor materialization case). The - // optimistic order keeps the card where it was dropped until the - // confirming event round-trips; canonical order matching it releases the - // override, and a failed write clears it (the card snaps back) with a toast. - // ANY membership change (new pin, unpin, snooze/wake) also releases it: - // the override can't say where members it never saw belong, and holding it - // would misplace them and launder the stale order into later drags. + // planPinnedReorder for the keyless-neighbor materialization case, which + // instead rewrites every key in the section). The optimistic order keeps + // the card where it was dropped until EVERY key the drop wrote is + // reflected in canonical state — a section rewrite is several sequential + // writes, and releasing on the first landed key would expose the + // half-written canonical order, reshuffling the block once per write. + // A failed write clears the override (the card snaps back) with a toast. + // A key we did NOT write landing (a concurrent client's reorder that must + // win) and ANY membership change (new pin, unpin, snooze/wake) also + // release it: the override can't say where members it never saw belong, + // and holding it would launder a stale order into later drags. const pinnedDndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ readonly order: readonly string[]; - /** pinOrderKey per thread as of the drop, so ANY landed write (ours - confirming, or a concurrent one from another client) releases the - override rather than fighting canonical state. */ + /** pinOrderKey per thread as of the drop — the baseline that tells a + concurrent client's write apart from one of our own landing. */ readonly keysAtDrop: ReadonlyMap; + /** The keys this drop writes (one per planned assignment). The + override holds until all of them appear in canonical state. */ + readonly assignedKeys: ReadonlyMap; } | null>(null); const orderedPinnedThreads = useMemo(() => { if (optimisticPinnedOrder === null) return pinnedThreads; @@ -2303,24 +2309,32 @@ export default function Sidebar() { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); // The override represents one drop against one snapshot of the world. - // Release it as soon as the world moves on in any way: membership - // changed (pin/unpin/snooze/wake — the override can't say where members - // it never saw belong), a key changed (our write confirming, or a - // concurrent client's reorder that must win), or canonical already - // matches. Holding it longer would misplace newcomers and launder the - // stale order into later drags. + // Release it when the world moves on: membership changed (pin/unpin/ + // snooze/wake — the override can't say where members it never saw + // belong), a key changed to something we did NOT write (a concurrent + // client's reorder that must win), every key we wrote has landed, or + // canonical already matches. Releasing on the FIRST landed key instead + // of the last exposes the half-written order mid-materialization and + // the block visibly reshuffles once per write. const membershipChanged = canonicalKeys.length !== optimisticPinnedOrder.order.length || canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); - const anyKeyLanded = canonical.some( - (thread, index) => - optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== - (thread.pinOrderKey ?? null), + const foreignKeyLanded = canonical.some((thread, index) => { + const threadKey = canonicalKeys[index]!; + const currentKey = thread.pinOrderKey ?? null; + if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false; + return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey); + }); + const currentKeyByThreadKey = new Map( + canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]), + ); + const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( + ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, ); const orderConfirmed = !membershipChanged && canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || anyKeyLanded || orderConfirmed) { + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { setOptimisticPinnedOrder(null); } }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); @@ -2390,7 +2404,13 @@ export default function Sidebar() { movedId: activeKey, }); if (assignments.length === 0) return; - setOptimisticPinnedOrder({ order: newOrder, keysAtDrop }); + setOptimisticPinnedOrder({ + order: newOrder, + keysAtDrop, + assignedKeys: new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ), + }); void (async () => { // Sequential, stop on first failure. There is deliberately no // rollback: every key write is a complete, valid placement on its @@ -2404,8 +2424,12 @@ export default function Sidebar() { scopeThreadRef(thread.environmentId, thread.id), assignment.orderKey, ); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + if (result._tag === "Failure") { + // Any failure — interrupted included — releases the override: + // a key that never lands would otherwise hold it until some + // unrelated world change came along. setOptimisticPinnedOrder(null); + if (isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ From 288d8e3457f0466da2cbef2eab648331c969b8a7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:50:03 -0400 Subject: [PATCH 11/23] feat(web): overhaul project settings into a real settings page (#5768) Co-authored-by: Claude Fable 5 --- apps/web/src/components/CommandPalette.tsx | 11 + .../src/components/ProjectScriptsControl.tsx | 414 +------ apps/web/src/components/Sidebar.tsx | 376 +----- .../src/components/projectScriptEditor.tsx | 415 +++++++ .../settings/ProjectSettingsPanel.tsx | 1011 +++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 6 +- .../src/components/settings/settingsSearch.ts | 22 + apps/web/src/hooks/useT3ProjectFileScripts.ts | 44 +- apps/web/src/routeTree.gen.ts | 43 + apps/web/src/routes/settings.projects.tsx | 11 + .../routes/settings.projects_.$projectKey.tsx | 12 + 11 files changed, 1609 insertions(+), 756 deletions(-) create mode 100644 apps/web/src/components/projectScriptEditor.tsx create mode 100644 apps/web/src/components/settings/ProjectSettingsPanel.tsx create mode 100644 apps/web/src/routes/settings.projects.tsx create mode 100644 apps/web/src/routes/settings.projects_.$projectKey.tsx diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 605127f9737..b3e845f0007 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + icon: , + run: async () => { + await navigate({ to: "/settings/projects" }); + }, + }); + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 7f21177e7b1..304922909b0 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,61 +1,28 @@ import type { ProjectScript, - ProjectScriptIcon, ResolvedKeybindingsConfig, T3ProjectFileScript, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - BugIcon, - ChevronDownIcon, - DownloadIcon, - FlaskConicalIcon, - HammerIcon, - ListChecksIcon, - PlayIcon, - PlusIcon, - SettingsIcon, - WrenchIcon, -} from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; -import { - keybindingValueForCommand, - decodeProjectScriptKeybindingRule, -} from "~/lib/projectScriptKeybindings"; -import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; -import { - commandForProjectScript, - nextProjectScriptId, - primaryProjectScript, -} from "~/projectScripts"; +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + ScriptIcon, + type NewProjectScriptInput, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { Group, GroupSeparator } from "./ui/group"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; import { Menu, MenuGroup, @@ -66,48 +33,9 @@ import { MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Switch } from "./ui/switch"; -import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ - { id: "play", label: "Play" }, - { id: "test", label: "Test" }, - { id: "lint", label: "Lint" }, - { id: "configure", label: "Configure" }, - { id: "build", label: "Build" }, - { id: "debug", label: "Debug" }, -]; - -function ScriptIcon({ - icon, - className = "size-3.5", -}: { - icon: ProjectScriptIcon; - className?: string; -}) { - if (icon === "test") return ; - if (icon === "lint") return ; - if (icon === "configure") return ; - if (icon === "build") return ; - if (icon === "debug") return ; - return ; -} - -export interface NewProjectScriptInput { - name: string; - command: string; - icon: ProjectScriptIcon; - runOnWorktreeCreate: boolean; - keybinding: string | null; - /** Optional URL to open in the in-app preview when this script runs. */ - previewUrl: string | null; - /** When true, automatically open the preview panel pointed at `previewUrl`. */ - autoOpenPreview: boolean; -} - -export type ProjectScriptActionResult = AtomCommandResult; +export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { - const addScriptFormId = React.useId(); - const [editingScriptId, setEditingScriptId] = useState(null); const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, }); - const [dialogOpen, setDialogOpen] = useState(false); - const [name, setName] = useState(""); - const [command, setCommand] = useState(""); - const [icon, setIcon] = useState("play"); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); - const [keybinding, setKeybinding] = useState(""); - const [previewUrl, setPreviewUrl] = useState(""); - const [autoOpenPreview, setAutoOpenPreview] = useState(false); - const [validationError, setValidationError] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [editorRequest, setEditorRequest] = useState(null); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ ), [fileScripts, scripts], ); - const isEditing = editingScriptId !== null; const dropdownItemClassName = "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; - const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; - event.preventDefault(); - if (event.key === "Backspace" || event.key === "Delete") { - setKeybinding(""); - return; - } - const next = keybindingFromKeyboardEvent(event, navigator.platform); - if (!next) return; - setKeybinding(next); - }; - - const submitAddScript = async (event: FormEvent) => { - event.preventDefault(); - const trimmedName = name.trim(); - const trimmedCommand = command.trim(); - if (trimmedName.length === 0) { - setValidationError("Name is required."); - return; - } - if (trimmedCommand.length === 0) { - setValidationError("Command is required."); - return; - } - - setValidationError(null); - let payload: NewProjectScriptInput; - try { - const scriptIdForValidation = - editingScriptId ?? - nextProjectScriptId( - trimmedName, - scripts.map((script) => script.id), - ); - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: commandForProjectScript(scriptIdForValidation), - }); - const trimmedPreviewUrl = previewUrl.trim(); - payload = { - name: trimmedName, - command: trimmedCommand, - icon, - runOnWorktreeCreate, - keybinding: keybindingRule?.key ?? null, - previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, - autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, - } satisfies NewProjectScriptInput; - } catch (error) { - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - return; - } - - const result = editingScriptId - ? await onUpdateScript(editingScriptId, payload) - : await onAddScript(payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - } - return; - } - setDialogOpen(false); - setIconPickerOpen(false); - }; - const openAddDialog = () => { - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setIconPickerOpen(false); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); }; const openEditDialog = (script: ProjectScript) => { setActionsMenuOpen({ scripts: false, imports: false }); - setEditingScriptId(script.id); - setName(script.name); - setCommand(script.command); - setIcon(script.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(script.runOnWorktreeCreate); - setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); - setPreviewUrl(script.previewUrl ?? ""); - setAutoOpenPreview(script.autoOpenPreview ?? false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest(editorRequestForScript(script, keybindings)); }; - const confirmDeleteScript = useCallback(() => { - if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); - }, [editingScriptId, onDeleteScript]); + const submitScript = useCallback( + (scriptId: string | null, input: NewProjectScriptInput) => + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), + [onAddScript, onUpdateScript], + ); const importFileScript = async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ // Surface the failure through the regular add dialog, prefilled so the // user can adjust and retry. const error = squashAtomCommandFailure(result); - setEditingScriptId(null); - setName(payload.name); - setCommand(payload.command); - setIcon(payload.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(payload.runOnWorktreeCreate); - setKeybinding(""); - setPreviewUrl(payload.previewUrl ?? ""); - setAutoOpenPreview(payload.autoOpenPreview); - setValidationError(error instanceof Error ? error.message : "Failed to import action."); - setDialogOpen(true); + setEditorRequest({ + scriptId: null, + initial: payload, + error: error instanceof Error ? error.message : "Failed to import action.", + }); } }; @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ )} - { - setDialogOpen(open); - if (!open) { - setIconPickerOpen(false); - } - }} - onOpenChangeComplete={(open) => { - if (open) return; - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - }} - open={dialogOpen} - > - - - {isEditing ? "Edit Action" : "Add Action"} - - Actions are project-scoped commands you can run from the top bar or keybindings. - - - -
-
- -
- - - } - > - - - -
- {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
-
-
- setName(event.target.value)} - /> -
-
-
- - -

- Press a shortcut. Use Backspace to clear. -

-
-
- -