From c0bb2373450231e3931937d2f703e35ce906ed85 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 18:27:57 -0700 Subject: [PATCH 01/21] fix(web): always show environment chip for remote projects (#4217) Co-authored-by: Claude Fable 5 --- .../components/BranchToolbar.logic.test.ts | 39 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 11 ++++++ apps/web/src/components/BranchToolbar.tsx | 18 +++++++-- .../BranchToolbarEnvironmentSelector.tsx | 6 ++- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..8291e5e006e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,7 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => { }); }); +describe("shouldShowEnvironmentIndicator", () => { + it("shows the indicator whenever multiple environments are pickable", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: true, + }), + ).toBe(true); + }); + + it("shows a sole remote environment so the user knows where the project runs", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: false }, + canPickEnvironment: false, + }), + ).toBe(true); + }); + + it("hides a sole primary (this-device) environment", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: false, + }), + ).toBe(false); + }); + + it("hides the indicator when the active environment is unknown", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: null, + canPickEnvironment: false, + }), + ).toBe(false); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..b16e1f590a9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: { return runtimeLabel ?? savedLabel ?? input.environmentId; } +// A remote (non-primary) environment is always surfaced, even when it is the +// only environment available: with a single connected machine there is nothing +// to pick, but the user still needs to see where the project runs. +export function shouldShowEnvironmentIndicator(input: { + activeEnvironment: Pick | null; + canPickEnvironment: boolean; +}): boolean { + if (input.canPickEnvironment) return true; + return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c7..0354c2e0cd7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -20,6 +20,7 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; @@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; + showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ environmentId, availableEnvironments, showEnvironmentPicker, + showEnvironmentIndicator, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, @@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; - const icon = showEnvironmentPicker ? ( + const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. @@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ <> {icon} - {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} ); @@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); + const activeEnvironmentOption = + availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; + const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: showEnvironmentPicker, + }); const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId={environmentId} availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} + showEnvironmentIndicator={showEnvironmentIndicator} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} @@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({ /> ) : (
- {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + {showEnvironmentIndicator && availableEnvironments && ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 35fc8b6a190..dbc742bea5a 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps { envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; - onEnvironmentChange: (environmentId: EnvironmentId) => void; + // Absent when there is only one environment to show: the indicator still + // renders (as a static label) so remote projects are always identifiable. + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ @@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); - if (envLocked) { + if (envLocked || onEnvironmentChange === undefined) { return ( {activeEnvironment?.isPrimary ? ( From 23c18fda7a969634a30888e36b2da45f6d66a83b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 14:29:23 -0700 Subject: [PATCH 02/21] fix(web): keep composer editable while disconnected (#4241) Co-authored-by: Claude Fable 5 --- apps/web/src/components/chat/ChatComposer.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f3346b4100a..f35bff081e1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1159,6 +1159,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isConnecting || projectSelectionRequired || + environmentUnavailable !== null || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = @@ -1697,7 +1698,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || phase === "running") return false; + if (isSendBusy || isConnecting || environmentUnavailable !== null || phase === "running") { + return false; + } if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } @@ -1706,6 +1709,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, activePendingResolvedAnswers, composerSendState.hasSendableContent, + environmentUnavailable, isConnecting, isMobileViewport, isSendBusy, @@ -1892,8 +1896,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting || isComposerApprovalState || pendingUserInputs.length > 0 || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) + projectSelectionRequired ) { return false; } @@ -2101,8 +2104,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerApprovalState, pendingUserInputs.length, projectSelectionRequired, - environmentUnavailable, - activePendingProgress, applyPromptReplacement, isComposerModelPickerOpen, readComposerSnapshot, @@ -2144,7 +2145,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className={cn( "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45", isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", - environmentUnavailable || projectSelectionRequired ? "opacity-75" : null, + projectSelectionRequired ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, )} onFocusCapture={(event) => { @@ -2502,12 +2503,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" } - disabled={ - isConnecting || - isComposerApprovalState || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - } + disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> {showMobilePendingAnswerActions ? (
Date: Tue, 21 Jul 2026 17:13:44 -0700 Subject: [PATCH 03/21] =?UTF-8?q?fix:=20better=20defaults=20=E2=80=94=20Cl?= =?UTF-8?q?aude=201M=20context,=20Codex=20gpt-5.6,=20worktrees=20from=20or?= =?UTF-8?q?igin=20main=20(#4240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- .../threads/new-task-flow-provider.tsx | 39 +++++++++++----- apps/mobile/src/lib/modelOptions.ts | 3 ++ .../src/provider/Layers/ClaudeAdapter.test.ts | 11 ++--- .../src/provider/Layers/ClaudeAdapter.ts | 23 ++++------ .../src/provider/Layers/ClaudeProvider.ts | 26 ++++++++--- .../src/provider/Layers/CodexProvider.test.ts | 44 ++++++++++++++++++- .../src/provider/Layers/CodexProvider.ts | 32 +++++++++++++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 20 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 16 ++++++- .../BranchToolbarBranchSelector.tsx | 21 +++++++-- apps/web/src/modelSelection.ts | 4 ++ apps/web/src/providerModels.ts | 1 + .../src/operations/projects.test.ts | 3 +- packages/contracts/src/model.ts | 14 +++++- packages/contracts/src/server.ts | 1 + packages/contracts/src/settings.test.ts | 8 ++-- packages/contracts/src/settings.ts | 2 +- 17 files changed, 218 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee2bde9d971..74fe2f4852a 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -58,6 +58,8 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; +const EMPTY_BRANCH_REFS: ReadonlyArray = []; + function pendingTaskDraftKey(messageId: string): string { return `pending-task:${messageId}`; } @@ -348,7 +350,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; - const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; + // Keep the user's explicit choice separate from the resolved display value: + // only the explicit flag is ever written back to the draft, so the resolved + // value keeps tracking the server setting when the config loads late. + const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; + const startFromOrigin = + draftStartFromOrigin ?? + selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? + true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; @@ -368,6 +377,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedModel = selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? + modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; const selectedModelKey = selectedModel @@ -475,13 +485,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const branchState = useBranches(branchTarget); const branchesLoading = branchState.isPending; + const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; const availableBranches = useMemo( () => pipe( - branchState.data?.refs ?? [], + allBranchRefs, Arr.filter((branch) => !branch.isRemote), ), - [branchState.data?.refs], + [allBranchRefs], ); const filteredBranches = useMemo(() => { @@ -543,11 +554,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode, branch: selectedBranchName, worktreePath: selectedWorktreePath, - startFromOrigin, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin], + [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], ); const selectBranch = useCallback( @@ -560,11 +571,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode: workspaceMode, branch: branch.name, worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), - startFromOrigin, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode], + [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode], ); const setStartFromOrigin = useCallback( @@ -597,14 +608,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (workspaceMode !== "worktree" || selectedBranchName !== null) { return; } + // The default may only exist as origin/ (isRemote), which + // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = + allBranchRefs.find((branch) => branch.isDefault) ?? availableBranches.find((branch) => branch.current) ?? - availableBranches.find((branch) => branch.isDefault) ?? null; if (preferredBranch) { selectBranch(preferredBranch); } - }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { @@ -696,7 +709,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceMode: mode, branch: workspaceSelection?.branch ?? null, worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), - ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), + // The draft only carries the flag when the user touched it; fall + // back to the resolved default (server settings) so queued tasks + // drain with the same origin mode the composer displayed. + ...((workspaceSelection?.startFromOrigin ?? startFromOrigin) + ? { startFromOrigin: true } + : {}), }, createdAt: metadata.createdAt, }; @@ -707,6 +725,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, + startFromOrigin, ], ); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index e21682414d7..ab859c73b46 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -15,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly isDefault: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -78,6 +79,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + isDefault: model.isDefault === true, capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -107,6 +109,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + isDefault: false, capabilities: null, selection: fallbackModelSelection, }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 17aeff2d0e3..3e883fa1cf5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3045,7 +3045,7 @@ describe("ClaudeAdapterLive", () => { attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -3143,10 +3143,11 @@ describe("ClaudeAdapterLive", () => { yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "contextWindow", value: "200k" }], + ), attachments: [], }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f6e63eeffad..5ccd011ef09 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -77,6 +77,7 @@ import { isClaudeUltracodeEffort, normalizeClaudeCliEffort, resolveClaudeApiModelId, + resolveClaudeContextWindow, resolveClaudeEffort, } from "./ClaudeProvider.ts"; import { @@ -341,24 +342,18 @@ function selectedClaudeContextWindow( switch (modelSelection?.model) { case "claude-opus-4-8": case "claude-opus-4-7": + // Always 1M at the API; these models expose no contextWindow option. return 1_000_000; } - const optionValue = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); - if (optionValue === "1m") { - return 1_000_000; - } - if (optionValue === "200k") { - return 200_000; - } - const caps = getClaudeModelCapabilities(modelSelection?.model); - const hasContextWindowOption = getProviderOptionDescriptors({ caps }).some( - (descriptor) => descriptor.type === "select" && descriptor.id === "contextWindow", - ); - if (hasContextWindowOption) { - return 200_000; + switch (resolveClaudeContextWindow(modelSelection)) { + case "1m": + return 1_000_000; + case "200k": + return 200_000; + default: + return undefined; } - return undefined; } function finiteNonNegativeInteger(value: unknown): number | undefined { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ff0d1992454..9df672f54b0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -76,8 +76,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ id: "contextWindow", label: "Context Window", options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], }), ], @@ -162,8 +162,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ id: "contextWindow", label: "Context Window", options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], }), ], @@ -214,6 +214,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", + // Sonnet is 200k-default in Claude Code (1M is opt-in there too). options: [ { value: "200k", label: "200k", isDefault: true }, { value: "1m", label: "1M" }, @@ -243,6 +244,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", + // Sonnet is 200k-default in Claude Code (1M is opt-in there too). options: [ { value: "200k", label: "200k", isDefault: true }, { value: "1m", label: "1M" }, @@ -369,8 +371,22 @@ export function isClaudeUltracodeEffort(effort: string | null | undefined): bool return effort === "ultracode"; } +export function resolveClaudeContextWindow( + modelSelection: ModelSelection | undefined, +): string | undefined { + const caps = getClaudeModelCapabilities(modelSelection?.model); + const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { + switch (resolveClaudeContextWindow(modelSelection)) { case "1m": return `${modelSelection.model}[1m]`; default: diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..2aeebdb2ccd 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -102,3 +102,45 @@ it("uses standard routing when the catalog has no default service tier", () => { }, ]); }); + +it("marks the most preferred available model as default", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual( + models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "gpt-5.6-terra", isDefault: true }, + { slug: "gpt-5.4", isDefault: undefined }, + ], + ); +}); + +it("prefers sol over terra when both are available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); +}); + +it("keeps Codex's own default when no preferred model is available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); + +it("ignores custom models that shadow a preferred slug", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 9306087a0bc..1ed9c750c18 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -22,7 +22,7 @@ import type { ServerProviderModel, ServerProviderSkill, } from "@t3tools/contracts"; -import { ServerSettingsError } from "@t3tools/contracts"; +import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -189,10 +189,36 @@ function parseCodexModelListResponse( slug: model.model, name: toDisplayName(model), isCustom: false, + ...(model.isDefault ? { isDefault: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } +/** + * Prefer our own default-model ranking when one of the preferred slugs is in + * the live catalog; otherwise keep whatever Codex itself flagged as default. + */ +export function applyPreferredCodexDefaultModel( + models: ReadonlyArray, +): ReadonlyArray { + const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) => + models.some((model) => model.slug === slug && !model.isCustom), + ); + if (!preferredSlug) { + return models; + } + return models.map((model) => { + if (model.slug === preferredSlug) { + return model.isDefault ? model : { ...model, isDefault: true }; + } + if (!model.isDefault) { + return model; + } + const { isDefault: _isDefault, ...rest } = model; + return rest; + }); +} + function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, @@ -376,7 +402,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, version, - models: appendCustomCodexModels(models, input.customModels ?? []), + models: applyPreferredCodexDefaultModel( + appendCustomCodexModels(models, input.customModels ?? []), + ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8c627525b4c..9ffd3ed696d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -596,6 +596,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("marks the origin default ref as default when no local copy exists", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "set-head", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/only-local"]); + yield* git(cwd, ["branch", "-D", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + const remoteDefault = refs.refs.find((ref) => ref.name === `origin/${initialBranch}`); + assert.equal(remoteDefault?.isRemote, true); + assert.equal(remoteDefault?.isDefault, true); + }), + ); + it.effect("creates, checks out, renames, and lists refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6293ff7c29c..471ec10b566 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2217,7 +2217,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* name: refName.name, current: false, isRemote: true, - isDefault: false, + // origin/HEAD's target is the repo default even when no local + // copy of the default branch exists. + isDefault: + defaultBranch !== null && + parsedRemoteRef?.remoteName === "origin" && + parsedRemoteRef.branchName === defaultBranch, worktreePath: null, }; if (parsedRemoteRef) { @@ -2232,9 +2237,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }) : []; - const allBranches = input.includeMatchingRemoteRefs + const combinedBranches = input.includeMatchingRemoteRefs ? [...localBranches, ...remoteBranches] : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + // Keep current/default refs on the first page even when the default + // only exists as origin/ (remote refs sort after all locals). + const allBranches = combinedBranches.toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + return aPriority - bPriority; + }); const branchesForKind = input.refKind === "local" ? allBranches.filter((ref) => !ref.isRemote) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e2ee24c3608..67ae3a8187d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -422,17 +422,32 @@ export function BranchToolbarBranchSelector({ }); }; + // Default the worktree base to the repo default branch (origin/HEAD), only + // falling back to the checked-out branch when no default is known. + const defaultBranchName = useMemo( + () => refs.find((refName) => refName.isDefault)?.name ?? null, + [refs], + ); + const worktreeBaseBranchCandidate = isInitialBranchesLoadPending + ? null + : (defaultBranchName ?? currentGitBranch); useEffect(() => { if ( effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || - !currentGitBranch + !worktreeBaseBranchCandidate ) { return; } - setThreadBranch(currentGitBranch, null); - }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + setThreadBranch(worktreeBaseBranchCandidate, null); + }, [ + activeThreadBranch, + activeWorktreePath, + effectiveEnvMode, + setThreadBranch, + worktreeBaseBranchCandidate, + ]); // --------------------------------------------------------------------------- // Combobox / list plumbing diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index f1d527880ed..ec089d766cf 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -75,6 +75,7 @@ export interface AppModelOption { shortName?: string; subProvider?: string; isCustom: boolean; + isDefault?: boolean; } function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { @@ -85,6 +86,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti }; if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; + if (model.isDefault) option.isDefault = true; return option; } @@ -247,7 +249,9 @@ export function resolveAppModelSelectionForInstance( const options = getAppModelOptionsForInstance(settings, entry); return ( resolveSelectableModel(entry.driverKind, selectedModel, options) ?? + options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? + entry.models.find((model) => model.isDefault)?.slug ?? entry.models[0]?.slug ?? null ); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index aa9afcf31e1..9715344cba8 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -92,6 +92,7 @@ export function getDefaultServerModel( ): string { const models = getProviderModels(providers, provider); return ( + models.find((model) => model.isDefault && !model.isCustom)?.slug ?? models.find((model) => !model.isCustom)?.slug ?? models[0]?.slug ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index bf4e2c89392..f3bc72603ac 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + DEFAULT_MODEL, EnvironmentId, ProjectId, CommandId, @@ -139,7 +140,7 @@ describe("add project shared logic", () => { createWorkspaceRootIfMissing: true, defaultModelSelection: { instanceId: "codex", - model: "gpt-5.4", + model: DEFAULT_MODEL, }, }); }); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..8c74c13b89b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -133,8 +133,18 @@ const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); -export const DEFAULT_MODEL = "gpt-5.4"; -export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; +export const DEFAULT_MODEL = "gpt-5.6-sol"; + +/** + * Codex default-model preference, most preferred first. The provider snapshot + * marks the first of these present in the live `model/list` response as + * default; when none are available, Codex's own `isDefault` flag wins. + */ +export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ + "gpt-5.6-sol", + "gpt-5.6-terra", +]; +export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 316f09693ec..3d99b8e95a6 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -64,6 +64,7 @@ export const ServerProviderModel = Schema.Struct({ shortName: Schema.optional(TrimmedNonEmptyString), subProvider: Schema.optional(TrimmedNonEmptyString), isCustom: Schema.Boolean, + isDefault: Schema.optional(Schema.Boolean), capabilities: Schema.NullOr(ModelCapabilities), }); export type ServerProviderModel = typeof ServerProviderModel.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f618729c43..a79042a2847 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -88,14 +88,14 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); describe("ServerSettings worktree defaults", () => { - it("defaults start-from-origin off for legacy configs", () => { - expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); + it("defaults start-from-origin on for legacy configs", () => { + expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); }); it("accepts start-from-origin updates", () => { expect( - decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin, - ).toBe(true); + decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, + ).toBe(false); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fe593f49199..b983aa8a3fa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -382,7 +382,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), newWorktreesStartFromOrigin: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + Schema.withDecodingDefault(Effect.succeed(true)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( From 6f34ad3e87eba2ffba66cac5593dae8b680e5b84 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 19:58:39 -0700 Subject: [PATCH 04/21] fix(claude): handle all SDK stream messages; stop spurious work-log warning rows (#4244) Co-authored-by: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 127 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 86 +++++++++++- 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3e883fa1cf5..2735b69a187 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1711,6 +1711,133 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("consumes undeclared and UX-internal system subtypes without warning rows", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + // Undeclared wire-only roster snapshot + every typed UX-internal + // subtype and top-level type consumed silently: none may surface as + // unknown-subtype warnings. + for (const message of [ + { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "t1", task_type: "local_agent", description: "Say hi" }], + session_id: "session", + uuid: "roster", + }, + { + type: "system", + subtype: "task_updated", + task_id: "t1", + patch: { status: "running" }, + session_id: "session", + uuid: "tu", + }, + { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, + { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" }, + { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" }, + { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, + { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, + { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, + { + type: "system", + subtype: "notification", + key: "context", + text: "low priority note", + priority: "low", + session_id: "session", + uuid: "notif", + }, + ]) { + harness.query.emit(message as unknown as SDKMessage); + } + // High-priority notifications DO surface as a warning row. + harness.query.emit({ + type: "system", + subtype: "notification", + key: "limit", + text: "context window nearly full", + priority: "high", + session_id: "session", + uuid: "notif-high", + } as unknown as SDKMessage); + // session_state_changed maps to the matching session states. + for (const [state, uuid] of [ + ["running", "ssc-run"], + ["requires_action", "ssc-req"], + ["idle", "ssc-idle"], + ]) { + harness.query.emit({ + type: "system", + subtype: "session_state_changed", + state, + session_id: "session", + uuid, + } as unknown as SDKMessage); + } + // api_retry maps to a session heartbeat, not a warning row. + harness.query.emit({ + type: "system", + subtype: "api_retry", + attempt: 3, + max_retries: 10, + retry_delay_ms: 1000, + error_status: 502, + error: { type: "api_error" }, + session_id: "session", + uuid: "retry", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); + // Exactly one warning: the high-priority notification. Nothing else. + assert.deepEqual( + warnings.map((event) => event.payload.message), + ["context window nearly full"], + ); + const sessionStates = runtimeEvents + .filter((event) => event.type === "session.state.changed") + .map((event) => + event.type === "session.state.changed" + ? `${event.payload.state}:${event.payload.reason ?? ""}` + : "", + ) + .filter( + (entry) => entry.startsWith("running:session_state") || entry.includes("session_state"), + ); + assert.deepEqual(sessionStates, [ + "running:session_state:running", + "waiting:session_state:requires_action", + "ready:session_state:idle", + ]); + const heartbeat = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + typeof event.payload.reason === "string" && + event.payload.reason.startsWith("api_retry:"), + ); + assert.equal(heartbeat?.type, "session.state.changed"); + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("emits thread token usage updates from Claude task progress", () => { 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 5ccd011ef09..757d7a00eb2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2585,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }; + // Undeclared-but-real subtypes (absent from the SDK's union, so they can't + // be switch cases): consumed intentionally without emitting, otherwise + // they fall through to the unknown-subtype warning and surface as spurious + // error rows in client work logs. `background_tasks_changed` is a roster + // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the + // authoritative per-agent data and the typed background_tasks control + // request is the reconciliation source. + if ((message.subtype as string) === "background_tasks_changed") { + return; + } + switch (message.subtype) { case "init": yield* offerRuntimeEvent({ @@ -2697,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; + // Task state patch (status/backgrounded/end_time). No runtime mapping + // yet — the terminal task_notification reports the outcome — but it + // must not surface as an unknown-subtype warning row. + case "task_updated": + return; case "task_notification": yield* emitThreadTokenUsage( context, @@ -2741,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; case "thinking_tokens": return; + case "api_retry": + // Transport-level retry heartbeat. Surfacing each attempt as a + // warning row spammed the work log (10 rows during a 502 storm); + // the terminal result/error path reports the actual failure. Keep + // the session visibly alive instead. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: "running", + reason: `api_retry:${message.attempt}/${message.max_retries}`, + }, + }); + return; + case "session_state_changed": + // Authoritative turn-over signal from the CLI. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: + message.state === "running" + ? "running" + : message.state === "requires_action" + ? "waiting" + : "ready", + reason: `session_state:${message.state}`, + }, + }); + return; + case "notification": + // User-facing CLI notification (e.g. context-limit warnings). Only + // high-priority ones warrant a work-log row. + if (message.priority === "high" || message.priority === "immediate") { + yield* emitRuntimeWarning(context, message.text, message); + } + return; + // Inner protocol/UX details with no T3 surface today — consumed + // deliberately so they don't masquerade as unknown-subtype warnings. + case "model_refusal_fallback": + case "local_command_output": + case "plugin_install": + case "commands_changed": + case "memory_recall": + case "elicitation_complete": + return; case "permission_denied": yield* offerRuntimeEvent({ ...base, @@ -2760,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( message, ); return; - default: + default: { + // Exhaustiveness guard: every subtype in the SDK's typed union is + // handled above, so `message` narrows to never here — a new SDK + // release adding a subtype fails this typecheck instead of silently + // warning at runtime. The runtime fallback still catches undeclared + // wire-only subtypes (like background_tasks_changed used to be). + message satisfies never; + const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), + describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), message, ); return; + } } }); @@ -2874,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( case "rate_limit_event": yield* handleSdkTelemetryMessage(context, message); return; - default: + // Composer prompt suggestions have no T3 surface; consumed deliberately. + case "prompt_suggestion": + return; + default: { + // Exhaustiveness guard (see handleSystemMessage): new SDK top-level + // message types fail typecheck here instead of warning at runtime. + message satisfies never; + const unknownMessage = message as never as { type: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), + describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message), message, ); return; + } } }); From 32c6012dabdbd0eb178b25ea4225d889ec8f6475 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 22 Jul 2026 03:36:34 -0700 Subject: [PATCH 05/21] Sidebar v2 beta: flat thread list with a server-backed settled lifecycle (#4026) Co-authored-by: Claude Fable 5 Co-authored-by: maria-rcks --- .../settings/DesktopClientSettings.test.ts | 2 + .../archive/archivedThreadList.test.ts | 2 + .../src/features/home/HomeRouteScreen.tsx | 7 +- apps/mobile/src/features/home/HomeScreen.tsx | 386 +++- .../src/features/home/homeListItems.test.ts | 2 + .../src/features/home/homeThreadList.test.ts | 2 + .../features/home/thread-swipe-actions.tsx | 20 +- .../src/features/home/useThreadListActions.ts | 128 +- .../features/settings/SettingsRouteScreen.tsx | 33 + .../features/threads/thread-list-v2-items.tsx | 329 ++++ .../src/features/threads/threadListV2.test.ts | 220 +++ .../src/features/threads/threadListV2.ts | 167 ++ apps/mobile/src/lib/repositoryGroups.test.ts | 2 + apps/mobile/src/lib/threadActivity.test.ts | 2 + .../src/persistence/mobile-preferences.ts | 10 + apps/mobile/src/state/use-thread-selection.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/OrchestrationEngine.test.ts | 2 + .../Layers/ProjectionPipeline.test.ts | 64 + .../Layers/ProjectionPipeline.ts | 34 + .../Layers/ProjectionSnapshotQuery.test.ts | 113 ++ .../Layers/ProjectionSnapshotQuery.ts | 20 + apps/server/src/orchestration/Schemas.ts | 4 + .../orchestration/commandInvariants.test.ts | 4 + .../src/orchestration/decider.settled.test.ts | 521 +++++ apps/server/src/orchestration/decider.ts | 259 ++- .../orchestration/projector.settled.test.ts | 88 + .../src/orchestration/projector.test.ts | 2 + apps/server/src/orchestration/projector.ts | 28 + .../Layers/ProjectionRepositories.test.ts | 56 + .../persistence/Layers/ProjectionThreads.ts | 10 + apps/server/src/persistence/Migrations.ts | 2 + .../033_ProjectionThreadsSettled.ts | 23 + .../persistence/Services/ProjectionThreads.ts | 2 + .../Layers/ProviderSessionReaper.test.ts | 2 + .../src/relay/AgentAwarenessRelay.test.ts | 6 + apps/server/src/server.test.ts | 6 + apps/web/src/components/AppSidebarLayout.tsx | 14 +- .../web/src/components/ChatView.logic.test.ts | 2 + apps/web/src/components/ChatView.logic.ts | 2 + apps/web/src/components/ChatView.tsx | 1 + .../components/CommandPalette.logic.test.ts | 2 + apps/web/src/components/Sidebar.logic.test.ts | 98 + apps/web/src/components/Sidebar.logic.ts | 65 + apps/web/src/components/Sidebar.tsx | 122 +- apps/web/src/components/SidebarV2.tsx | 1707 +++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 6 +- apps/web/src/components/chat/ChatHeader.tsx | 23 + .../components/settings/BetaSettingsPanel.tsx | 108 ++ .../settings/SettingsSidebarNav.tsx | 3 + .../src/components/sidebar/SidebarChrome.tsx | 127 ++ apps/web/src/hooks/useThreadActions.ts | 109 +- apps/web/src/index.css | 73 +- apps/web/src/lib/chatThreadActions.test.ts | 21 + apps/web/src/lib/chatThreadActions.ts | 8 + apps/web/src/lib/threadSort.test.ts | 2 + apps/web/src/newThreadPickerBus.ts | 14 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.tsx | 14 + apps/web/src/routes/settings.beta.tsx | 11 + apps/web/src/state/entities.ts | 10 + apps/web/src/worktreeCleanup.test.ts | 2 + packages/client-runtime/package.json | 4 + .../src/operations/commands.test.ts | 39 +- .../client-runtime/src/operations/commands.ts | 22 + .../client-runtime/src/state/entities.test.ts | 2 + .../src/state/shellReducer.test.ts | 2 + .../src/state/threadCommands.ts | 18 + .../client-runtime/src/state/threadDetail.ts | 2 + .../src/state/threadReducer.test.ts | 58 + .../client-runtime/src/state/threadReducer.ts | 24 + .../src/state/threadSettled.test.ts | 345 ++++ .../client-runtime/src/state/threadSettled.ts | 147 ++ .../src/state/threads-sync.test.ts | 2 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/orchestration.test.ts | 115 ++ packages/contracts/src/orchestration.ts | 52 + packages/contracts/src/settings.test.ts | 21 + packages/contracts/src/settings.ts | 16 + 79 files changed, 5834 insertions(+), 165 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-list-v2-items.tsx create mode 100644 apps/mobile/src/features/threads/threadListV2.test.ts create mode 100644 apps/mobile/src/features/threads/threadListV2.ts create mode 100644 apps/server/src/orchestration/decider.settled.test.ts create mode 100644 apps/server/src/orchestration/projector.settled.test.ts create mode 100644 apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts create mode 100644 apps/web/src/components/SidebarV2.tsx create mode 100644 apps/web/src/components/settings/BetaSettingsPanel.tsx create mode 100644 apps/web/src/components/sidebar/SidebarChrome.tsx create mode 100644 apps/web/src/newThreadPickerBus.ts create mode 100644 apps/web/src/routes/settings.beta.tsx create mode 100644 packages/client-runtime/src/state/threadSettled.test.ts create mode 100644 packages/client-runtime/src/state/threadSettled.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..47800f3192d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], providerModelPreferences: {}, + sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", @@ -27,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarV2Enabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..697d13e7c47 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 49cf06d85ec..502d2bab1b5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -28,7 +28,8 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -111,6 +112,8 @@ export function HomeRouteScreen() { } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) @@ -120,6 +123,8 @@ export function HomeRouteScreen() { onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { + // Settled threads are live shells: opening one is plain + // navigation, and sending a message un-settles server-side. navigation.navigate("Thread", { environmentId: thread.environmentId, threadId: thread.id, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index dcc56fd77c5..d1339a9bb91 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -14,18 +14,22 @@ import type { } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; +import { cn } from "../../lib/cn"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -33,6 +37,8 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; +import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -74,6 +80,9 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + /** Resolves true iff the settle was dispatched and succeeded. */ + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -82,6 +91,10 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; +// v2 settled-tail paging: recent history is the common lookup; the deep +// tail stays behind an explicit Show more. +const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; +const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -156,6 +169,80 @@ function HomeTopContentSpacer() { return ; } +function ThreadListV2ProjectScope(props: { + readonly projects: ReadonlyArray; + readonly selectedKey: string | null; + readonly onChange: (key: string | null) => void; +}) { + if (props.projects.length === 0) return null; + + return ( + + {props.projects.length > 1 ? ( + props.onChange(null)} + className={cn( + "min-h-8 items-center justify-center rounded-lg border px-3", + props.selectedKey === null + ? "border-border bg-subtle-strong" + : "border-black/15 dark:border-white/15", + )} + > + All + + ) : null} + {props.projects.map((project) => { + const key = scopedProjectKey(project.environmentId, project.id); + const selected = props.selectedKey === key; + return ( + props.onChange(selected ? null : key)} + className={cn( + "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3", + selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15", + )} + > + + + {project.title} + + + ); + })} + + ); +} + /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { @@ -163,6 +250,9 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -267,6 +357,193 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of props.projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [props.projects]); + + const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState(null); + const v2ScopeProjects = useMemo( + () => + props.selectedEnvironmentId === null + ? props.projects + : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId), + [props.projects, props.selectedEnvironmentId], + ); + const v2ScopedProject = useMemo( + () => + v2ProjectScopeKey === null + ? null + : (v2ScopeProjects.find( + (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey, + ) ?? null), + [v2ProjectScopeKey, v2ScopeProjects], + ); + useEffect(() => { + if (v2ProjectScopeKey !== null && v2ScopedProject === null) { + setV2ProjectScopeKey(null); + } + }, [v2ProjectScopeKey, v2ScopedProject]); + + // Thread List v2 (beta): one flat list in creation order, no grouping. + // Settled threads collapse into a recency tail below the card block. + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells — no snapshot merging or + // optimistic holds. + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition (mirrors web). + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + const handleSettleThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onSettleThread(thread); + }, + [props.onSettleThread], + ); + const handleDeleteThread = props.onDeleteThread; + const handleUnsettleThread = props.onUnsettleThread; + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // now is quantized to the minute and ticks so the inactivity auto-settle + // boundary is actually crossed while the app stays open (mirrors web); + // without a clock dependency the partition memoizes a frozen "now". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + if (!threadListV2Enabled) return; + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + // Settled threads are live shells; archived threads keep their original + // "hidden from lists" meaning. + return buildThreadListV2Items({ + threads: props.threads.filter((thread) => thread.archivedAt === null), + environmentId: props.selectedEnvironmentId, + projectRef: + v2ScopedProject === null + ? null + : { + environmentId: v2ScopedProject.environmentId, + projectId: v2ScopedProject.id, + }, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + }); + }, [ + changeRequestStateByKey, + nowMinute, + settledVisibleCount, + settlementEnvironmentIds, + props.searchQuery, + props.selectedEnvironmentId, + props.threads, + threadListV2Enabled, + v2ScopedProject, + ]); + const threadListV2Items = threadListV2Layout.items; + + const renderV2Item = useCallback( + ({ item }: { readonly item: ThreadListV2Item }) => ( + + provider.instanceId === + (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), + )?.driver ?? null + } + onSelectThread={props.onSelectThread} + onDeleteThread={handleDeleteThread} + onArchiveThread={props.onArchiveThread} + settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} + onSettleThread={handleSettleThread} + onUnsettleThread={handleUnsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={ + projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? + null + } + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + ), + [ + handleChangeRequestState, + handleDeleteThread, + handleSettleThread, + handleSwipeableClose, + handleSwipeableWillOpen, + handleUnsettleThread, + projectByKey, + projectCwdByKey, + props.onArchiveThread, + props.onSelectThread, + serverConfigs, + settlementEnvironmentIds, + ], + ); + const v2KeyExtractor = useCallback( + (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, + [], + ); + const extraData = useMemo( () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), [props.savedConnectionsById, projectCwdByKey], @@ -360,6 +637,10 @@ export function HomeScreen(props: HomeScreenProps) { const keyExtractor = useCallback((item: HomeListItem) => item.key, []); /* Empty states */ + // The signal must ignore the search/environment filters: an active query + // that matches nothing needs the in-list "No results" state, not the + // full-page "No threads yet". Settled threads are unarchived live shells, + // so the v1 check already covers v2. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; const hasResults = projectGroups.length > 0; @@ -436,6 +717,44 @@ export function HomeScreen(props: HomeScreenProps) { ); + // v2 renders queued offline tasks above the thread cards — they are not + // thread shells, so the v2 item builder never sees them, but they must + // stay visible and deletable while their environment is offline. They + // respect the same environment scope and search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.message.environmentId === props.selectedEnvironmentId) && + (v2ScopedProject === null || + (pendingTask.message.environmentId === v2ScopedProject.environmentId && + pendingTask.creation.projectId === v2ScopedProject.id)) && + (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + const v2ListHeader = ( + <> + {listHeader} + + {v2PendingTasks.map((pendingTask, index) => ( + + ))} + + ); + const listEmpty = !hasResults ? ( hasSearchQuery ? ( @@ -448,6 +767,69 @@ export function HomeScreen(props: HomeScreenProps) { ) ) : null; + // Self-contained: v1's listEmpty keys off projectGroups, which ignores the + // v2 project scope, so it can be null (results elsewhere) while this list + // is empty. Search outranks the scope — "No results" names the actionable + // fact when a query is active. Pending tasks render in the header, so the + // list showing them isn't empty in the user's eyes. + const v2ListEmpty = + v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( + + ) : v2ScopedProject !== null ? ( + + ) : ( + listEmpty + ); + + if (threadListV2Enabled) { + return ( + + + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingBottom: + Platform.OS === "ios" + ? Math.max(insets.bottom, 24) + 96 + : Math.max(insets.bottom, 16) + 88, + }} + /> + + {connectionStatus} + + ); + } return ( diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 36e98ef129f..c5a9f2c6bbc 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8f13000b9e2..46d1173b0dd 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 84a9f32ee5e..666169f3039 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -167,6 +167,13 @@ export function ThreadSwipeable(props: { /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; + /** + * What a full swipe commits: "delete" (default, v1 behavior — the Delete + * button stretches) or "primary" — the advertised primary action fires and + * its button stretches instead. A full swipe must always match the action + * the stretching button advertises. + */ + readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; @@ -239,7 +246,11 @@ export function ThreadSwipeable(props: { if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); - props.onDelete(); + if (props.fullSwipeAction === "primary") { + props.primaryAction.onPress(); + } else { + props.onDelete(); + } } }} overshootFriction={1} @@ -247,6 +258,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( void; readonly onFullSwipeArmedChange: (armed: boolean) => void; @@ -430,6 +443,7 @@ export function ThreadSwipeActions(props: { readonly threadTitle: string; readonly translation: SharedValue; }) { + const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; useAnimatedReaction( () => -props.translation.value >= props.fullSwipeThreshold, (armed, previous) => { @@ -457,7 +471,7 @@ export function ThreadSwipeActions(props: { icon={props.primaryAction.icon} label={props.primaryAction.label} onPress={props.primaryAction.onPress} - stretchesOnFullSwipe={false} + stretchesOnFullSwipe={fullSwipeIsPrimary} translation={props.translation} /> diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8effdc942d9..e200eb7acde 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -6,19 +7,37 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -type ThreadListAction = "archive" | "unarchive" | "delete"; +/** Version skew: never send settle/unsettle to a server that predates them + (capability defaults false on decode for older servers). */ +function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + +type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; + +const ACTION_VERBS: Record = { + archive: "archived", + unarchive: "unarchived", + delete: "deleted", + settle: "settled", + unsettle: "un-settled", +}; function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { const error = Cause.squash(cause); if (error instanceof Error && error.message.trim().length > 0) { return error.message; } - const verb = - action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; - return `The thread could not be ${verb}.`; + return `The thread could not be ${ACTION_VERBS[action]}.`; } function selectionHaptic(): void { @@ -28,54 +47,115 @@ function selectionHaptic(): void { function actionFailureTitle(action: ThreadListAction): string { if (action === "archive") return "Could not archive thread"; if (action === "unarchive") return "Could not unarchive thread"; + if (action === "settle") return "Could not settle thread"; + if (action === "unsettle") return "Could not un-settle thread"; return "Could not delete thread"; } +/** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, ) { const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); + const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( async (action: ThreadListAction, thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { - return; + return false; } inFlightThreadKeys.current.add(key); selectionHaptic(); try { - const mutation = - action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation; - const result = await mutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + if ( + (action === "settle" || action === "unsettle") && + !environmentSupportsSettlement(thread.environmentId) + ) { + Alert.alert( + actionFailureTitle(action), + "This environment's server does not support settling yet. Update the server to use Settle.", + ); + return false; + } + // Settle may only target what effectiveSettled could classify as + // settled: not starting/running sessions, not threads waiting on + // approvals or user input. Anything else would hide live work. + if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { + Alert.alert( + actionFailureTitle(action), + "This thread still needs attention. Resolve or interrupt it first, then try again.", + ); + return false; + } + // Archive keeps its original, narrower guard: never interrupt a + // thread mid-turn. + if ( + action === "archive" && + thread.session?.status === "running" && + thread.session.activeTurnId != null + ) { + Alert.alert( + actionFailureTitle(action), + "This thread is working. Interrupt it first, then try again.", + ); + return false; + } + const result = + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return; + return false; + } + // Settled threads stay in the live shell stream; only the archive + // lifecycle still feeds the archived-snapshot surface. + if (action === "archive" || action === "unarchive" || action === "delete") { + refreshArchivedThreadsForEnvironment(thread.environmentId); } onCompleted?.(action, thread); + return true; } finally { inFlightThreadKeys.current.delete(key); } }, - [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], + [ + archiveMutation, + deleteMutation, + onCompleted, + settleMutation, + unarchiveMutation, + unsettleMutation, + ], ); return executeAction; } function useConfirmDeleteThread( - executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, ) { return useCallback( (thread: EnvironmentThreadShell) => { @@ -111,6 +191,8 @@ function useConfirmDeleteThread( export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly settleThread: (thread: EnvironmentThreadShell) => Promise; + readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -120,10 +202,18 @@ export function useThreadListActions(): { }, [executeAction], ); + const settleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, + [executeAction], + ); + const unsettleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, + [executeAction], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { archiveThread, confirmDeleteThread }; + return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; } export function useArchivedThreadListActions( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 6c67a4d89e8..9aea392555a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -123,6 +123,8 @@ function LocalSettingsRouteScreen() { + + @@ -506,6 +508,8 @@ function ConfiguredSettingsRouteScreen() { + + @@ -514,6 +518,35 @@ function ConfiguredSettingsRouteScreen() { ); } +/** + * Device-local beta toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → Beta backed by mobile preferences. + */ +function BetaSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.threadListV2Enabled === true + : false; + + return ( + + + savePreferences({ threadListV2Enabled: value })} + /> + + + One flat thread list in creation order. Active work renders as cards; settled threads + collapse to compact rows. Switch back any time. + + + ); +} + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx new file mode 100644 index 00000000000..9dd41f3cd60 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -0,0 +1,329 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { MenuAction } from "@react-native-menu/menu"; +import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; +import { Platform, Pressable, useWindowDimensions, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadPr } from "../../state/use-thread-pr"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; + +/** + * Thread List v2 rows mirror the web sidebar's compact tonal cards and + * receded settled tail while retaining native swipe and long-press actions. + */ + +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + +const STATUS_LABEL_BY_STATUS: Partial< + Record +> = { + approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, + input: { label: "Input", className: "text-amber-700 dark:text-amber-300" }, + working: { label: "Working", className: "text-blue-600 dark:text-blue-400" }, + failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, +}; + +function threadTimeLabel(thread: EnvironmentThreadShell): string { + return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); +} + +// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps +// its own surface (thread screen / settings) rather than crowding the row. +const CARD_MENU_ACTIONS: MenuAction[] = [ + { id: "settle", title: "Settle", image: "checkmark" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +const SLIM_MENU_ACTIONS: MenuAction[] = [ + { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +// Pre-settlement servers: no lifecycle items, archive fills the gap. +const LEGACY_MENU_ACTIONS: MenuAction[] = [ + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { + const borderColor = useThemeColor("--color-border"); + return ( + + Settled + + + ); +}); + +export const ThreadListV2Row = memo(function ThreadListV2Row(props: { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + readonly showSettledDivider: boolean; + readonly project: EnvironmentProject | null; + readonly providerDriver: string | null; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + /** False on environments whose server predates thread.settle/unsettle: + swipe + menu fall back to Archive instead of failing on use. */ + readonly settlementSupported: boolean; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** Reports this row's live PR state up so the partition can auto-settle + merged/closed work (mirrors web's onChangeRequestState). */ + readonly onChangeRequestState?: ( + threadKey: string, + state: "open" | "closed" | "merged" | null, + ) => void; + readonly projectCwd?: string | null; + readonly simultaneousSwipeGesture?: ComponentProps< + typeof ThreadSwipeable + >["simultaneousWithExternalGesture"]; +}) { + const { width: windowWidth } = useWindowDimensions(); + const { + thread, + variant, + onSelectThread, + onDeleteThread, + onSettleThread, + onUnsettleThread, + onArchiveThread, + onChangeRequestState, + } = props; + + const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); + const prState = pr?.state ?? null; + const threadKey = `${thread.environmentId}:${thread.id}`; + useEffect(() => { + onChangeRequestState?.(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const screenColor = useThemeColor("--color-screen"); + + const status = resolveThreadListV2Status(thread); + const statusLabel = STATUS_LABEL_BY_STATUS[status]; + const timeLabel = threadTimeLabel(thread); + + const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); + const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); + const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "settle") handleSettle(); + if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "delete") handleDelete(); + }, + [handleArchive, handleDelete, handleSettle, handleUnsettle], + ); + + // Swipe: the v2 primary action is the lifecycle transition. Every settled + // row can un-settle — explicit settles clear the override, auto-settled + // rows get pinned active until real activity clears the pin. + const canUnsettle = variant === "slim"; + const primaryAction = useMemo(() => { + // Pre-settlement server: archive is the swipe action, as in v1. (Slim + // rows cannot occur here — unsupported environments never classify as + // settled.) + if (!props.settlementSupported) { + return { + accessibilityLabel: `Archive ${thread.title}`, + icon: "archivebox" as const, + label: "Archive", + onPress: handleArchive, + }; + } + return canUnsettle + ? { + accessibilityLabel: `Un-settle ${thread.title}`, + icon: "arrow.uturn.backward" as const, + label: "Un-settle", + onPress: handleUnsettle, + } + : { + accessibilityLabel: `Settle ${thread.title}`, + icon: "checkmark" as const, + label: "Settle", + onPress: handleSettle, + }; + }, [ + canUnsettle, + handleArchive, + handleSettle, + handleUnsettle, + props.settlementSupported, + thread.title, + ]); + + const rowContent = (close: () => void) => + variant === "card" ? ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + {props.project ? ( + + ) : null} + + {props.project?.title ?? ""} + + + {statusLabel?.label ?? timeLabel} + + + + {thread.title} + + + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch ? ( + + {thread.branch} + + ) : ( + + )} + {props.providerDriver ? ( + + + + ) : null} + + + + + + ) : ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + {/* Settled history recedes: dimmed favicon + muted title. */} + + {props.project ? ( + + + + ) : null} + + {thread.title} + + + {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + + + + ); + + return ( + <> + {props.showSettledDivider ? : null} + + {(close) => ( + + {rowContent(close)} + + )} + + + ); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts new file mode 100644 index 00000000000..80edc86124b --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -0,0 +1,220 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadListV2Items, + resolveThreadListV2Status, + sortThreadsForListV2, +} from "./threadListV2"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeThread( + input: Partial & Pick, +): EnvironmentThreadShell { + return { + environmentId, + projectId: ProjectId.make("project-1"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +const NOW = "2026-06-02T00:00:00.000Z"; + +describe("resolveThreadListV2Status", () => { + it("prioritizes approval over a running session", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + hasPendingApprovals: true, + session: { + threadId: ThreadId.make("t"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + expect(resolveThreadListV2Status(thread)).toBe("approval"); + }); + + it("resolves ready for quiescent threads", () => { + expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( + "ready", + ); + }); +}); + +describe("sortThreadsForListV2", () => { + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForListV2([ + { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); +}); + +describe("buildThreadListV2Items", () => { + it("partitions settled threads into a slim tail with one divider", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("settled-2"), + title: "Settled 2", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["active", "card"], + ["settled", "slim"], + ["settled-2", "slim"], + ]); + expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); + expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + }); + + it("keeps cards in creation order while settled sorts by recency", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("older-created"), + title: "Older", + createdAt: "2026-06-01T08:00:00.000Z", + updatedAt: NOW, // recent activity must NOT promote it + }), + makeThread({ + id: ThreadId.make("newer-created"), + title: "Newer", + createdAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); + }); + + it("keeps settled threads in the tail and filters by search query", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), + makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Fix login again", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "login", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["match", "card"], + ["settled", "slim"], + ]); + }); + + it("scopes the flat list to one project", () => { + const otherProjectId = ProjectId.make("project-2"); + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("included"), title: "Included" }), + makeThread({ + id: ThreadId.make("excluded"), + projectId: otherProjectId, + title: "Excluded", + }), + ], + environmentId: null, + projectRef: { environmentId, projectId: ProjectId.make("project-1") }, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["included"]); + }); +}); + +describe("buildThreadListV2Items settled paging", () => { + it("caps the settled tail at settledLimit and reports the hidden count", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + ...Array.from({ length: 4 }, (_, index) => + makeThread({ + id: ThreadId.make(`settled-${index}`), + title: `Settled ${index}`, + settledOverride: "settled", + settledAt: NOW, + latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, + // A turn adopted the message (same requestedAt): without it the + // thread reads as a queued turn start, which never settles. + latestTurn: { + turnId: TurnId.make(`turn-${index}`), + state: "completed", + requestedAt: `2026-06-01T0${index}:00:00.000Z`, + startedAt: `2026-06-01T0${index}:00:00.000Z`, + completedAt: `2026-06-01T0${index}:10:00.000Z`, + assistantMessageId: null, + }, + }), + ), + ]; + + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + settledLimit: 2, + now: NOW, + }); + + expect(layout.hiddenSettledCount).toBe(2); + expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); + // Most recent settled first — the hidden ones are the oldest. + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "active", + "settled-3", + "settled-2", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts new file mode 100644 index 00000000000..2a1df18309c --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -0,0 +1,167 @@ +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +/** + * Thread List v2 model, ported from the web sidebar v2 + * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). + * + * Four visual states, three colors: color is reserved for "act now" + * (approval), "in motion" (working), and "broken" (failed). Ready is the + * unlabeled resting state. + */ +export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; + +export function resolveThreadListV2Status( + thread: Pick, +): ThreadListV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.hasPendingUserInput) { + return "input"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not + poison the whole ordering, so it sinks to the epoch instead. */ +function parseTimestampMs(isoDate: string): number { + const parsed = Date.parse(isoDate); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** First VALID timestamp wins: a present-yet-malformed string falls through + to the next candidate rather than sinking the row to the epoch. */ +function firstValidTimestampMs(...candidates: ReadonlyArray): number { + for (const candidate of candidates) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +/** + * v2 sort: static creation order, newest thread on top. Activity NEVER + * reorders the list — a row holds its position from open until settled, so + * the screen only moves at lifecycle transitions. Mirrors web's + * sortThreadsForSidebarV2. + */ +export function sortThreadsForListV2( + threads: readonly T[], +): T[] { + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 + // change-by-copy array methods. + return [...threads].sort( + (left, right) => + parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + left.id.localeCompare(right.id), + ); +} + +export interface ThreadListV2Item { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + /** First settled row after the card block draws the SETTLED divider. */ + readonly showSettledDivider: boolean; + readonly isLast: boolean; +} + +export interface ThreadListV2Layout { + readonly items: ThreadListV2Item[]; + /** Settled threads beyond the render limit (behind "Show more"). */ + readonly hiddenSettledCount: number; +} + +/** + * Partitions visible threads into the active card block (creation order) and + * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` + * mirrors the web default of 3 — mobile has no client-settings sync yet, so + * the default is fixed here rather than user-configurable. + */ +export function buildThreadListV2Items(input: { + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRef?: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + } | null; + readonly searchQuery: string; + /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ + readonly changeRequestStateByKey?: ReadonlyMap; + /** Environments whose server supports thread.settle/unsettle. Threads on + other environments never classify as settled — the user could neither + un-settle nor pin them. Absent = no gating (tests). */ + readonly settlementEnvironmentIds?: ReadonlySet; + readonly autoSettleAfterDays?: number; + /** Max settled rows to render; the rest are counted, not built. */ + readonly settledLimit?: number; + /** Injectable for tests; defaults to now. */ + readonly now?: string; +}): ThreadListV2Layout { + const now = input.now ?? new Date().toISOString(); + const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const query = input.searchQuery.trim().toLocaleLowerCase(); + + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of input.threads) { + // Callers pass live (unarchived) shells; settled threads are among them + // and partition into the tail via effectiveSettled. + if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; + if ( + input.projectRef != null && + (thread.environmentId !== input.projectRef.environmentId || + thread.projectId !== input.projectRef.projectId) + ) { + continue; + } + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + const changeRequestState = + input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + + const orderedActive = sortThreadsForListV2(active); + const orderedSettled = [...settled].sort( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ); + const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; + const visibleSettled = + orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; + + const items: ThreadListV2Item[] = []; + for (const thread of orderedActive) { + items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); + } + for (const [index, thread] of visibleSettled.entries()) { + items.push({ + thread, + variant: "slim", + showSettledDivider: index === 0, + isLast: false, + }); + } + const last = items.at(-1); + if (last) { + items[items.length - 1] = { ...last, isLast: true }; + } + return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 8cea5df2307..ab4311524ce 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,6 +38,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..5f9f099a46c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,6 +50,8 @@ function makeThread( checkpoints: [], session: null, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6971570b0e6..1138ad2b655 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -22,6 +22,12 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + /** + * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no + * client-settings sync, so the flat v2 thread list is opted into per + * device. + */ + readonly threadListV2Enabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -71,6 +77,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { codeWordBreak?: boolean; connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; + threadListV2Enabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -97,6 +104,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { (key): key is string => typeof key === "string", ); } + if (typeof parsed.threadListV2Enabled === "boolean") { + preferences.threadListV2Enabled = parsed.threadListV2Enabled; + } return preferences; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d369fbc4377..80bc1916b11 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,6 +58,8 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1c0d34ea5bc..01567b98d32 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -136,6 +136,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + threadSettlement: true, }, }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b00c00e0d3f..731002ea783 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..a9d7317999a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -171,6 +171,70 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { for (const row of stateRows) { assert.equal(row.lastAppliedSequence, 3); } + + // Settled lifecycle through the DB pipeline: thread.settled writes the + // override + timestamp, thread.unsettled(user) flips to the active pin. + yield* eventStore.append({ + type: "thread.settled", + eventId: EventId.make("evt-settle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("cmd-settle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-settle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const settledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(settledRows, [ + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + ]); + + yield* eventStore.append({ + type: "thread.unsettled", + eventId: EventId.make("evt-unsettle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: CommandId.make("cmd-unsettle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-unsettle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + reason: "user", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const unsettledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); }), ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3ceae0ea43b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.settled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsettled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9a136b06872..15ded458e22 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [ { @@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", @@ -562,6 +566,115 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-settled-test', + 'Settled Test', + '/tmp/settled-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + settled_override, + settled_at, + deleted_at + ) + VALUES ( + 'thread-settled', + 'project-settled-test', + 'Settled Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:05.000Z', + NULL, + 'settled', + '2026-04-06T00:00:04.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + `; + + // Settled ≠ archived: the thread must appear in the LIVE shell + // snapshot, carrying its settlement fields through the row aliases. + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-settled")], + ); + assert.equal(shellSnapshot.threads[0]?.settledOverride, "settled"); + assert.equal(shellSnapshot.threads[0]?.settledAt, "2026-04-06T00:00:04.000Z"); + + // And the full command read model carries them too. + const readModel = yield* snapshotQuery.getCommandReadModel(); + const thread = readModel.threads.find( + (candidate) => candidate.id === ThreadId.make("thread-settled"), + ); + assert.equal(thread?.settledOverride, "settled"); + assert.equal(thread?.settledAt, "2026-04-06T00:00:04.000Z"); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 32210436e67..155e9ab0013 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..0d0d7bdd5e4 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,11 +4,13 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, + ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, + ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; +export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; +export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..9531cd5c3af 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts new file mode 100644 index 00000000000..73f1cbf9127 --- /dev/null +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -0,0 +1,521 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel( + settledOverride: OrchestrationThread["settledOverride"], + archivedAt: string | null = null, + session: OrchestrationSession | null = null, + activities: OrchestrationThread["activities"] = [], + messages: OrchestrationThread["messages"] = [], +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + settledOverride, + settledAt: settledOverride === "settled" ? SETTLED_AT : null, + deletedAt: null, + messages, + proposedPlans: [], + activities, + checkpoints: [], + session, + }, + ], + updatedAt: NOW, + }; +} + +function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { + return { + threadId: ThreadId.make("thread-1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("settles active threads and re-emits idempotently for settled ones", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); + } + + // Already settled: the engine rejects zero-event commands, so idempotency + // is by re-emission — preserving the original settledAt. + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled"), + }); + const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(reEmitEvents).toHaveLength(1); + expect(reEmitEvents[0]?.type).toBe("thread.settled"); + if (reEmitEvents[0]?.type === "thread.settled") { + expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + // updatedAt must NOT rewind to the historical settledAt: sorting and + // relative-time labels key on it. + expect(reEmitEvents[0].payload.updatedAt).not.toBe(SETTLED_AT); + } + }), + ); + + it.effect("rejects settling a thread with a live session", () => + Effect.gen(function* () { + for (const status of ["starting", "running"] as const) { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make(`cmd-settle-live-${status}`), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession(status)), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + } + // Stopped/error sessions are settleable — only live work is protected. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stopped"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession("stopped")), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling a thread with an open approval or user-input request", () => + Effect.gen(function* () { + const requestActivity = (kind: string, requestId: string, at: string) => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId }, + turnId: null, + createdAt: at, + }) as OrchestrationThread["activities"][number]; + + // Open approval request: settle rejected. + const openError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + ]), + }).pipe(Effect.flip); + expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + + // Same request later resolved: settleable again. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-resolved"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + requestActivity("approval.resolved", "req-1", NOW), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // Open user-input request: also rejected. + const inputError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending-input"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("user-input.requested", "req-2", NOW), + ]), + }).pipe(Effect.flip); + expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("clears an open request when its respond failure marks it stale", () => + Effect.gen(function* () { + const activity = ( + kind: string, + requestId: string, + payload: Record, + ): OrchestrationThread["activities"][number] => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId, ...payload }, + turnId: null, + createdAt: NOW, + }) as OrchestrationThread["activities"][number]; + + // Stale-failure detail clears the request — mirrors the projection's + // pending accounting, which is what the client's canSettle sees. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stale-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-1", {}), + activity("provider.approval.respond.failed", "req-1", { + detail: "Unknown pending approval request req-1", + }), + activity("user-input.requested", "req-2", {}), + activity("provider.user-input.respond.failed", "req-2", { + detail: "stale pending user-input request req-2", + }), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // A non-stale respond failure (transient provider error) keeps the + // request open: the user can retry, so it is still blocked-on-you. + const stillOpen = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-transient-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-3", {}), + activity("provider.approval.respond.failed", "req-3", { + detail: "provider connection reset", + }), + ]), + }).pipe(Effect.flip); + expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("bounds the queued-turn grace window against client clock skew", () => + Effect.gen(function* () { + const userMessage = (createdAt: string): OrchestrationThread["messages"][number] => ({ + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }); + + // The decider's clock is the Effect test clock, pinned to the epoch: + // timestamps here are relative to 1970-01-01T00:00:00.000Z. + + // Within the grace window: genuinely queued, settle rejected. + const queuedError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-queued"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), + }).pipe(Effect.flip); + expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + + // Message timestamp far in the FUTURE (client clock ahead of server): + // a negative age must not read as queued forever — past the grace + // bound in either direction the thread is settleable. + const skewed = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-skewed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1970-01-01T01:00:00.000Z")]), + }); + const skewedEvents = Array.isArray(skewed) ? skewed : [skewed]; + expect(skewedEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling and unsettling archived threads", () => + Effect.gen(function* () { + const settleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, NOW), + }).pipe(Effect.flip); + expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); + + const unsettleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-archived"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled", NOW), + }).pipe(Effect.flip); + expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => + Effect.gen(function* () { + const userEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled"), + }); + const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; + expect(userEvents).toHaveLength(1); + expect(userEvents[0]?.type).toBe("thread.unsettled"); + if (userEvents[0]?.type === "thread.unsettled") { + expect(userEvents[0].payload.reason).toBe("user"); + } + + // Re-dispatching against the already-reached state re-emits rather than + // producing zero events (the engine rejects empty commands). + const userAgain = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user-again"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("active"), + }); + const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; + expect(userAgainEvents).toHaveLength(1); + expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); + }), + ); + + it.effect("prepends activity unsets for turn starts and live session updates", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), + session: makeSession("running"), + createdAt: NOW, + }, + // A keep-active pin is also an override: real activity clears it + // back to neutral so auto-settle can apply again later. + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.session-set", + ]); + }), + ); + + it.effect("clears a keep-active pin on real activity", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-active-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-active"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + // The pin exists to suppress AUTO-settle, not to survive real work: + // activity resets it to neutral, restoring the default lifecycle. + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const activityResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-active-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-active"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; + expect(activityEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + }), + ); + + it.effect("does not unsettle for session stop/error status writes", () => + Effect.gen(function* () { + for (const status of ["stopped", "error", "ready", "idle"] as const) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-session-${status}`), + threadId: ThreadId.make("thread-1"), + session: makeSession(status), + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); + } + }), + ); + + it.effect("unsettles for approval and user-input activities but not others", () => + Effect.gen(function* () { + const approvalResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; + expect(approvalEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + + const routineResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-routine"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "info", + kind: "tool.completed", + summary: "Tool completed", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; + expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1730494ecc6..cba967afc7c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -24,6 +24,61 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +// Session adoption takes seconds; a user message still unadopted after this +// window is a failed/stale start, not pending work. Mirrors the client's +// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * Blocked-on-you work derived from the thread's retained activities: an + * approval or user-input request with no later resolution for the same + * requestId. The server-side twin of the shell's hasPendingApprovals / + * hasPendingUserInput flags, which the decider read model does not carry. + * The clearing rules MUST match ProjectionPipeline's pending accounting — + * resolved activities always clear, respond.failed clears only when the + * failure detail marks the request stale/unknown — or settle would be + * rejected on threads whose shell flags read as clear. + */ +function isStaleRequestFailureDetail(payload: Record | null): boolean { + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (detail === null) return false; + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") || + detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request") || + detail.includes("unknown pending user input request") || + detail.includes("unknown pending codex user input request") + ); +} + +function hasOpenBlockingRequest(thread: { + readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; +}): boolean { + const openRequestIds = new Set(); + for (const activity of thread.activities) { + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; + if (requestId === null) continue; + if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + } else if ( + (activity.kind === "provider.approval.respond.failed" || + activity.kind === "provider.user-input.respond.failed") && + isStaleRequestFailureDetail(payload) + ) { + openRequestIds.delete(requestId); + } + } + return openRequestIds.size > 0; +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -327,6 +382,132 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.settle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Server-side twin of the client's canSettle session check: a stale + // or raced client must not settle a thread whose session is coming + // alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has an active session and cannot be settled`, + }), + ); + } + // Pending approval / user-input requests are blocked-on-you work: a + // raced or stale client must not park them behind a settled override + // that would surface only after the request resolves. + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, + }), + ); + } + const occurredAt = yield* nowIso; + // A queued turn start — a user message no turn has picked up yet — is + // work in flight even though session is still null (turn.start emits + // message-sent + turn-start-requested; the session arrives later). + // Settling in that window would hide just-requested work. Detection + // mirrors the client's hasQueuedTurnStart: the newest user message is + // strictly newer than every latestTurn timestamp (adoption stamps the + // new turn's requestedAt with the message time, clearing this), and + // only within the adoption grace window — historical threads whose + // last user message postdates their turn timestamps (older-server + // data, mid-turn messages) must stay settleable. A failed session + // start (status "error") clears the block immediately. + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + // The age check is bounded on BOTH sides: message timestamps are + // client-supplied, so a client clock ahead of the server yields a + // negative age. Without the lower bound that negative age satisfies + // `<= grace` for as long as the skew lasts, extending the settle + // block far past the intended two minutes. + const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; + const hasQueuedTurnStart = + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs && + Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS; + if (hasQueuedTurnStart) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, + }), + ); + } + // Settling an already-settled thread re-emits with the original + // settledAt: the engine rejects zero-event commands, and bulk-settle / + // double-click must stay silent no-ops rather than surface errors. + const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt: alreadySettled ? thread.settledAt : occurredAt, + // A re-emission is a projected no-op: keep the existing updatedAt + // so duplicate settles neither rewind nor churn ordering. A fresh + // settle stamps the command time. + updatedAt: alreadySettled ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsettle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): reducing the event a + // second time lands on the same override state. A re-emission keeps + // the existing updatedAt so duplicates do not churn ordering. + const alreadyPinnedActive = thread.settledOverride === "active"; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -479,7 +660,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - return [userMessageEvent, turnStartRequestedEvent]; + // Real activity resets ANY override: it wakes an explicitly settled + // thread, and it clears a keep-active pin back to neutral so the + // thread can auto-settle again after this burst of work goes stale. + if (targetThread.settledOverride === null) { + return [userMessageEvent, turnStartRequestedEvent]; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -600,12 +801,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -619,6 +820,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; + // Only a session coming alive is activity worth waking a settled thread + // for — status writes like ready/stopped/error arrive after the fact and + // must not fight a user's explicit settle. + const isSessionActivity = + command.session.status === "starting" || command.session.status === "running"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !isSessionActivity) { + return sessionSetEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -745,7 +970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, @@ -758,7 +983,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - return { + const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -772,6 +997,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; + // An approval or user-input request is blocked-on-you work — it must + // never stay hidden inside a settled slim row. + const wakesSettledThread = + command.activity.kind === "approval.requested" || + command.activity.kind === "user-input.requested"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !wakesSettledThread) { + return activityAppendedEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts new file mode 100644 index 00000000000..2070c44418a --- /dev/null +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -0,0 +1,88 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects settled lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.settledAt).toBe(now); + + const userUnsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + }), + ); + expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); + expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + + const activityUnsettled = yield* projectEvent( + userUnsettled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + }), + ); + expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); + expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..e9a55c0796b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,6 +89,8 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..c8f47dcebbf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,7 +22,9 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSettledPayload, ThreadUnarchivedPayload, + ThreadUnsettledPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -286,6 +288,8 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], activities: [], @@ -337,6 +341,30 @@ export function projectEvent( })), ); + case "thread.settled": + return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: "settled", + settledAt: payload.settledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsettled": + return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..497aa8b7d2c 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -128,4 +130,58 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); + + it.effect("round-trips non-null settlement values through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-settled"), + projectId: ProjectId.make("project-1"), + title: "Settled thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + archivedAt: null, + settledOverride: "settled", + settledAt: "2026-03-25T00:00:00.000Z", + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const row = Option.getOrNull(persisted); + if (!row) { + return yield* Effect.die("Expected settled projection_threads row to exist."); + } + assert.strictEqual(row.settledOverride, "settled"); + assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + + // Un-settle to the keep-active pin and confirm the flip persists. + yield* threads.upsert({ + ...row, + settledOverride: "active", + settledAt: null, + }); + const repersisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const updated = Option.getOrNull(repersisted); + assert.strictEqual(updated?.settledOverride, "active"); + assert.strictEqual(updated?.settledAt, null); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..e0c85e91494 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + settled_override, + settled_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.settledOverride}, + ${row.settledAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + settled_override = excluded.settled_override, + settled_at = excluded.settled_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d468838d5d4..cacb2c85b83 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadsSettled", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts new file mode 100644 index 00000000000..e93407defe2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "settled_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_override TEXT + `; + } + + if (!columns.some((column) => column.name === "settled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..8057d434950 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,6 +36,8 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), + settledAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..3843c8acbcd 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,6 +100,8 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 0c261c8f21e..74a4de594a1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", @@ -607,6 +611,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6122c498145..8a5e0b713e6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -164,6 +164,8 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -193,6 +195,8 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -5499,6 +5503,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6c692dc3de8..8a713fd9026 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -8,7 +8,9 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; +import { useClientSettings } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; +import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { resolveInitialThreadSidebarWidth, @@ -98,7 +100,12 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + // Settings routes render the settings nav, which lives in the v1 component + // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); + const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const useSidebarV2 = sidebarV2Enabled && !isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); @@ -157,7 +164,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - + {useSidebarV2 ? : } {children} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bc7487cee29..c578e69c450 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -49,6 +49,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 325f9afa90a..6b74eae9ff4 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,6 +45,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..f3145df4500 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5220,6 +5220,7 @@ function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.title} + activeProjectCwd={activeProject?.workspaceRoot ?? null} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..6609017f91c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -24,6 +24,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1dd4e340125..a9b32a887c0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -18,8 +18,10 @@ import { resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, + resolveSidebarV2Status, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -634,6 +636,100 @@ describe("isContextMenuPointerDown", () => { }); }); +describe("resolveSidebarV2Status", () => { + const session = { + threadId: ThreadId.make("thread-1"), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }; + + const idle = { hasPendingApprovals: false, hasPendingUserInput: false }; + + it("prioritizes approval over a running session", () => { + expect(resolveSidebarV2Status({ ...idle, hasPendingApprovals: true, session })).toBe( + "approval", + ); + }); + + it("prioritizes awaiting input over a running session, below approval", () => { + expect(resolveSidebarV2Status({ ...idle, hasPendingUserInput: true, session })).toBe("input"); + expect( + resolveSidebarV2Status({ + ...idle, + hasPendingApprovals: true, + hasPendingUserInput: true, + session, + }), + ).toBe("approval"); + }); + + it("reports working for running and starting sessions", () => { + expect(resolveSidebarV2Status({ ...idle, session })).toBe("working"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "starting" as const }, + }), + ).toBe("working"); + }); + + it("reports failed only while the session status is error", () => { + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "error" as const, lastError: "boom" }, + }), + ).toBe("failed"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "stopped" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "ready" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + }); + + it("defaults to ready with no session", () => { + expect(resolveSidebarV2Status({ ...idle, session: null })).toBe("ready"); + }); +}); + +describe("sortThreadsForSidebarV2", () => { + const sortable = (input: { id: string; createdAt: string }) => ({ + id: input.id, + createdAt: input.createdAt, + }); + + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }), + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); + + it("breaks creation-time ties by id so the order is stable", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }), + sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { hasActionableProposedPlan: false, @@ -900,6 +996,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1a565efd878..b8ae19c3e5f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -417,6 +417,71 @@ export function resolveThreadRowClassName(input: { return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } +// ── Sidebar v2 status model ───────────────────────────────────────── +// Five visual states, three colors: color is reserved for "act now" +// (approval), "in motion" (working), and "broken" (failed). Ready is the +// unlabeled resting state — the agent stopped and is waiting on the user, +// whether it finished, asked a question, or proposed a plan. +// Unread completion is tracked separately: it describes whether a ready +// thread needs attention, not what the thread is currently doing. +export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready"; + +type SidebarV2StatusInput = Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "session" +>; + +export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.hasPendingUserInput) { + return "input"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not + poison the whole ordering, so it sinks to the epoch instead. */ +export function parseTimestampMs(isoDate: string): number { + const parsed = Date.parse(isoDate); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** First VALID timestamp wins: `a ?? b` falls through on null, but a present- + yet-malformed string must also fall through to the next candidate rather + than sink the row to the epoch. */ +export function firstValidTimestampMs( + ...candidates: ReadonlyArray +): number { + for (const candidate of candidates) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +// v2 sort: static creation order, newest thread on top. Activity NEVER +// reorders the list — a row holds its position from open until settled, so +// the screen only moves at lifecycle transitions. Status (including pending +// approval) is carried by each card's edge strip, not by position. +export function sortThreadsForSidebarV2< + T extends { readonly id: string; readonly createdAt: string }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted( + (left, right) => + parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + left.id.localeCompare(right.id), + ); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4b4b1e52a94..cf3fa30cf8d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,7 +8,6 @@ import { Globe2Icon, LoaderIcon, SearchIcon, - SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -63,7 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -74,10 +73,9 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { cn, isMacPlatform } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { readThreadShell, useProject, @@ -126,7 +124,6 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -169,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, - SidebarFooter, SidebarGroup, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -179,7 +174,6 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, - SidebarTrigger, useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; @@ -194,7 +188,6 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -204,12 +197,12 @@ import { ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; -import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -223,7 +216,6 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -2787,112 +2779,6 @@ function SortableProjectItem({ ); } -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, -}: { - isElectron: boolean; -}) { - const stageLabel = useSidebarStageLabel(); - const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); - - return ( - - {backdropVariant ? : null} - - - - ); -}); - -function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { - return ( - - - - Code - - - ); -} - -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - -function T3Wordmark() { - return ( - - - - ); -} - -const SidebarChromeFooter = memo(function SidebarChromeFooter() { - const navigate = useNavigate(); - const { isMobile, setOpenMobile } = useSidebar(); - const handleSettingsClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/settings" }); - }, [isMobile, navigate, setOpenMobile]); - - return ( - - - - - - - - Settings - - - - - ); -}); - interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx new file mode 100644 index 00000000000..a62e09a3b8e --- /dev/null +++ b/apps/web/src/components/SidebarV2.tsx @@ -0,0 +1,1707 @@ +import { autoAnimate } from "@formkit/auto-animate"; +import { useAtomValue } from "@effect/atom-react"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + scopeProjectRef, + scopeThreadRef, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + CheckIcon, + CircleCheckIcon, + CircleDashedIcon, + CloudIcon, + FolderPlusIcon, + PlusIcon, + SearchIcon, + SquarePenIcon, + Undo2Icon, +} from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useParams, useRouter } from "@tanstack/react-router"; + +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { isElectron } from "../env"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHintsForModifiers, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { useShortcutModifierState } from "../shortcutModifierState"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isMacPlatform } from "~/lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { readLocalApi } from "../localApi"; +import { useUiStateStore } from "../uiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { useThreadActions } from "../hooks/useThreadActions"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { onOpenNewThreadPicker } from "../newThreadPickerBus"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; +import { + resolveThreadActionProjectRef, + startNewThreadFromContext, + startNewThreadInProjectFromContext, +} from "../lib/chatThreadActions"; +import { useClientSettings } from "../hooks/useSettings"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; +import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; +import { threadEnvironment } from "../state/threads"; +import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import type { SidebarThreadSummary } from "../types"; +import { cn } from "~/lib/utils"; +import { + firstValidTimestampMs, + hasUnseenCompletion, + isTrailingDoubleClick, + resolveAdjacentThreadId, + resolveSidebarV2Status, + sortThreadsForSidebarV2, +} from "./Sidebar.logic"; +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { primaryServerProvidersAtom } from "../state/server"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { CommandDialogTrigger } from "./ui/command"; +import { Kbd } from "./ui/kbd"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + useSidebar, +} from "./ui/sidebar"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +// Settled-tail paging: recent history is the common lookup; the deep tail +// stays behind an explicit Show more. +const SETTLED_TAIL_INITIAL_COUNT = 10; +const SETTLED_TAIL_PAGE_COUNT = 25; + +function compactSidebarTimeLabel(label: string): string { + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; +} + +function threadTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; + return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +} + +const SidebarV2Row = memo(function SidebarV2Row(props: { + thread: SidebarThreadSummary; + variant: "card" | "slim"; + // Slim rows are either settled (action: un-settle) or merely quiet + // (seen Ready threads — action: settle). + variantAction: "settle" | "unsettle"; + // False on environments whose server predates thread.settle/unsettle: + // the lifecycle affordances hide entirely rather than fail on click. + settlementSupported: boolean; + // Marks where active work transitions into history: a quiet labeled + // rule above the first settled row, so the tail reads as a named zone + // rather than an unexplained gap. + showSettledGap?: boolean; + isActive: boolean; + jumpLabel: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + projectTitle: string | null; + providerEntryByInstanceId: ReadonlyMap; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; + renamingTitle: string; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onUnsettle: (threadRef: ScopedThreadRef) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { + isRenaming, + onChangeRequestState, + onCancelRename, + onCommitRename, + onContextMenu, + onRenameTitleChange, + onSettle, + onStartRename, + onThreadActivate, + onThreadClick, + onUnsettle, + renamingTitle, + thread, + variant, + variantAction, + } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const openPrLink = useOpenPrLink(); + + // Same semantics as v1 (never-visited counts as read): flipping the beta + // flag must not light up every historical thread as unread. + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarV2Status(thread); + const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; + const topStatus = + status === "working" + ? { + label: "Working", + icon: "working" as const, + className: + "animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400", + } + : status === "approval" + ? { + label: "Approval", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "input" + ? { + label: "Input", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "failed" + ? { + label: "Failed", + icon: null, + className: "font-semibold text-red-700 dark:text-red-300", + } + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "font-semibold text-emerald-700 dark:text-emerald-300", + } + : null; + + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Report the PR state up: the parent partitions rows with effectiveSettled, + // and a merged/closed PR auto-settles a thread — data only rows have. + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null; + + const isRemote = + props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); + }, + [onThreadClick, threadRef], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }, + [onContextMenu, threadRef], + ); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(threadRef); + }, + [onThreadActivate, threadRef], + ); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); + }, + [isRenaming, onStartRename, thread.title, threadRef], + ); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }, + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], + ); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); + } + }, [onCommitRename, renamingTitle, thread.title, threadRef]); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onSettle(threadRef); + }, + [onSettle, threadRef], + ); + const handleUnsettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsettle(threadRef); + }, + [onUnsettle, threadRef], + ); + const handlePrClick = useCallback( + (event: ReactMouseEvent) => { + if (pr?.url) openPrLink(event, pr.url); + }, + [openPrLink, pr], + ); + + const rowClassName = cn( + "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left", + props.isActive + ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]" + : isSelected + ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]" + : "hover:bg-accent/65", + ); + + const title = isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground" + /> + ) : ( + + {thread.title} + + ); + + const prBadge = + prStatus && pr ? ( + + ) : null; + + if (variant === "slim") { + return ( +
  • + {props.showSettledGap ? ( +
    + Settled + +
    + ) : null} +
    + {/* Settled history recedes: dimmed favicon at rest, restored on + hover so the tail stays scannable when you're hunting. */} + + + + {title} + {/* The PR badge stays outside the hover-fading slot: it must + remain visible AND clickable while the row is hovered. Only + the time/jump label yields to the settle affordance. */} + {prBadge} + + + + {props.jumpLabel ?? + compactSidebarTimeLabel( + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), + )} + + + {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + + ) : ( + + )} + +
    +
  • + ); + } + + const diff = latestTurnDiff(thread); + + return ( +
  • +
    +
    +
    + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} + + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} + +
    +
    {title}
    +
    + {thread.branch ? ( + + {thread.branch} + + ) : ( + + )} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {driverKind ? ( + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} + +
    + {status === "failed" && thread.session?.lastError ? ( +
    + {thread.session.lastError} +
    + ) : null} +
    +
    +
  • + ); +}); + +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; +} + +export default function SidebarV2() { + const projects = useProjects(); + const threads = useThreadShells(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const { settleThread, unsettleThread, deleteThread } = useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const newThreadContext = useHandleNewThread(); + const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // Post-settle navigation validates against the CURRENT route, not the one + // captured when the settle started: if the user navigated elsewhere while + // the command was in flight, completing it must not yank them away. + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerEntryByInstanceId = useMemo( + () => + new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + [serverProviders], + ); + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + const projectTitleByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); + + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + + // Project scope: chips above the list. Scoping filters the list AND + // becomes the new-thread target — one visible control doing both jobs the + // old per-project headers did. + const [projectScopeKey, setProjectScopeKey] = useState(null); + const scopedProject = useMemo( + () => + projectScopeKey === null + ? null + : (projects.find( + (project) => `${project.environmentId}:${project.id}` === projectScopeKey, + ) ?? null), + [projectScopeKey, projects], + ); + useEffect(() => { + if ( + projectScopeKey !== null && + !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey) + ) { + setProjectScopeKey(null); + } + }, [projectScopeKey, projects]); + // Scope flips drop the selection: rows selected under the old scope may be + // hidden now, and bulk actions must never count or touch invisible rows. + useEffect(() => { + clearSelection(); + }, [clearSelection, projectScopeKey]); + + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells: no archived-snapshot + // merging, no optimistic holds. Archived threads remain hidden here — + // archive keeps its original "remove from sidebar" meaning. + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const { activeThreads, settledThreads } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id)), + ); + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + return { + activeThreads: sortThreadsForSidebarV2(active), + settledThreads: settled.toSorted( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ), + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProject, + serverConfigs, + threads, + ]); + + // The settled tail renders in pages: history shouldn't dominate the + // sidebar, and the common lookups are recent. Expansion resets when the + // filter context changes so a scope/search flip never inherits a deep + // page state. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = `${projectScopeKey ?? "all"}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); + const visibleSettledThreads = useMemo( + () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), + [hiddenSettledCount, settledThreads, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + + const orderedThreads = useMemo( + () => [...activeThreads, ...visibleSettledThreads], + [activeThreads, visibleSettledThreads], + ); + const orderedThreadKeys = useMemo( + () => + orderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedThreads], + ); + // Rows call back into the click handler without carrying the ordered list as + // a prop — a fresh array identity per shell update would defeat every row's + // memoization. The ref keeps shift-range-select working against the list as + // rendered at click time. + const orderedThreadKeysRef = useRef(orderedThreadKeys); + orderedThreadKeysRef.current = orderedThreadKeys; + const threadByKey = useMemo( + () => + new Map( + orderedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [orderedThreads], + ); + // Handlers read these through refs: depending on per-update Map/Set + // identities would give every row a fresh callback prop on each shell + // event and defeat row memoization during streaming. + const threadByKeyRef = useRef(threadByKey); + threadByKeyRef.current = threadByKey; + // handleNewThread is inherently unstable (depends on the projects list); + // a ref keeps it out of attemptSettle's dependency array. + const handleNewThreadRef = useRef(newThreadContext.handleNewThread); + handleNewThreadRef.current = newThreadContext.handleNewThread; + const settledThreadKeys = useMemo( + () => + new Set( + settledThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [settledThreads], + ); + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + + const jumpLabelByKey = useMemo(() => { + const mapping = new Map(); + for (const [index, threadKey] of orderedThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(index); + if (!jumpCommand) break; + const label = shortcutLabelForCommand(keybindings, jumpCommand); + if (label) mapping.set(threadKey, label); + } + return mapping; + }, [keybindings, orderedThreadKeys]); + const [showJumpHints, setShowJumpHints] = useState(false); + + // Settled threads are live shells, so opening one is plain navigation: + // history stays readable without un-settling, and sending a message or + // starting a session un-settles server-side. + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], + ); + + const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); + const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); + const commitThreadRename = useCallback( + (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { + void (async () => { + const trimmed = title.trim(); + setRenamingThreadKey(null); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (trimmed === originalTitle) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [updateThreadMetadata], + ); + + const handleThreadClick = useCallback( + (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const threadKey = scopedThreadKey(threadRef); + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, orderedThreadKeysRef.current); + return; + } + if (isTrailingDoubleClick(event.detail)) { + return; + } + navigateToThread(threadRef); + }, + [navigateToThread, rangeSelectTo, toggleThreadSelection], + ); + + // A settle per thread at a time: double clicks and repeated menu picks + // must not dispatch a second settle that fails and toasts a false error. + const settlingThreadKeysRef = useRef(new Set()); + const attemptSettle = useCallback( + (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (settlingThreadKeysRef.current.has(threadKey)) return; + settlingThreadKeysRef.current.add(threadKey); + try { + // Settling the thread you're looking at moves you forward: the next + // remaining card (never a settled row, never one settling in the + // same batch), or a fresh draft in this project when it was the + // last active one. Snapshot the target before the settle mutates + // the partition. Background settles never navigate. + const shell = threadByKeyRef.current.get(threadKey); + let navigateAfterSettle: (() => void) | null = null; + if (routeThreadKey === threadKey) { + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([ + ...orderedKeys.slice(currentIndex + 1), + ...orderedKeys.slice(0, currentIndex), + ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + navigateAfterSettle = nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current( + scopeProjectRef(shell.environmentId, shell.projectId), + ) + : () => void router.navigate({ to: "/" }); + } + const result = await settleThread(threadRef); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not settle. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + // Only move forward if the user is still on the settled thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSettle?.(); + } + } finally { + settlingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [navigateToThread, routeThreadKey, router, settleThread], + ); + const attemptUnsettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unsettleThread], + ); + + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + // One exact actionable set: keys whose rows are actually rendered + // right now. Selections can outlive their rows (settled-tail paging, + // thread deletion elsewhere) and the menu labels must count only what + // the actions will touch. + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( + (threadKey) => threadByKeyRef.current.has(threadKey), + ); + if (threadKeys.length === 0) return; + const count = threadKeys.length; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "settle", label: `Settle (${count})` }, + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value === "settle") { + // Post-settle navigation must skip threads settling in this same + // batch — they are all leaving the card block together. Rows that + // are already explicitly settled are skipped: nothing to do on a + // valid mixed selection. + const coSettlingKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread || thread.settledOverride === "settled") continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + } + clearSelection(); + return; + } + if (clicked.value === "mark-unread") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + if (clicked.value !== "delete") return; + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + // Grown as deletions actually land, never seeded with the whole batch: + // orphaned-worktree detection must only discount threads that are + // really gone, or the first delete would treat still-alive batch mates + // as deleted and remove a worktree they still point at. + const deletedThreadKeys = new Set(); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + deletedThreadKeys.add(threadKey); + } + removeFromSelection(threadKeys); + }, + [ + attemptSettle, + clearSelection, + confirmThreadDelete, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + + const handleThreadContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const threadKey = scopedThreadKey(threadRef); + const selectionState = useThreadSelectionStore.getState(); + if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { + await handleMultiSelectContextMenu(position); + return; + } + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return; + // Un-settle works on every settled row: for explicit settles it + // clears the override, for auto-settled rows it pins the thread + // active until real activity clears the pin. Environments without + // the settlement capability get no lifecycle items at all. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const isSettled = settledThreadKeysRef.current.has(threadKey); + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + ...(supportsSettlement + ? [ + isSettled + ? { id: "unsettle", label: "Un-settle thread" } + : { id: "settle", label: "Settle thread" }, + ] + : []), + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "settle": + attemptSettle(threadRef); + return; + case "unsettle": + attemptUnsettle(threadRef); + return; + case "rename": + startThreadRename(threadRef, thread.title); + return; + case "mark-unread": + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } + default: + return; + } + })(); + }, + [ + attemptSettle, + attemptUnsettle, + confirmThreadDelete, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + serverConfigs, + startThreadRename, + ], + ); + + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); + return; + } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + keybindings, + navigateToThread, + orderedThreadKeys, + routeTerminalOpen, + routeThreadKey, + threadByKey, + ]); + + // Same predicate as v1: hints show only while the held modifiers exactly + // match a thread-jump binding. Adding Shift (screenshots) or Alt no + // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. + const shortcutModifiers = useShortcutModifierState(); + const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { platform: navigator.platform }, + ); + useEffect(() => { + setShowJumpHints(shouldShowJumpHintsNow); + }, [shouldShowJumpHintsNow]); + + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, []); + + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The chevron menu is the explicit project picker the flat list no + // longer gets from per-project headers. + const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); + // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in + // v2 with multiple projects it opens this picker via the event bus. + useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); + const handleNewThreadClick = useCallback(() => { + // One project: nothing to pick, create immediately. + if (projects.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + setNewThreadPickerOpen(true); + }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + const createThreadInProject = useCallback( + (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { + setNewThreadPickerOpen(false); + if (isMobile) setOpenMobile(false); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (!project) return; + void startNewThreadInProjectFromContext( + { + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + [isMobile, newThreadContext, projects, setOpenMobile], + ); + const contextualProjectRef = resolveThreadActionProjectRef({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + const newThreadTargetRef = scopedProject + ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) + : contextualProjectRef; + const newThreadTargetProject = newThreadTargetRef + ? (projects.find( + (project) => + project.environmentId === newThreadTargetRef.environmentId && + project.id === newThreadTargetRef.projectId, + ) ?? null) + : null; + // Picker order: the contextual default first (preselected), everything else + // after — the common case is Enter/click on the top row. + const newThreadPickerProjects = useMemo(() => { + if (!newThreadTargetProject) return projects; + return [ + newThreadTargetProject, + ...projects.filter( + (project) => + project.environmentId !== newThreadTargetProject.environmentId || + project.id !== newThreadTargetProject.id, + ), + ]; + }, [newThreadTargetProject, projects]); + + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); + // Same resolution as v1: prefer the local-thread binding, fall back to + // chat.new, no platform gating — web users have working shortcuts too. + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal") ?? + shortcutLabelForCommand(keybindings, "chat.new"); + const projectScrollerRef = useRef(null); + const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); + const updateProjectScrollFade = useCallback(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + setCanScrollProjectsRight( + scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, + ); + }, []); + useEffect(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + + updateProjectScrollFade(); + const resizeObserver = new ResizeObserver(updateProjectScrollFade); + resizeObserver.observe(scroller); + return () => resizeObserver.disconnect(); + }, [projects, updateProjectScrollFade]); + + return ( + <> + + + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + + + + + + {projects.length > 0 ? ( + +
    +
    + {projects.length > 1 ? ( + + ) : null} + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + const isScoped = projectScopeKey === scopeKey; + return ( + + ); + })} +
    +
    + + + } + > + + + Add project + +
    +
    +
    + ) : null} + +
      + {orderedThreads.map((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + ); + const showSettledGap = !isCard && previousWasCard; + return ( + + ); + })} + {hiddenSettledCount > 0 ? ( +
    • + +
    • + ) : null} +
    + {orderedThreads.length === 0 ? ( +
    + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProject ? ( + `No threads in ${scopedProject.title} yet` + ) : ( + "No threads yet" + )} +
    + ) : null} +
    +
    + + + + + + New thread in… + +
    { + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + const container = event.currentTarget; + const options = [...container.querySelectorAll("button")]; + if (options.length === 0) return; + const currentIndex = options.findIndex((option) => option === document.activeElement); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1) % options.length + : (currentIndex - 1 + options.length) % options.length; + event.preventDefault(); + options[nextIndex]?.focus(); + }} + > + {newThreadPickerProjects.map((project, index) => { + const isDefault = index === 0 && newThreadTargetProject !== null; + return ( + + ); + })} + +
    +
    +
    + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f35bff081e1..94e4af3bba6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2143,8 +2143,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ref={composerSurfaceRef} data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} className={cn( - "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45", - isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", + "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-foreground/40", + isDragOverComposer + ? "border-primary/70 bg-accent/45" + : "border-black/12 dark:border-white/12", projectSelectionRequired ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, )} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ef3ec863d0b..d39828752fb 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -16,6 +16,7 @@ import ProjectScriptsControl, { } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; +import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -24,6 +25,7 @@ interface ChatHeaderProps { draftId?: DraftId; activeThreadTitle: string; activeProjectName: string | undefined; + activeProjectCwd: string | null; openInCwd: string | null; activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; @@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, + activeProjectCwd, openInCwd, activeProjectScripts, preferredScriptId, @@ -79,6 +82,26 @@ export const ChatHeader = memo(function ChatHeader({ return (
    + {/* The project always leads the header: knowing which project a + thread lives in is priority zero, and the thread title alone + doesn't answer it. */} + {activeProjectName ? ( + + + + + {activeProjectName} + + + + / + + + ) : null} void; +}) { + // Local draft so the field can be emptied mid-edit; the setting only moves + // on valid input and snaps back to the persisted value on blur. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( + { + setDraft(event.target.value); + // Number(), not parseInt: "3.5" must be rejected (not truncated to a + // committed 3 while the field shows 3.5) — commit only when the + // persisted value matches the displayed one. + const parsed = Number(event.target.value); + if ( + Number.isInteger(parsed) && + parsed >= AUTO_SETTLE_MIN_DAYS && + parsed <= AUTO_SETTLE_MAX_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +export function BetaSettingsPanel() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarAutoSettleAfterDays = useClientSettings( + (settings) => settings.sidebarAutoSettleAfterDays, + ); + const updateSettings = useUpdateClientSettings(); + + return ( + + + updateSettings({ sidebarV2Enabled: Boolean(checked) })} + aria-label="Enable the sidebar v2 beta" + /> + } + /> + {sidebarV2Enabled ? ( + <> + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..9d690f2a4e4 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + FlaskConicalIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -28,6 +29,7 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/beta" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx new file mode 100644 index 00000000000..c665f7741e7 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -0,0 +1,127 @@ +import { useAtomValue } from "@effect/atom-react"; +import { SettingsIcon } from "lucide-react"; +import { memo, useCallback } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; + +import { APP_STAGE_LABEL } from "../../branding"; +import { cn } from "../../lib/utils"; +import { primaryServerConfigAtom } from "../../state/server"; +import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; +import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; +import { + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, + useSidebar, +} from "../ui/sidebar"; +import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; + +export const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + const stageLabel = useSidebarStageLabel(); + const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); + + return ( + + {backdropVariant ? : null} + + + + ); +}); + +function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + return ( + + + + Code + + + ); +} + +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +function T3Wordmark() { + return ( + + + + ); +} + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const handleSettingsClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/settings" }); + }, [isMobile, navigate, setOpenMobile]); + + return ( + + + + + + + + Settings + + + + + ); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index dbde5acce99..de31ff09aa6 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,6 +4,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -19,7 +20,12 @@ import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { readLocalApi } from "../localApi"; -import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; +import { + readEnvironmentSupportsSettlement, + readEnvironmentThreadRefs, + readProject, + readThreadShell, +} from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -39,6 +45,30 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadSettlementUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support settling yet. Update the server to use Settle."; + } +} + +export class ThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "ThreadSettleBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -50,6 +80,12 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { + reportFailure: false, + }); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -345,6 +381,66 @@ export function useThreadActions() { ], ); + const settleThread = useCallback( + async (target: ScopedThreadRef) => { + // Version skew: never send the command to a server that predates it — + // the raw protocol rejection would read as a random failure. + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + const resolved = resolveThreadTarget(target); + // Settle may only target what effectiveSettled could classify as + // settled: not starting/running sessions, not threads waiting on + // approvals or user input. Anything else would hide live work. + if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettleBlockedError({ + environmentId: resolved.threadRef.environmentId, + threadId: resolved.threadRef.threadId, + }), + ), + ); + } + // Settle is a high-frequency lifecycle action and stays silent — no + // toast. + return settleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }, + [resolveThreadTarget, settleThreadMutation], + ); + + const unsettleThread = useCallback( + async (target: ScopedThreadRef) => { + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + // reason "user" pins the thread active: auto-settle (PR merged / + // inactivity) stays suppressed until real activity clears the pin. + return unsettleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }, + [unsettleThreadMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -379,7 +475,16 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + settleThread, + unsettleThread, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + confirmAndDeleteThread, + deleteThread, + settleThread, + unarchiveThread, + unsettleThread, + ], ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 41abcbd3b17..72caf539e8b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -108,11 +108,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; - /* Duty-cycled indicator animations: long opacity holds with short stepped - ramps, so the compositor only produces frames while the value changes - (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */ + /* Duty-cycled indicator animations: long holds with stepped ramps, so the + compositor updates discrete frames instead of every vsync. */ --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; + --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -185,6 +185,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil scale: 2; } } + @keyframes sidebar-working-text { + 0%, + 36% { + opacity: 1; + animation-timing-function: steps(10); + } + 50%, + 86% { + opacity: 0.75; + animation-timing-function: steps(10); + } + 100% { + opacity: 1; + } + } } @layer base { @@ -232,8 +247,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the - mask lets the surface grain show through at the boundary. */ + mask lets the surface grain show through at the boundary. Panels whose + background differs from the app chrome (e.g. sidebar v2) override + --sidebar-stage-fade so the art fades into their own surface color. */ .sidebar-stage-backdrop { + --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); } @@ -246,12 +264,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil to bottom, transparent 0%, transparent 28%, - color-mix(in srgb, var(--app-chrome-background) 10%, transparent) 40%, - color-mix(in srgb, var(--app-chrome-background) 30%, transparent) 52%, - color-mix(in srgb, var(--app-chrome-background) 58%, transparent) 64%, - color-mix(in srgb, var(--app-chrome-background) 82%, transparent) 75%, - color-mix(in srgb, var(--app-chrome-background) 96%, transparent) 85%, - var(--app-chrome-background) 93% + color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, + color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, + color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, + color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, + color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, + var(--stage-fade) 93% ); } @@ -450,6 +468,41 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +/* Give the navigation rail its own cool graphite surface instead of borrowing + the workspace card color. The scoped semantic tokens keep sidebar v2's + controls in the same palette without changing v1 or the settings nav. */ +.app-sidebar { + --background: oklch(0.965 0.009 265); + --foreground: oklch(0.27 0.025 265); + --card: oklch(0.945 0.014 265); + --card-foreground: var(--foreground); + --accent: oklch(0.875 0.008 265); + --accent-foreground: oklch(0.24 0.012 265); + --muted: oklch(0.9 0.018 265); + --muted-foreground: oklch(0.49 0.03 265); + --border: oklch(0.72 0.025 265 / 28%); + --input: oklch(0.62 0.03 265 / 28%); + --sidebar-stage-fade: var(--card); + background-color: var(--card); +} + +.dark .app-sidebar { + --background: #000; + --foreground: #f1f3f7; + --card: #000; + --card-foreground: var(--foreground); + --accent: #191a1d; + --accent-foreground: #f7f9ff; + --muted: #0a0a0a; + --muted-foreground: #a3a3a3; + --border: rgb(255 255 255 / 14%); + --input: rgb(255 255 255 / 18%); + /* The stage-channel header art must ramp to THIS panel's surface, not the + global chrome background, or the fade shows a seam (same rule as the + light palette above). */ + --sidebar-stage-fade: var(--card); +} + body { font-family: "DM Sans Variable", diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 2b1d7b09b9f..2189f1e3ca2 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -6,6 +6,7 @@ import { resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, + startNewThreadInProjectFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -117,6 +118,26 @@ describe("chatThreadActions", () => { }); }); + it("does not carry branch or worktree context into a different project", async () => { + const handleNewThread = vi.fn(async () => {}); + const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID); + + await startNewThreadInProjectFromContext( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + }, + handleNewThread, + }), + targetProjectRef, + ); + + expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef); + }); + it("delegates the target environment defaults to the new-thread handler", async () => { const handleNewThread = vi.fn(async () => {}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4f30885610a..d8b831ac3ad 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -75,6 +75,14 @@ export async function startNewThreadInProjectFromContext( context: ChatThreadActionContext, projectRef: ScopedProjectRef, ): Promise { + const contextualProjectRef = resolveThreadActionProjectRef(context); + const matchesContext = + contextualProjectRef?.environmentId === projectRef.environmentId && + contextualProjectRef.projectId === projectRef.projectId; + if (!matchesContext) { + await context.handleNewThread(projectRef); + return; + } await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); } diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index b9981bc2e3e..ca9a5986c66 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts new file mode 100644 index 00000000000..a1cc7560e20 --- /dev/null +++ b/apps/web/src/newThreadPickerBus.ts @@ -0,0 +1,14 @@ +// Tiny event bus connecting the global chat.new shortcut (handled in the +// _chat route layout) to SidebarV2's project picker dialog. The route layer +// can't render the picker itself — it lives with the sidebar — and the +// sidebar can't own the window keydown handler without racing the layout's. +const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; + +export function openNewThreadPicker(): void { + window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); +} + +export function onOpenNewThreadPicker(listener: () => void): () => void { + window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); + return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b524fe018e9..563d1b43755 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' @@ -79,6 +80,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) +const SettingsBetaRoute = SettingsBetaRouteImport.update({ + id: '/beta', + path: '/beta', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -108,6 +114,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -123,6 +130,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -141,6 +149,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -160,6 +169,7 @@ export interface FileRouteTypes { | '/settings' | '/connect/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -175,6 +185,7 @@ export interface FileRouteTypes { | '/settings' | '/connect/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -192,6 +203,7 @@ export interface FileRouteTypes { | '/settings' | '/connect_/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -290,6 +302,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } + '/settings/beta': { + id: '/settings/beta' + path: '/beta' + fullPath: '/settings/beta' + preLoaderRoute: typeof SettingsBetaRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -337,6 +356,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -347,6 +367,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9fb1eae721e..b590c59ed9d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { useClientSettings } from "../hooks/useSettings"; +import { openNewThreadPicker } from "../newThreadPickerBus"; +import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const projectCount = useProjects().length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); + // Sidebar v2 routes creation through its project picker whenever + // there is a real choice to make; v1 (and single-project setups) + // keep the immediate contextual create. + if (sidebarV2Enabled && projectCount > 1) { + openNewThreadPicker(); + return; + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, @@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + projectCount, routeThreadRef, selectedThreadKeysSize, + sidebarV2Enabled, terminalOpen, ]); diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx new file mode 100644 index 00000000000..a1e78f2dff7 --- /dev/null +++ b/apps/web/src/routes/settings.beta.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; + +function SettingsBetaRoute() { + return ; +} + +export const Route = createFileRoute("/settings/beta")({ + component: SettingsBetaRoute, +}); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index db895e37c4a..47e63ca2cd3 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -185,6 +185,16 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } +/** Whether the environment's server understands thread.settle/unsettle. + False for pre-settlement servers (capability defaults false on decode), + so clients under version skew fall back instead of erroring. */ +export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..89734357889 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..4fa05f850e5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/thread-settled": { + "types": "./src/state/threadSettled.ts", + "default": "./src/state/threadSettled.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5cc3f0c1a86..0cb1650066c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; +import { + archiveThread, + createProject, + settleThread, + stopThreadSession, + unsettleThread, +} from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -134,4 +140,35 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("dispatches settle and unsettle commands without timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* settleThread({ + commandId: CommandId.make("settle-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* unsettleThread({ + commandId: CommandId.make("unsettle-command"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.settle", + commandId: "settle-command", + threadId: "thread-1", + }, + { + type: "thread.unsettle", + commandId: "unsettle-command", + threadId: "thread-1", + reason: "user", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..ef767854804 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type SettleThreadInput = CommandInput<"thread.settle">; +export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E }); }); +export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.settleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.settle", + commandId: yield* commandId(input), + }); +}); + +export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unsettleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unsettle", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index c772d134a67..e08fd9e552f 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -96,6 +96,8 @@ const THREAD_SHELL = { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index a069460e63c..fdccc4c47dd 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -36,6 +36,8 @@ const stubThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..ea87298e98f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,9 +12,11 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, + type SettleThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnsettleThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, @@ -25,9 +27,11 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + settleThread, startThreadTurn, stopThreadSession, unarchiveThread, + unsettleThread, updateThreadMetadata, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -42,9 +46,11 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, + SettleThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnsettleThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + settle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:settle", + execute: (input: SettleThreadInput) => settleThread(input), + scheduler, + concurrency, + }), + unsettle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unsettle", + execute: (input: UnsettleThreadInput) => unsettleThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 430900bdbb0..770738ad9bb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -57,6 +57,8 @@ export function mergeEnvironmentThread( createdAt: shell.createdAt, updatedAt: shell.updatedAt, archivedAt: shell.archivedAt, + settledOverride: shell.settledOverride, + settledAt: shell.settledAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..211f8748f4e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.settled / thread.unsettled", () => { + it("sets the settled override and timestamp", () => { + const settledAt = "2026-04-01T05:00:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe("settled"); + expect(result.thread.settledAt).toBe(settledAt); + } + }); + + it.each([ + ["user", "active"], + ["activity", null], + ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { + const settledThread: OrchestrationThread = { + ...baseThread, + settledOverride: "settled", + settledAt: "2026-04-01T05:00:00.000Z", + }; + const updatedAt = "2026-04-01T06:00:00.000Z"; + const result = applyThreadDetailEvent(settledThread, { + ...baseEventFields, + sequence: 6, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.unsettled", + payload: { + threadId: ThreadId.make("thread-1"), + reason, + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe(settledOverride); + expect(result.thread.settledAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..7fdee37c0e6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -72,6 +72,8 @@ export function applyThreadDetailEvent( createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -100,6 +102,28 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; + case "thread.settled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.unsettled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts new file mode 100644 index 00000000000..50f3c2d3912 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -0,0 +1,345 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + canSettle, + effectiveSettled, + hasQueuedTurnStart, + threadLastActivityAt, + type ChangeRequestStateLike, +} from "./threadSettled.ts"; + +const NOW = "2026-04-10T00:00:00.000Z"; +const FRESH = "2026-04-09T00:00:00.000Z"; +const STALE = "2026-04-06T23:59:59.999Z"; + +function makeShell(input: { + readonly settledOverride?: "settled" | "active" | null; + readonly activityAt: string | null; + readonly sessionStatus?: "starting" | "running"; + readonly pending?: "approval" | "user-input"; +}): OrchestrationThreadShell { + const threadId = ThreadId.make("thread-1"); + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + input.activityAt === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: input.activityAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: NOW, + archivedAt: null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledOverride === "settled" ? NOW : null, + session: + input.sessionStatus === undefined + ? null + : { + threadId, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + latestUserMessageAt: null, + hasPendingApprovals: input.pending === "approval", + hasPendingUserInput: input.pending === "user-input", + hasActionableProposedPlan: false, + }; +} + +describe("threadLastActivityAt", () => { + it("returns the latest real user or turn activity and ignores thread/session updates", () => { + const shell = makeShell({ activityAt: null, sessionStatus: "running" }); + const withActivity: OrchestrationThreadShell = { + ...shell, + latestUserMessageAt: "2026-04-04T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-03T00:00:00.000Z", + startedAt: "2026-04-05T00:00:00.000Z", + completedAt: "2026-04-06T00:00:00.000Z", + assistantMessageId: null, + }, + }; + + expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); + expect(threadLastActivityAt(shell)).toBeNull(); + }); +}); + +describe("effectiveSettled", () => { + const overrideCases = [null, "settled", "active"] as const; + const changeRequestStates = [undefined, "open", "merged"] as const; + const inactivityCases = [ + ["fresh", FRESH], + ["stale", STALE], + ["no-activity", null], + ] as const; + const runningCases = [false, true] as const; + const pendingCases = [undefined, "approval", "user-input"] as const; + const truthTable = overrideCases.flatMap((settledOverride) => + changeRequestStates.flatMap((changeRequestState) => + inactivityCases.flatMap(([inactivity, activityAt]) => + runningCases.flatMap((running) => + pendingCases.map((pending) => ({ + settledOverride, + changeRequestState, + inactivity, + activityAt, + running, + pending, + // Settled iff nothing blocks (pending work / live session) AND + // the override says settled, or (with no override) a merged PR + // or staleness auto-settles. The "active" pin suppresses both + // auto signals. + expected: + pending === undefined && + !running && + (settledOverride === "settled" || + (settledOverride === null && + (changeRequestState === "merged" || inactivity === "stale"))), + })), + ), + ), + ), + ); + + it.each(truthTable)( + "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", + ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { + const shell = makeShell({ + settledOverride, + activityAt, + ...(running ? { sessionStatus: "running" as const } : {}), + ...(pending === undefined ? {} : { pending }), + }); + const changeRequestOptions = + changeRequestState === undefined + ? {} + : { changeRequestState: changeRequestState as ChangeRequestStateLike }; + + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + ...changeRequestOptions, + }), + ).toBe(expected); + }, + ); + + it("treats closed change requests like merged ones", () => { + const shell = makeShell({ activityAt: null }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + it("never settles a starting session, even with a settled override", () => { + const shell = makeShell({ + settledOverride: "settled", + activityAt: STALE, + sessionStatus: "starting", + }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + }); + + it("uses a strict inactivity boundary and honors a null threshold", () => { + const boundary = makeShell({ + activityAt: "2026-04-07T00:00:00.000Z", + }); + const stale = makeShell({ activityAt: STALE }); + + expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); + }); +}); + +describe("hasQueuedTurnStart", () => { + const QUEUED_AT = "2026-04-09T12:00:00.000Z"; + // Within the adoption grace window of the queued message. + const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; + + it("flags a user message no turn has picked up, within the grace window", () => { + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; + expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); + + const staleTurn = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); + }); + + it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; + expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); + // Historical shells (e.g. from servers that never carried latestTurn) + // must never read as queued. + expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); + }); + + it("clears once a turn adopts the message or the start fails", () => { + const adopted = { + ...makeShell({ activityAt: QUEUED_AT }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); + + const failed = makeShell({ activityAt: FRESH }); + const failedShell = { + ...failed, + latestUserMessageAt: QUEUED_AT, + session: { + threadId: failed.id, + status: "error" as const, + providerName: "Codex", + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: "boom", + updatedAt: NOW, + }, + }; + expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); + }); + + it("is quiet without user messages", () => { + expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); + }); + + it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { + // Message timestamps originate on other devices; a clock an hour ahead + // must not hold the queued state for the whole skew. + const skewed = { + latestUserMessageAt: "2026-04-09T13:00:00.000Z", + latestTurn: null, + session: null, + }; + expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); + // A small negative age (within the grace window) still reads as queued. + const slightlyAhead = { + latestUserMessageAt: "2026-04-09T12:00:30.000Z", + latestTurn: null, + session: null, + }; + expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); + }); +}); + +describe("canSettle", () => { + it("blocks every state effectiveSettled refuses to classify as settled", () => { + expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); + expect( + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), + ).toBe(false); + expect( + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), + ).toBe(false); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( + false, + ); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( + false, + ); + }); + + it("blocks settling a queued turn start, only within the grace window", () => { + const queued = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: "2026-04-09T12:00:00.000Z", + }; + const justAfter = "2026-04-09T12:00:30.000Z"; + expect(canSettle(queued, { now: justAfter })).toBe(false); + // effectiveSettled must agree: queued work never auto-settles either, + // even with a merged PR. + expect( + effectiveSettled(queued, { + now: justAfter, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + // Past the window the message is a failed/stale start: settleable again. + expect(canSettle(queued, { now: NOW })).toBe(true); + }); + + it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { + // The settle action ran with wall-clock `now` (past the grace window); + // the list partition re-evaluates with a minute-floored `now` that is + // still INSIDE the window. settledAt >= message time proves the server + // already adjudicated this exact message, so the row must not snap back + // to active until the coarser clock catches up. + const messageAt = "2026-04-09T12:00:00.000Z"; + const flooredNow = "2026-04-09T12:01:00.000Z"; + const base = makeShell({ settledOverride: "settled", activityAt: null }); + const settledAfterMessage = { + ...base, + latestUserMessageAt: messageAt, + settledAt: "2026-04-09T12:02:10.000Z", + }; + expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); + expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( + true, + ); + + // A message NEWER than settledAt is genuinely new work: still blocked + // until the server's auto-unsettle lands. + const messageAfterSettle = { + ...base, + latestUserMessageAt: "2026-04-09T12:03:00.000Z", + settledAt: "2026-04-09T12:02:10.000Z", + }; + expect( + effectiveSettled(messageAfterSettle, { + now: "2026-04-09T12:03:30.000Z", + autoSettleAfterDays: 3, + }), + ).toBe(false); + }); + + it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { + // Anything canSettle rejects must render as active even when the user + // settled it earlier. + const blocked = makeShell({ + settledOverride: "settled", + activityAt: FRESH, + pending: "user-input", + }); + expect(canSettle(blocked, { now: NOW })).toBe(false); + expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts new file mode 100644 index 00000000000..3cb4f7e65f7 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -0,0 +1,147 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export type ChangeRequestStateLike = "open" | "closed" | "merged"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { + const candidates = [ + shell.latestUserMessageAt, + shell.latestTurn?.requestedAt, + shell.latestTurn?.startedAt, + shell.latestTurn?.completedAt, + ]; + let latest: string | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + + for (const candidate of candidates) { + if (candidate === null || candidate === undefined) continue; + const timestamp = Date.parse(candidate); + if (timestamp > latestTimestamp) { + latest = candidate; + latestTimestamp = timestamp; + } + } + + return latest; +} + +/** + * A queued turn start lives for at most this long: session adoption takes + * seconds, so a user message still unadopted after the grace window is a + * failed start (or stale data — shells from older servers can carry user + * messages with no latestTurn at all), not pending work. Without this bound + * such threads would be permanently unsettleable. + */ +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * A user message no turn has picked up yet: the turn.start command was + * dispatched (message-sent + turn-start-requested) but no session has + * adopted it, so `session` is still null and the pending work is invisible + * to the session-status checks. Detectable as a user message strictly newer + * than every timestamp on the latest turn — on adoption the new turn's + * requestedAt equals the message time, clearing the condition — and only + * within the adoption grace window. + */ +export function hasQueuedTurnStart( + shell: Pick, + options: { readonly now: string }, +): boolean { + if (shell.latestUserMessageAt == null) return false; + // A failed session start clears the queued state: the failure is already + // visible (status edge / error). + if (shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + const nowMs = Date.parse(options.now); + if (Number.isNaN(nowMs)) return false; + // Bounded on both sides: message timestamps originate on whichever device + // sent the message, so a clock ahead of this one yields a negative age + // that would otherwise hold the queued state for the whole skew. Mirrors + // the decider's guard. + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = shell.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate == null || Date.parse(candidate) < messageAt, + ); +} + +/** + * A thread may be settled only when none of effectiveSettled's activity + * blockers hold. This is deliberately the same list: anything the partition + * refuses to CLASSIFY as settled must also be refused as a settle TARGET. + * The server enforces its own invariants; this client-side twin exists so + * the UI can disable/reject before a round trip. + */ +export function canSettle( + shell: Pick< + OrchestrationThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" + >, + options: { readonly now: string }, +): boolean { + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + // Queued work is as blocked-on-progress as a live session: settling it + // (or auto-settling it on a closed PR) would hide a just-requested turn. + if (hasQueuedTurnStart(shell, options)) return false; + return true; +} + +/** + * Settled resolution over the server-backed settled lifecycle. The explicit + * user override (thread.settle / thread.unsettle commands, projected into + * settledOverride + settledAt) wins in both directions; without one, a + * thread auto-settles on a merged/closed PR or inactivity past the window. + * The server un-settles on real activity (user message, session start, + * approval/user-input request), so an override never goes stale silently. + */ +export function effectiveSettled( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly changeRequestState?: ChangeRequestStateLike | null; + }, +): boolean { + // Blocked work must remain visible even when a user explicitly settled it. + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (hasQueuedTurnStart(shell, { now: options.now })) { + // The queued-turn blocker alone is forgivable: it is clock-derived, and + // list callers pass a coarser `now` than the settle action used. When + // the server already adjudicated the queued message by accepting a + // settle after it (settledAt stamps server accept time), trust that + // ruling — otherwise a settle near the grace boundary leaves the row + // pinned active until the caller's clock ticks over. A message NEWER + // than settledAt is genuinely new work and keeps the block until the + // server's auto-unsettle lands. + const serverAdjudicated = + shell.settledOverride === "settled" && + shell.settledAt !== null && + shell.latestUserMessageAt !== null && + Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt); + if (!serverAdjudicated) return false; + } + if (shell.settledOverride === "settled") return true; + // "active" is the explicit keep-active pin: it suppresses auto-settle + // until real activity clears it server-side. + if (shell.settledOverride === "active") return false; + if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + return true; + } + if (options.autoSettleAfterDays === null) return false; + + const lastActivityAt = threadLastActivityAt(shell); + if (lastActivityAt === null) return false; + + // threadLastActivityAt only returns candidates whose Date.parse beat + // -Infinity, so this parse is a real number; a malformed `now` yields NaN, + // the comparison is false, and the thread stays active (never a surprise + // auto-settle on bad input). + return ( + Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS + ); +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index f3c4c4e6338..8d0e4d0a35c 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -69,6 +69,8 @@ const BASE_THREAD: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc8db25a95a..6fc0c914d8a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -23,6 +23,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.settle / thread.unsettle commands. Absent on + pre-settlement servers, so clients treat missing as unsupported and + never send the commands under version skew. */ + threadSettlement: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29a732ca69b..1ebc23a483b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -15,6 +15,8 @@ import { ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, + OrchestrationThread, + OrchestrationThreadShell, ProjectCreateCommand, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, @@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); function getOptionValue( @@ -344,6 +348,75 @@ it.effect("decodes thread archive and unarchive commands", () => }), ); +it.effect("decodes thread settle and unsettle commands", () => + Effect.gen(function* () { + const settle = yield* decodeOrchestrationCommand({ + type: "thread.settle", + commandId: "cmd-settle-1", + threadId: "thread-1", + }); + const unsettle = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-1", + threadId: "thread-1", + reason: "user", + }); + + assert.strictEqual(settle.type, "thread.settle"); + assert.strictEqual(unsettle.type, "thread.unsettle"); + + // "activity" is server-owned: it exists on the event, never on the + // command, so a client cannot forge the neutral reset. + const forged = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-2", + threadId: "thread-1", + reason: "activity", + }).pipe(Effect.flip); + assert.ok(forged); + }), +); + +it.effect("defaults settled fields when decoding historical thread data", () => + Effect.gen(function* () { + const common = { + id: "thread-1", + projectId: "project-1", + title: "Historical thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: null, + }; + const thread = yield* decodeOrchestrationThread({ + ...common, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + const shell = yield* decodeOrchestrationThreadShell({ + ...common, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }); + + assert.strictEqual(thread.settledOverride, null); + assert.strictEqual(thread.settledAt, null); + assert.strictEqual(shell.settledOverride, null); + assert.strictEqual(shell.settledAt, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ @@ -388,6 +461,48 @@ it.effect("decodes thread archived and unarchived events", () => }), ); +it.effect("decodes thread settled and unsettled events", () => + Effect.gen(function* () { + const settled = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-settle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.settled", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-settle-1", + causationEventId: null, + correlationId: "cmd-settle-1", + metadata: {}, + payload: { + threadId: "thread-1", + settledAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const unsettled = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-unsettle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.unsettled", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-unsettle-1", + causationEventId: null, + correlationId: "cmd-unsettle-1", + metadata: {}, + payload: { + threadId: "thread-1", + reason: "user", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.strictEqual(settled.type, "thread.settled"); + assert.strictEqual(unsettled.type, "thread.unsettled"); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c0c9c62d080..b01df310062 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -558,6 +566,22 @@ const ThreadUnarchiveCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.settle"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnsettleCommand = Schema.Struct({ + type: Schema.Literal("thread.unsettle"), + commandId: CommandId, + threadId: ThreadId, + // Commands only carry "user": activity un-settles are decided server-side + // (the decider emits thread.unsettled(reason: "activity") events directly, + // never through this command), so a client cannot forge the neutral reset. + reason: Schema.Literal("user"), +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -700,6 +724,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -721,6 +747,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -823,6 +851,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.archived", "thread.unarchived", + "thread.settled", + "thread.unsettled", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -902,6 +932,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadSettledPayload = Schema.Struct({ + threadId: ThreadId, + settledAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnsettledPayload = Schema.Struct({ + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1069,6 +1111,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unarchived"), payload: ThreadUnarchivedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.settled"), + payload: ThreadSettledPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unsettled"), + payload: ThreadUnsettledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a79042a2847..001daf1b239 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -4,12 +4,14 @@ import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, + ClientSettingsPatch, DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch, } from "./settings.ts"; const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -31,6 +33,25 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebar v2", () => { + it("defaults the beta off with a three-day auto-settle threshold", () => { + const settings = decodeClientSettings({}); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + }); + + it("allows auto-settle by inactivity to be disabled", () => { + expect( + decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + ).toBeNull(); + }); + + it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { + expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b983aa8a3fa..2f3c1aafe69 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,6 +38,16 @@ export const SidebarThreadPreviewCount = Schema.Int.check( ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; +export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; +export const SidebarAutoSettleAfterDays = Schema.Number.check( + Schema.isBetween({ + minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + }), +); +export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; +export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -72,6 +82,9 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -88,6 +101,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -567,6 +581,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), @@ -574,6 +589,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); From 282ecb317877e6cce296976a03278cc388b7ca37 Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Wed, 22 Jul 2026 06:54:20 -0700 Subject: [PATCH 06/21] fix(settings): validate the add-provider wizard step before advancing (#2813) (#3100) Co-authored-by: Julius Marminge Co-authored-by: codex --- .../AddProviderInstanceDialog.logic.ts | 36 ++++++ .../AddProviderInstanceDialog.test.ts | 44 +++++++ .../settings/AddProviderInstanceDialog.tsx | 115 +++++++----------- .../AddProviderInstanceWizardSteps.test.tsx | 61 ++++++++++ .../AddProviderInstanceWizardSteps.tsx | 70 +++++++++++ 5 files changed, 254 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.test.ts create mode 100644 apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx create mode 100644 apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts new file mode 100644 index 00000000000..fdffa9a190e --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -0,0 +1,36 @@ +export type WizardNavigation = + | { readonly kind: "navigate"; readonly step: number } + | { readonly kind: "blocked"; readonly step: number; readonly error: string }; + +const IDENTITY_STEP = 1; + +export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; + +/** + * Resolve navigation within the add-provider wizard. + * + * Moving forward past Identity requires a valid instance id, whether the user + * advances one step at a time or skips directly to Config from a step header. + * A blocked skip lands on Identity so its existing inline validation is + * visible. Backward navigation is always preserved. + */ +export function resolveWizardNavigation( + currentStep: number, + requestedStep: number, + stepCount: number, + validation: { readonly instanceIdError: string | null }, +): WizardNavigation { + const lastStep = Math.max(0, stepCount - 1); + const targetStep = Math.max(0, Math.min(lastStep, requestedStep)); + const movesForwardPastIdentity = currentStep <= IDENTITY_STEP && targetStep > IDENTITY_STEP; + + if (movesForwardPastIdentity && validation.instanceIdError !== null) { + return { + kind: "blocked", + step: Math.min(IDENTITY_STEP, lastStep), + error: validation.instanceIdError, + }; + } + + return { kind: "navigate", step: targetStep }; +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts new file mode 100644 index 00000000000..594d2e4537e --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic"; + +describe("resolveWizardNavigation", () => { + const invalidId = { instanceIdError: "Instance ID is required." }; + const validId = { instanceIdError: null }; + + it("allows moving from Driver to Identity before the instance id is valid", () => { + expect(resolveWizardNavigation(0, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 }); + }); + + it("blocks Next from Identity to Config while the instance id is invalid", () => { + expect(resolveWizardNavigation(1, 2, 3, invalidId)).toEqual({ + kind: "blocked", + step: 1, + error: "Instance ID is required.", + }); + }); + + it("stops a direct Driver-to-Config skip at Identity and surfaces its error", () => { + expect(resolveWizardNavigation(0, 2, 3, invalidId)).toEqual({ + kind: "blocked", + step: 1, + error: "Instance ID is required.", + }); + }); + + it("allows advancing and skipping forward once the instance id is valid", () => { + expect(resolveWizardNavigation(1, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 }); + expect(resolveWizardNavigation(0, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 }); + }); + + it("always preserves backward Driver and Identity navigation", () => { + expect(resolveWizardNavigation(2, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 }); + expect(resolveWizardNavigation(2, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); + expect(resolveWizardNavigation(1, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); + }); + + it("clamps requested steps to the wizard bounds", () => { + expect(resolveWizardNavigation(2, 8, 3, validId)).toEqual({ kind: "navigate", step: 2 }); + expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 77c1813f110..ed808e30b0c 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -1,8 +1,7 @@ "use client"; -import { CheckIcon } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { ProviderInstanceId, ProviderDriverKind, @@ -29,6 +28,12 @@ import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; import { AnimatedHeight } from "../AnimatedHeight"; +import { + ADD_PROVIDER_WIZARD_STEPS, + resolveWizardNavigation, + type WizardNavigation, +} from "./AddProviderInstanceDialog.logic"; +import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", @@ -143,26 +148,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns const instanceIdError = validateInstanceId(instanceId, existingIds); const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; const previewLabel = label.trim() || `${driverOption.label} Workspace`; - const wizardSteps = ["Driver", "Identity", "Config"] as const; const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; - const setConfigDraft = useCallback( - (config: Record | undefined) => { - setConfigByDriver((existing) => { - const next = { ...existing }; - if (config === undefined || Object.keys(config).length === 0) { - delete next[driver]; - } else { - next[driver] = config; - } - return next; - }); - }, - [driver], - ); + const setConfigDraft = (config: Record | undefined) => { + setConfigByDriver((existing) => { + const next = { ...existing }; + if (config === undefined || Object.keys(config).length === 0) { + delete next[driver]; + } else { + next[driver] = config; + } + return next; + }); + }; + + const applyWizardNavigation = (navigation: WizardNavigation) => { + if (navigation.kind === "blocked") { + setHasAttemptedSubmit(true); + } + setWizardStep(navigation.step); + }; - const handleSave = useCallback(() => { + const navigateToStep = (requestedStep: number) => { + applyWizardNavigation( + resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { + instanceIdError, + }), + ); + }; + + const handleSave = () => { setHasAttemptedSubmit(true); if (instanceIdError !== null) return; @@ -201,18 +217,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns description: error instanceof Error ? error.message : "Update failed.", }); } - }, [ - driver, - driverOption, - configByDriver, - instanceId, - instanceIdError, - label, - accentColor, - onOpenChange, - settings.providerInstances, - updateSettings, - ]); + }; return ( @@ -224,46 +229,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns Configure an additional provider instance — for example, a second Codex install pointed at a different workspace. -
    - {wizardSteps.map((step, index) => ( - - ))} -
    +
    {wizardStep === 0 ? "Cancel" : "Back"} - {wizardStep < wizardSteps.length - 1 ? ( - ) : ( diff --git a/apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx new file mode 100644 index 00000000000..964779f4ec9 --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx @@ -0,0 +1,61 @@ +import { Children, isValidElement, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { ADD_PROVIDER_WIZARD_STEPS } from "./AddProviderInstanceDialog.logic"; +import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; + +interface StepButtonProps { + readonly "aria-current"?: string; + readonly onClick: () => void; +} + +function renderStepButtons( + currentStep: number, + instanceIdError: string | null, + onNavigation: Parameters[0]["onNavigation"], +): ReactElement[] { + const header = AddProviderInstanceWizardSteps({ + currentStep, + summaries: ["Codex", "Codex Workspace", null], + instanceIdError, + onNavigation, + }); + + return Children.toArray(header.props.children).filter( + (child): child is ReactElement => isValidElement(child), + ); +} + +describe("AddProviderInstanceWizardSteps", () => { + it("gates the actual Config header click through Identity validation", () => { + const onNavigation = vi.fn(); + const buttons = renderStepButtons(0, "Instance ID is required.", onNavigation); + + expect(buttons).toHaveLength(ADD_PROVIDER_WIZARD_STEPS.length); + buttons[2]!.props.onClick(); + + expect(onNavigation).toHaveBeenCalledOnce(); + expect(onNavigation).toHaveBeenCalledWith({ + kind: "blocked", + step: 1, + error: "Instance ID is required.", + }); + }); + + it("marks the wizard step separately from the clicked button focus", () => { + const buttons = renderStepButtons(1, "Instance ID is required.", vi.fn()); + + expect(buttons[0]!.props["aria-current"]).toBeUndefined(); + expect(buttons[1]!.props["aria-current"]).toBe("step"); + expect(buttons[2]!.props["aria-current"]).toBeUndefined(); + }); + + it("preserves the actual backward header click", () => { + const onNavigation = vi.fn(); + const buttons = renderStepButtons(2, "Instance ID is required.", onNavigation); + + buttons[0]!.props.onClick(); + + expect(onNavigation).toHaveBeenCalledWith({ kind: "navigate", step: 0 }); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx new file mode 100644 index 00000000000..4747557471d --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx @@ -0,0 +1,70 @@ +import { CheckIcon } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { + ADD_PROVIDER_WIZARD_STEPS, + resolveWizardNavigation, + type WizardNavigation, +} from "./AddProviderInstanceDialog.logic"; + +interface AddProviderInstanceWizardStepsProps { + readonly currentStep: number; + readonly summaries: readonly (string | null)[]; + readonly instanceIdError: string | null; + readonly onNavigation: (navigation: WizardNavigation) => void; +} + +export function AddProviderInstanceWizardSteps({ + currentStep, + summaries, + instanceIdError, + onNavigation, +}: AddProviderInstanceWizardStepsProps) { + return ( +
    + {ADD_PROVIDER_WIZARD_STEPS.map((step, index) => ( + + ))} +
    + ); +} From aa5ec8036451a8dcf16d93294c69f524907c42c8 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms Date: Wed, 22 Jul 2026 08:54:30 -0500 Subject: [PATCH 07/21] fix(claude): isolate capability probe from user MCP servers (#4015) Co-authored-by: codex --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 138 ++++++++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 54 +++++-- 2 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts new file mode 100644 index 00000000000..ab6e5992990 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -0,0 +1,138 @@ +import { ClaudeSettings } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildClaudeCapabilitiesProbeQueryOptions, + CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + probeClaudeCapabilities, +} from "./ClaudeProvider.ts"; + +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +it("isolates Claude capability probes without dropping workspace setting sources", () => { + const abortController = new AbortController(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath: "/usr/bin/claude", + abortController, + environment: { + HOME: "/home/user", + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + cwd: "/workspace/project", + }); + + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.strictMcpConfig, true); + assert.equal(options.cwd, "/workspace/project"); + assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); + assert.deepEqual(options.allowedTools, []); + assert.equal(options.persistSession, false); + assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); + assert.equal(options.abortController, abortController); + assert.equal(options.env?.HOME, "/home/user"); + assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); +}); + +it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { + it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const invocationPath = path.join(tempDir, "invocation.json"); + const workspaceCwd = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspaceCwd, { recursive: true }); + + yield* fs.writeFileString( + executablePath, + [ + "#!/usr/bin/env node", + 'import { existsSync, readFileSync, writeFileSync } from "node:fs";', + 'import { createInterface } from "node:readline";', + "const args = process.argv.slice(2);", + 'const mcpConfigIndex = args.indexOf("--mcp-config");', + "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;", + "let mcpConfig;", + "if (rawMcpConfig) {", + ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;', + " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }", + "}", + "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", + " args,", + " cwd: process.cwd(),", + " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", + " mcpConfig,", + "}));", + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fs.chmod(executablePath, 0o755); + + const capabilities = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { + ...process.env, + T3_PROBE_INVOCATION_PATH: invocationPath, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + workspaceCwd, + ); + + assert.deepEqual(capabilities, { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + apiProvider: undefined, + slashCommands: [ + { + name: "review", + description: "Review changes", + input: { hint: "[path]" }, + }, + ], + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly connectorEnv: string; + readonly mcpConfig: unknown; + }; + assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); + assert.equal(invocation.connectorEnv, "false"); + assert.equal(invocation.args.includes("--strict-mcp-config"), true); + assert.equal(invocation.args.includes("--mcp-config"), false); + assert.equal(invocation.mcpConfig, undefined); + + assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9df672f54b0..4920f96465a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -21,8 +21,10 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, + type SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -508,6 +510,44 @@ function apiProviderAuthMetadata( // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +/** + * Keep workspace-scoped command discovery intact while isolating the periodic + * health check from configured MCP servers. + */ +export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** Build the exact SDK options used by the periodic Claude capability probe. */ +export function buildClaudeCapabilitiesProbeQueryOptions(input: { + readonly executablePath: string; + readonly abortController: AbortController; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; +}): ClaudeQueryOptions { + return { + persistSession: false, + pathToClaudeCodeExecutable: input.executablePath, + abortController: input.abortController, + settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], + allowedTools: [], + // Ignore MCP definitions from every filesystem setting source above. The + // SDK combines this empty explicit map with --strict-mcp-config. + mcpServers: {}, + strictMcpConfig: true, + env: { + ...input.environment, + // Connected claude.ai MCP servers are discovered outside filesystem + // config; disable them independently for this health check. + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + ...(input.cwd ? { cwd: input.cwd } : {}), + stderr: () => {}, + }; +} + function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); return candidate ? candidate : undefined; @@ -631,16 +671,12 @@ const probeClaudeCapabilities = ( prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), - options: { - persistSession: false, - pathToClaudeCodeExecutable: executablePath, + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, abortController: abort, - settingSources: ["user", "project", "local"], - allowedTools: [], - env: claudeEnvironment, - ...(cwd ? { cwd } : {}), - stderr: () => {}, - }, + environment: claudeEnvironment, + cwd, + }), }); const init = await q.initializationResult(); const account = init.account as From 783692afc10dd63693b3f65ca334322762293e67 Mon Sep 17 00:00:00 2001 From: Ishan Date: Wed, 22 Jul 2026 19:24:55 +0530 Subject: [PATCH 08/21] Preserve connecting status while a turn starts (#4101) Co-authored-by: codex --- .../Layers/ProjectionPipeline.test.ts | 68 +++++++ .../Layers/ProjectionPipeline.ts | 9 + .../Layers/ProviderCommandReactor.test.ts | 125 +++++++++++- .../Layers/ProviderCommandReactor.ts | 44 ++++- .../Layers/ProviderRuntimeIngestion.test.ts | 180 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 26 ++- .../src/state/threadSettled.test.ts | 45 +++++ 7 files changed, 475 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index a9d7317999a..926182a3ef0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2464,6 +2464,74 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ); }); +it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test-"))( + "OrchestrationProjectionPipeline pending turn cleanup", + (it) => { + it.effect("clears pending turn starts when startup reaches a terminal session state", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + + for (const [index, status] of (["error", "interrupted", "stopped"] as const).entries()) { + const threadId = ThreadId.make(`thread-terminal-${status}`); + const requestedAt = `2026-02-26T14:00:0${index}.000Z`; + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-terminal-pending-${status}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: requestedAt, + commandId: CommandId.make(`cmd-terminal-pending-${status}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-terminal-pending-${status}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-terminal-${status}`), + runtimeMode: "approval-required", + createdAt: requestedAt, + }, + }); + yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make(`evt-terminal-session-${status}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: requestedAt, + commandId: CommandId.make(`cmd-terminal-session-${status}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-terminal-session-${status}`), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status, + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: status === "error" ? "startup failed" : null, + updatedAt: requestedAt, + }, + }, + }); + } + + yield* projectionPipeline.bootstrap; + + const pendingRows = yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE turn_id IS NULL + AND state = 'pending' + `; + assert.deepEqual(pendingRows, []); + }), + ); + }, +); + it.effect("restores pending turn-start metadata across projection pipeline restart", () => Effect.gen(function* () { const { dbPath } = yield* ServerConfig; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3ceae0ea43b..cfb88a06cd2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1059,6 +1059,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { + if ( + event.payload.session.status === "error" || + event.payload.session.status === "stopped" || + event.payload.session.status === "interrupted" + ) { + yield* projectionTurnRepository.deletePendingTurnStartByThreadId({ + threadId: event.payload.threadId, + }); + } // Leaving the "running" session status is the turn-end signal: // settle still-running turns so their duration reflects the whole // turn rather than the last assistant message. diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..c49646b7a4b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -22,6 +22,7 @@ import { TurnId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -145,6 +146,9 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly startSessionEffect?: ( + session: ProviderSession, + ) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -159,6 +163,7 @@ describe("ProviderCommandReactor", () => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }; + const startSessionEffect = input?.startSessionEffect; const startSession = vi.fn((_: unknown, input: unknown) => { const sessionIndex = nextSessionIndex++; const resumeCursor = @@ -212,8 +217,13 @@ describe("ProviderCommandReactor", () => { createdAt: now, updatedAt: now, }; - runtimeSessions.push(session); - return Effect.succeed(session); + return (startSessionEffect?.(session) ?? Effect.succeed(session)).pipe( + Effect.tap((startedSession) => + Effect.sync(() => { + runtimeSessions.push(startedSession); + }), + ), + ); }); const sendTurn = vi.fn((_: unknown) => Effect.succeed({ @@ -463,9 +473,120 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.status).toBe("starting"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect("projects starting before a slow provider session finishes", () => + Effect.gen(function* () { + const releaseStart = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-slow-provider"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-slow-provider"), + role: "user", + text: "start slowly", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); + const duringStartup = yield* Effect.promise(() => harness.readModel()); + expect( + duringStartup.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status, + ).toBe("starting"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + yield* Deferred.succeed(releaseStart, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + }), + ); + + effectIt.effect("settles a failed provider startup and allows a clean retry", () => + Effect.gen(function* () { + let failStartup = true; + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + failStartup + ? Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.start", + detail: "deterministic startup failure", + }), + ) + : Effect.succeed(session), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-failure"), + role: "user", + text: "fail once", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status === "error" + ); + }), + ); + let readModel = yield* Effect.promise(() => harness.readModel()); + let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.lastError).toContain("deterministic startup failure"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + failStartup = false; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-retry"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-retry"), + role: "user", + text: "retry", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + readModel = yield* Effect.promise(() => harness.readModel()); + thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.lastError).toBeNull(); + }), + ); + it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..b6bff8c766a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -288,15 +288,20 @@ const make = Effect.gen(function* () { readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); - const session = thread?.session; - if (!session) { + if (!thread) { return; } + const session = thread.session; yield* setThreadSession({ threadId: input.threadId, session: { - ...session, - status: session.status === "stopped" ? "stopped" : "ready", + ...(session ?? { + threadId: input.threadId, + providerName: null, + providerInstanceId: thread.modelSelection.instanceId, + runtimeMode: thread.runtimeMode, + }), + status: session?.status === "stopped" ? "stopped" : "error", activeTurnId: null, lastError: input.detail, updatedAt: input.createdAt, @@ -354,6 +359,7 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; + readonly pendingTurnStart?: boolean; }, ) { const thread = yield* resolveThread(threadId); @@ -428,6 +434,22 @@ const make = Effect.gen(function* () { }); } const preferredProvider: ProviderDriverKind = desiredDriverKind; + if (options?.pendingTurnStart === true && thread.session?.status !== "running") { + yield* setThreadSession({ + threadId, + session: { + threadId, + status: "starting", + providerName: activeSession?.provider ?? preferredProvider, + providerInstanceId: activeSession?.providerInstanceId ?? desiredInstanceId, + runtimeMode: desiredRuntimeMode, + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + } if (thread.session !== null) { yield* rejectStartedThreadModelChangeIfRequired({ threadId, @@ -498,7 +520,10 @@ const make = Effect.gen(function* () { threadId, session: { threadId, - status: mapProviderSessionStatusToOrchestrationStatus(session.status), + status: + options?.pendingTurnStart === true && session.status === "ready" + ? "starting" + : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, providerInstanceId: session.providerInstanceId, runtimeMode: desiredRuntimeMode, @@ -597,11 +622,10 @@ const make = Effect.gen(function* () { new Error(`Thread '${input.threadId}' was not found in read model.`), ); } - yield* ensureSessionForThread( - input.threadId, - input.createdAt, - input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, - ); + yield* ensureSessionForThread(input.threadId, input.createdAt, { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + pendingTurnStart: true, + }); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 20611e1ee75..74ece50cd31 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -30,6 +30,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import { it as effectIt } from "@effect/vitest"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -493,6 +494,185 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBeNull(); }); + effectIt.effect( + "keeps a reconnecting pending turn starting while ready clears stale active state", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const staleTurnId = asTurnId("turn-stale-before-reconnect"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-pending-reconnect"), + threadId, + message: { + messageId: MessageId.make("message-pending-reconnect"), + role: "user", + text: "resume after reconnect", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-starting-pending-reconnect"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: staleTurnId, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-ready-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + payload: { state: "ready" }, + }); + + let thread = yield* Effect.promise(() => + waitForThread( + harness.readModel, + (entry) => entry.session?.status === "starting" && entry.session.activeTurnId === null, + ), + ); + expect(thread.session?.status).toBe("starting"); + expect(thread.session?.activeTurnId).toBeNull(); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:03.000Z", + }); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + )!; + expect(thread.session?.status).toBe("starting"); + expect(thread.session?.activeTurnId).toBeNull(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + turnId: asTurnId("turn-after-reconnect"), + createdAt: "2026-01-01T00:00:04.000Z", + }); + thread = yield* Effect.promise(() => + waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "running" && + entry.session.activeTurnId === asTurnId("turn-after-reconnect"), + ), + ); + expect(thread.session?.status).toBe("running"); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started-duplicate-midturn"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:05.000Z", + }); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + )!; + expect(thread.session?.status).toBe("running"); + expect(thread.session?.activeTurnId).toBe(asTurnId("turn-after-reconnect")); + }), + ); + + effectIt.effect("keeps an aborted pending start stopped across duplicate exit events", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const stoppedAt = "2026-01-01T00:00:02.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-stop"), + threadId, + message: { + messageId: MessageId.make("message-before-stop"), + role: "user", + text: "stop this startup", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-starting-before-stop"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-stop-pending-start"), + threadId, + session: { + threadId, + status: "stopped", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: stoppedAt, + }, + createdAt: stoppedAt, + }); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-session-exited-after-stop"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:03.000Z", + }); + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-duplicate-session-exited-after-stop"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:04.000Z", + }); + + yield* Effect.promise(() => harness.drain()); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.activeTurnId).toBeNull(); + }), + ); + it("does not clear active turn when session/thread started arrives mid-turn", 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 09566feb2b2..a8a51b30260 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1308,6 +1308,11 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }); + const hasPendingTurnStart = + Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); @@ -1322,11 +1327,7 @@ const make = Effect.gen(function* () { const conflictingTurnStartIsPendingTurnStart = event.type === "turn.started" && conflictsWithActiveTurn ? sameId(yield* getExpectedProviderTurnIdForThread(thread.id), eventTurnId) && - Option.isSome( - yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }), - ) + Option.isSome(pendingTurnStart) : false; const shouldApplyThreadLifecycle = (() => { @@ -1370,8 +1371,10 @@ const make = Effect.gen(function* () { ) { const status = (() => { switch (event.type) { - case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "session.state.changed": { + const runtimeStatus = orchestrationSessionStatusFromRuntimeState(event.payload.state); + return hasPendingTurnStart && runtimeStatus === "ready" ? "starting" : runtimeStatus; + } case "turn.started": return "running"; case "session.exited": @@ -1383,8 +1386,8 @@ const make = Effect.gen(function* () { case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an - // active turn; preserve turn-running state in that case. - return activeTurnId !== null ? "running" : "ready"; + // active or pending turn; preserve that lifecycle state. + return activeTurnId !== null ? "running" : hasPendingTurnStart ? "starting" : "ready"; } })(); const nextActiveTurnId = @@ -1392,7 +1395,10 @@ const make = Effect.gen(function* () { ? (eventTurnId ?? null) : event.type === "turn.completed" || event.type === "session.exited" ? null - : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn(status) + : event.type === "session.state.changed" && + !sessionStatusAllowsActiveTurn( + orchestrationSessionStatusFromRuntimeState(event.payload.state), + ) ? null : activeTurnId; const lastError = diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 50f3c2d3912..59f9f1d6f55 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -178,6 +178,51 @@ describe("effectiveSettled", () => { ).toBe(false); }); + it("keeps a new turn active from queued through starting and running", () => { + const requestedAt = "2026-04-09T12:00:00.000Z"; + const transitionNow = "2026-04-09T12:00:30.000Z"; + const base = makeShell({ + settledOverride: null, + activityAt: STALE, + }); + const queued: OrchestrationThreadShell = { + ...base, + latestUserMessageAt: requestedAt, + latestTurn: null, + session: null, + }; + const starting: OrchestrationThreadShell = { + ...queued, + session: { + threadId: queued.id, + status: "starting", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: requestedAt, + }, + }; + const running: OrchestrationThreadShell = { + ...starting, + session: { + ...starting.session!, + status: "running", + activeTurnId: TurnId.make("turn-new"), + }, + }; + + for (const shell of [queued, starting, running]) { + expect( + effectiveSettled(shell, { + now: transitionNow, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + } + }); + it("uses a strict inactivity boundary and honors a null threshold", () => { const boundary = makeShell({ activityAt: "2026-04-07T00:00:00.000Z", From 4e09cddb40eb1bb1e111a0374b46e73b38ffbb29 Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:57:29 +0800 Subject: [PATCH 09/21] fix(server): stop restoring stale OpenCode models (#4095) Co-authored-by: codex --- .../provider/Layers/OpenCodeProvider.test.ts | 19 +- .../provider/Layers/ProviderRegistry.test.ts | 308 ++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 28 +- apps/server/src/provider/opencodeRuntime.ts | 38 ++- 4 files changed, 372 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 05160517bfe..41454b48b31 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -97,10 +97,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), loadInventoryFromCli: () => runtimeMock.state.inventoryError - ? Effect.succeed({ - providerList: { all: [], default: {}, connected: [] as string[] }, - agents: [], - } as OpenCodeInventory) + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; @@ -212,14 +215,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("degrades gracefully on CLI failure for local installs", () => + it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: opencode models failed", + ); }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 159d853121c..5efbb6f1c14 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops stale OpenCode models missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains stale OpenCode models when a refresh fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode provider status has not been checked in this session yet.", + } satisfies ServerProvider; + const loggedOutProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "OpenCode is available, but it did not report any connected upstream providers.", + } satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:03:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const authoritativeProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:04:00.000Z", + models: [previousProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:05:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ + ...previousProvider.models, + ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).models, + [], + ); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); + + const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); + const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); + + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -866,6 +1036,144 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect( + "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", + () => + Effect.gen(function* () { + const openCodeDriver = ProviderDriverKind.make("opencode"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const initialProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const authoritativeProvider = { + ...initialProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [initialProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(authoritativeProvider), + streamChanges: Stream.fromPubSub(changes), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-opencode-authoritative-persist-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: openCodeInstanceId, + }); + + yield* PubSub.publish(changes, authoritativeProvider); + + let cachedProvider = yield* readProviderStatusCache(filePath); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + + yield* PubSub.publish(changes, failedProvider); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + authoritativeProvider.models[0]!, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("returns the cached provider list when a manual refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..760c8e1c59e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,11 +78,31 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { + if (provider.driver !== ProviderDriverKind.make("opencode")) { + return true; + } + + // OpenCode's initial snapshot is deliberately non-authoritative while its + // first probe is still running. A probe error from an installed CLI/server + // is likewise partial: it could not establish the current inventory. + // Conversely, disabled and missing-CLI snapshots are authoritative removals, + // as are successful ready/warning inventories (including an empty one after + // logout or plugin removal). + const isPendingInitialProbe = + provider.enabled && !provider.installed && provider.status === "warning"; + const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; + return isPendingInitialProbe || didInstalledProviderProbeFail; +}; + const mergeProviderModels = ( + provider: ServerProvider, previousModels: ReadonlyArray, nextModels: ReadonlyArray, ): ReadonlyArray => { - if (nextModels.length === 0 && previousModels.length > 0) { + const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); + + if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) { return previousModels; } @@ -98,7 +118,9 @@ const mergeProviderModels = ( }; }); const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; + return shouldRetainMissingModels + ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))] + : mergedModels; }; export const mergeProviderSnapshot = ( @@ -109,7 +131,7 @@ export const mergeProviderSnapshot = ( ? nextProvider : { ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), + models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), }; export const mergeProviderSnapshots = ( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index b853662b037..63fcea22d19 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -690,22 +690,36 @@ const makeOpenCodeRuntime = Effect.gen(function* () { agentsResult = a2; } - // Degrade gracefully on failure — return empty inventory (warning status, not error) - let connected: string[] = []; - let allProviders: ProviderListResponse["all"] = []; - if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - connected = [...parsed.connected]; - allProviders = [...parsed.providers.values()].map((p) => ({ - id: p.id, - name: p.name, + if (modelsResult._tag === "Failure") { + const cause = Cause.squash(modelsResult.cause); + return yield* ensureRuntimeError( + "loadInventoryFromCli", + `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ); + } + if (modelsResult.value.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + }); + } + + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const connected = [...parsed.connected]; + const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( + (provider) => ({ + id: provider.id, + name: provider.name, source: "config" as const, env: [], options: {}, - models: p.models, - })); - } + models: provider.models, + }), + ); + // Agent metadata enriches model capabilities but is not required for an + // authoritative model inventory, so it may still degrade to an empty list. let agents: ReadonlyArray = []; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { agents = parseAgentListCliOutput(agentsResult.value.stdout); From c7b21ff172f8e64318ec681e8839ee60ca660fa4 Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Thu, 23 Jul 2026 01:57:39 +1200 Subject: [PATCH 10/21] [codex] keep scoped package references as text (#4167) Co-authored-by: codex Co-authored-by: Julius Marminge --- .../components/ComposerPromptEditor.test.ts | 79 +++++++++++++++++++ apps/web/src/composer-editor-mentions.test.ts | 29 ++++++- apps/web/src/composer-logic.test.ts | 13 ++- .../shared/src/composerInlineTokens.test.ts | 48 +++++++++++ packages/shared/src/composerInlineTokens.ts | 5 +- 5 files changed, 169 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 45a2db4731c..0aab8fb0c2d 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => { " ", ); }); + + it.each([ + "yarn expo install @expo/ui", + "npm install @jane/foo.js", + "import '@scope/pkg/sub/path'", + ])("leaves scoped package command %s to the plain-text paste fallback", (command) => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const plainTextFallback = vi.fn((event: ClipboardEvent) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + selection.insertText(event.clipboardData?.getData("text/plain") ?? ""); + return true; + }); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent(command); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(plainTextFallback).toHaveBeenCalledOnce(); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command); + }); + + it("pastes a canonical scoped folder link as a mention", () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const mention = "[sub](@scope/pkg/sub)"; + const plainTextFallback = vi.fn(() => true); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent(mention); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(plainTextFallback).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + " ", + ); + }); }); diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index d79170769d5..70c97400370 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => { }); it("keeps newlines around mention tokens", () => { - expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([ + expect(splitPromptIntoComposerSegments("one\n@AGENTS.md \ntwo")).toEqual([ { type: "text", text: "one\n" }, - { type: "mention", path: "src/index.ts", source: "@src/index.ts" }, + { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, { type: "text", text: " \ntwo" }, ]); }); @@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => { ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]); }); + it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( + "does not turn scoped package reference %s into file mention segments", + (reference) => { + const prompt = `Install ${reference} next`; + expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); + }, + ); + + it("keeps IME-composed text containing a scoped package reference as text", () => { + const prompt = "入力 @expo/ui を追加"; + expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); + }); + + it("turns canonical scoped folder links into file mention segments", () => { + expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([ + { type: "text", text: "Inspect " }, + { + type: "mention", + path: "@scope/pkg/sub", + source: "[sub](@scope/pkg/sub)", + }, + { type: "text", text: " next" }, + ]); + }); + it("decodes reserved path characters from generated links", () => { expect( splitPromptIntoComposerSegments( diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 99ac6bba716..b8ef7443611 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => { ); }); - it("keeps replacement cursors aligned when another mention already exists earlier", () => { + it("keeps package-like text expanded when another mention already exists earlier", () => { const text = "open @AGENTS.md then @src/index.ts "; const expandedCursor = text.length; const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); - expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); + expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length); + expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); + }); + + it("collapses only genuine mentions when package-like text exists earlier", () => { + const text = "install @scope/pkg then @README.md "; + const expandedCursor = text.length; + const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); + + expect(collapsedCursor).toBe("install @scope/pkg then ".length + 1 + " ".length); expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); }); diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index f99d0b6654e..5a7c14f1725 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => { it("ignores normal web links", () => { expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); }); + + it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( + "keeps scoped package reference %s as plain text", + (reference) => { + expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]); + }, + ); + + it("keeps scoped package references plain across incomplete input and IME whitespace", () => { + expect(collectComposerInlineTokens("Install @expo/ui")).toEqual([]); + expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]); + }); + + it("keeps bare non-scoped file paths as mentions", () => { + expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([ + { + type: "mention", + value: "README.md", + source: "@README.md", + start: 8, + end: 18, + }, + ]); + }); + + it("keeps canonical file links for scoped paths as mentions", () => { + expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([ + { + type: "mention", + value: "@scope/pkg/sub", + source: "[sub](@scope/pkg/sub)", + start: 8, + end: 29, + }, + ]); + }); + + it("allows ambiguous scoped paths through explicit quoted mentions", () => { + expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([ + { + type: "mention", + value: "expo/ui", + source: '@"expo/ui"', + start: 8, + end: 18, + }, + ]); + }); }); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index aa5e67d6fc8..dda548059df 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; +// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. +const SCOPED_PACKAGE_REFERENCE_REGEX = + /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; function collectMentionTokens(text: string): ComposerInlineToken[] { const matches: ComposerInlineToken[] = []; @@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { const prefix = match[1] ?? ""; const quotedPath = match[2]; const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); - if (!path) { + if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) { continue; } const start = (match.index ?? 0) + prefix.length; From b6e1b39335ffd34583c94f5b1e5e2c80520729fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 22 Jul 2026 15:58:27 +0200 Subject: [PATCH 11/21] fix(web): default provider selection for users without Codex (#4117) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/ChatView.tsx | 14 +- apps/web/src/components/CommandPalette.tsx | 21 +- apps/web/src/components/chat/ChatComposer.tsx | 157 +++++++---- apps/web/src/providerInstances.test.ts | 258 +++++++++++++++++- apps/web/src/providerInstances.ts | 93 ++++++- .../src/operations/projects.test.ts | 6 +- .../client-runtime/src/operations/projects.ts | 6 +- 7 files changed, 458 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f3145df4500..04bd3a97c05 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -152,6 +152,7 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -1361,10 +1362,7 @@ function ChatViewContent(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], @@ -1856,7 +1854,7 @@ function ChatViewContent(props: ChatViewProps) { const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), + selectedProviderByThreadId ?? threadProvider, ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); @@ -4038,7 +4036,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) return; + if (!sendCtx?.providerAvailable) return; const { images: composerImages, terminalContexts: composerTerminalContexts, @@ -4614,7 +4612,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { @@ -4773,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..9d824e0f72d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,12 +7,10 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { - DEFAULT_MODEL, type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, type ProjectId, - ProviderInstanceId, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, @@ -111,7 +109,8 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { resolveDefaultProviderModelSelection } from "../providerInstances"; import { resolveShortcutCommand } from "../keybindings"; import { Command, @@ -476,6 +475,7 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -1149,6 +1149,10 @@ function OpenCommandPaletteDialog(props: { } const projectId = newProjectId(); + const targetEnvironmentProviders = + environments.find((environment) => environment.environmentId === input.environmentId) + ?.serverConfig?.providers ?? + (input.environmentId === primaryEnvironmentId ? providers : []); const createResult = await createProject({ environmentId: input.environmentId, input: { @@ -1156,10 +1160,10 @@ function OpenCommandPaletteDialog(props: { title: inferProjectTitleFromPath(cwd), workspaceRoot: cwd, createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + defaultModelSelection: resolveDefaultProviderModelSelection( + targetEnvironmentProviders, + null, + ), }, }); if (createResult._tag === "Failure") { @@ -1195,8 +1199,11 @@ function OpenCommandPaletteDialog(props: { [ handleNewThread, createProject, + environments, navigate, + primaryEnvironmentId, projects, + providers, setOpen, clientSettings.sidebarThreadSortOrder, threads, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 94e4af3bba6..591a07ac4c5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -114,7 +114,9 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, resolveProviderDriverKindForInstanceSelection, + resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -433,6 +435,7 @@ export interface ChatComposerHandle { selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; + providerAvailable: boolean; selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; @@ -696,8 +699,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerInstanceEntries, providerStatuses, explicitSelectedInstanceId, - ) ?? ProviderDriverKind.make("codex"); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + ) ?? + providerInstanceEntries[0]?.driverKind ?? + ProviderDriverKind.make("unconfigured"); + const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; const lockedInstanceId = @@ -734,7 +739,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const candidate of candidates) { if (!candidate) continue; const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, + (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, ); if (match) { // When locked to a specific driver kind, ignore persisted instance @@ -749,36 +754,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return match.instanceId; } } - if (explicitSelectedInstanceId) { - return ProviderInstanceId.make(explicitSelectedInstanceId); - } - const byKind = providerInstanceEntries.find( + const compatibleEntries = providerInstanceEntries.filter( (entry) => - entry.enabled && - entry.driverKind === selectedProvider && + (!lockedProvider || entry.driverKind === lockedProvider) && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); - if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + const requestedDriverEntries = compatibleEntries.filter( + (entry) => entry.driverKind === requestedDriverKind, + ); return ( - anyEnabled?.instanceId ?? - providerInstanceEntries[0]?.instanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - ProviderInstanceId.make("codex") + resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? + NO_PROVIDER_MODEL_SELECTION.instanceId ); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, - selectedProvider, + requestedDriverKind, ]); + // Resolve the active instance's snapshot by `instanceId` so a custom + // instance gets its own slash commands, skills, and model list — not + // the first snapshot for the same driver kind. + const selectedProviderEntry = useMemo( + () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), + [providerInstanceEntries, selectedInstanceId], + ); + const noProviderAvailable = selectedProviderEntry === undefined; + // The driver kind follows the instance that will actually run the turn, + // which can differ from the persisted selection when that selection is + // disabled. + const selectedProvider: ProviderDriverKind = + selectedProviderEntry?.driverKind ?? requestedDriverKind; + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, providers: providerStatuses, @@ -788,14 +801,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectModelSelection: activeProjectDefaultModelSelection, settings, }); - - // Resolve the active instance's snapshot by `instanceId` so a custom - // instance gets its own slash commands, skills, and model list — not - // the first snapshot for the same driver kind. - const selectedProviderEntry = useMemo( - () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), - [providerInstanceEntries, selectedInstanceId], - ); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], @@ -1158,6 +1163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) phase === "running" || isSendBusy || isConnecting || + noProviderAvailable || projectSelectionRequired || environmentUnavailable !== null || !composerSendState.hasSendableContent; @@ -1698,7 +1704,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || environmentUnavailable !== null || phase === "running") { + if ( + isSendBusy || + isConnecting || + noProviderAvailable || + environmentUnavailable !== null || + phase === "running" + ) { return false; } if (activePendingProgress) { @@ -1713,18 +1725,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isMobileViewport, isSendBusy, + noProviderAvailable, phase, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (noProviderAvailable) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2083,6 +2100,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, + providerAvailable: !noProviderAvailable, selectedProvider, selectedModel, selectedProviderModels, @@ -2110,6 +2128,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, + noProviderAvailable, selectedPromptEffort, selectedProvider, selectedProviderModels, @@ -2260,7 +2279,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || projectSelectionRequired + environmentUnavailable !== null || + noProviderAvailable || + projectSelectionRequired } isPreparingWorktree={false} hasSendableContent={false} @@ -2292,7 +2313,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {activePendingProgress ? activePendingProgress.customAnswer || "Type your own answer, or leave this blank to use the selected option" - : prompt.trim() || "Ask anything..."} + : prompt.trim() || + (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} + ) : ( + { + setIsComposerModelPickerOpen(open); + }} + getModelDisabledReason={getModelDisabledReason} + onInstanceModelChange={onProviderModelSelect} + /> + )} {isComposerFooterCompact ? ( ({ + slug, + name: slug, + isCustom, + ...(isDefault ? { isDefault: true } : {}), + capabilities: {}, +}); + describe("isProviderInstancePickerReady", () => { it("rejects a disabled instance even while its last probe status is ready", () => { const [entry] = deriveProviderInstanceEntries([ @@ -146,6 +158,67 @@ describe("resolveSelectableProviderInstance", () => { expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback); }); + it("prefers a ready instance over an enabled one whose driver cannot start", () => { + const notInstalled = ProviderInstanceId.make("codex"); + const ready = ProviderInstanceId.make("claudeAgent"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: notInstalled, + status: "error", + }), + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: ready }), + ]; + + expect(resolveSelectableProviderInstance(providers, undefined)).toBe(ready); + }); + + it("prefers an unprobed (warning) instance over one whose probe errored", () => { + const notInstalled = ProviderInstanceId.make("codex"); + const unprobed = ProviderInstanceId.make("claudeAgent"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: notInstalled, + status: "error", + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: unprobed, + status: "warning", + }), + ]; + + expect(resolveSelectableProviderInstance(providers, undefined)).toBe(unprobed); + }); + + it("keeps a requested instance even when its probe errored", () => { + const requested = ProviderInstanceId.make("codex"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: requested, + status: "error", + }), + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + ]; + + expect(resolveSelectableProviderInstance(providers, requested)).toBe(requested); + }); + + it("does not invent an errored instance as a new-user default", () => { + const notInstalled = ProviderInstanceId.make("codex"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: notInstalled, + status: "error", + }), + ]; + + expect(resolveSelectableProviderInstance(providers, undefined)).toBeUndefined(); + }); + it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { const disabled = ProviderInstanceId.make("codex"); const unavailable = ProviderInstanceId.make("claudeAgent"); @@ -206,3 +279,184 @@ describe("resolveProviderDriverKindForInstanceSelection", () => { ).toBeUndefined(); }); }); + +describe("getDefaultProviderInstanceModel", () => { + it("uses the instance's own models, not the default instance of the kind", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claude_openrouter", + models: [model("openai/gpt-5.5", true), model("claude-opus-4-8")], + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-sonnet-5")], + }), + ]; + + expect( + getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claude_openrouter")), + ).toBe("claude-opus-4-8"); + }); + + it("falls back to the driver default when the instance reports no models", () => { + const providers = [ + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + ]; + + const resolved = getDefaultProviderInstanceModel( + providers, + ProviderInstanceId.make("claudeAgent"), + ); + expect(typeof resolved).toBe("string"); + expect(resolved?.length).toBeGreaterThan(0); + }); + + it("honors the instance's declared default before model-list order", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-sonnet-5"), model("claude-opus-4-8", false, true)], + }), + ]; + + expect(getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claudeAgent"))).toBe( + "claude-opus-4-8", + ); + }); + + it("returns undefined for an unknown instance", () => { + expect( + getDefaultProviderInstanceModel([], ProviderInstanceId.make("removed_instance")), + ).toBeUndefined(); + }); +}); + +describe("resolveDefaultProviderModelSelection", () => { + it.each([ + ["codex", "codex", "gpt-5.6"], + ["claudeAgent", "claudeAgent", "claude-fable-5"], + ["cursor", "cursor", "composer-2"], + ])("uses the only available %s instance", (driver, instanceId, modelSlug) => { + const providers = [ + provider({ + provider: ProviderDriverKind.make(driver), + instanceId, + models: [model(modelSlug, false, true)], + }), + ]; + + expect(resolveDefaultProviderModelSelection(providers, null)).toEqual({ + instanceId, + model: modelSlug, + }); + }); + + it("preserves a valid stored selection including its options", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8")], + }), + ]; + const stored = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "custom-model", + options: [{ id: "effort", value: "high" }], + }; + + expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); + }); + + it("replaces a stale stored instance with the first ready instance and its model", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + status: "warning", + models: [model("gpt-5.6")], + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("removed-provider"), + model: "stale-model", + }), + ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); + }); + + it.each([{ enabled: false }, { availability: "unavailable" as const }])( + "replaces an unavailable stored instance deterministically", + (requestedState) => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + models: [model("gpt-5.6")], + ...requestedState, + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }), + ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); + }, + ); + + it("returns no selection for empty, disabled, unavailable, or error-only profiles", () => { + expect(resolveDefaultProviderModelSelection([], null)).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + enabled: false, + }), + ], + null, + ), + ).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + availability: "unavailable", + }), + ], + null, + ), + ).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + status: "error", + }), + ], + null, + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c9ac87ac39f..337e68d44d0 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -13,10 +13,12 @@ * @module providerInstances */ import { + DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, + type ModelSelection, type ProviderDriverKind, - type ProviderInstanceId, + ProviderInstanceId, type ServerProvider, type ServerProviderModel, type ServerSettings, @@ -25,6 +27,16 @@ import { import { formatProviderDriverKindLabel } from "./providerModels"; +/** + * Local-only placeholder used while a draft has no provider it can safely + * target. It must never be persisted or dispatched; the composer disables + * send until a live provider replaces it. + */ +export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = { + instanceId: ProviderInstanceId.make("t3code_no_provider"), + model: "", +}; + /** * UI-facing projection of one configured provider instance. Carries the * snapshot verbatim for callers that need server-side fields we don't @@ -253,27 +265,82 @@ export function getProviderInstanceModels( return getProviderInstanceEntry(providers, instanceId)?.models ?? []; } +/** + * Default model slug for a specific instance: its declared built-in default, + * then its first built-in model, then any model it reports, then the driver-level default. Custom + * instances can serve a different model list than the default instance of + * the same driver kind, so the lookup must be instance-scoped rather than + * kind-scoped. + */ +export function getDefaultProviderInstanceModel( + providers: ReadonlyArray, + instanceId: ProviderInstanceId, +): string | undefined { + const entry = getProviderInstanceEntry(providers, instanceId); + if (!entry) return undefined; + return ( + entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ?? + entry.models.find((model) => !model.isCustom)?.slug ?? + entry.models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[entry.driverKind] + ); +} + +const isSelectableProviderInstanceEntry = (entry: ProviderInstanceEntry): boolean => + entry.enabled && entry.isAvailable; + +/** + * Resolve an exact stored instance when it remains enabled and available. + * Otherwise choose a deterministic fallback that can plausibly start now: + * ready first, then a non-error probe result. An errored provider is retained + * only when it was explicitly requested; it is never invented as a new-user + * default. + */ +export function resolveSelectableProviderInstanceEntry( + entries: ReadonlyArray, + instanceId: ProviderInstanceId | undefined, +): ProviderInstanceEntry | undefined { + if (instanceId !== undefined) { + const requested = entries.find((entry) => entry.instanceId === instanceId); + if (requested && isSelectableProviderInstanceEntry(requested)) { + return requested; + } + } + return ( + entries.find(isProviderInstancePickerReady) ?? + entries.find((entry) => isSelectableProviderInstanceEntry(entry) && entry.status !== "error") + ); +} + /** * Resolve the routing key for a selection that may reference an instance * id that no longer exists (e.g. a persisted thread selection after the - * user deleted the custom instance). Returns the first enabled instance - * as a fallback so downstream code can still send a turn. + * user deleted the custom instance). Returns a ready or non-error fallback, + * or `undefined` when no provider can safely become a new selection. */ export function resolveSelectableProviderInstance( providers: ReadonlyArray, instanceId: ProviderInstanceId | undefined, ): ProviderInstanceId | undefined { - if (instanceId === undefined) { - return deriveProviderInstanceEntries(providers).find( - (entry) => entry.enabled && entry.isAvailable, - )?.instanceId; - } const entries = deriveProviderInstanceEntries(providers); - const requested = entries.find((entry) => entry.instanceId === instanceId); - if (requested && requested.enabled && requested.isAvailable) { - return instanceId; - } - return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; + return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId; +} + +/** + * Resolve the model selection persisted for a project or new thread. A valid + * stored selection is preserved byte-for-byte. Falling back to another + * instance also resets the model to that instance's own default, avoiding + * cross-provider instance/model pairs. + */ +export function resolveDefaultProviderModelSelection( + providers: ReadonlyArray, + selection: ModelSelection | null | undefined, +): ModelSelection | null { + const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId); + if (instanceId === undefined) return null; + if (selection?.instanceId === instanceId) return selection; + const model = getDefaultProviderInstanceModel(providers, instanceId); + return model ? { instanceId, model } : null; } /** diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index f3bc72603ac..11b49742460 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; import { - DEFAULT_MODEL, EnvironmentId, ProjectId, CommandId, @@ -138,10 +137,7 @@ describe("add project shared logic", () => { title: "repo", workspaceRoot: "/work/repo", createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: "codex", - model: DEFAULT_MODEL, - }, + defaultModelSelection: null, }); }); }); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index ec58418a94f..6ae6e18baa2 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -7,7 +7,6 @@ import type { SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; -import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -215,10 +214,7 @@ export function buildProjectCreateCommand(input: { title: inferProjectTitleFromPath(input.workspaceRoot), workspaceRoot: input.workspaceRoot, createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + defaultModelSelection: null, createdAt: input.createdAt, }; } From 571a8b44bdd4f0f0b0464ef24240021689616e7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 16:19:01 +0200 Subject: [PATCH 12/21] Unify temporary worktree branch naming (#4278) --- packages/shared/src/git.test.ts | 23 +++++++++++++++++++++++ packages/shared/src/git.ts | 15 +++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 80578e262f9..96539f0aae2 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -71,6 +71,29 @@ describe("isTemporaryWorktreeBranch", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); }); + it("normalizes a UUID-shaped random callback to the canonical 8-hex form", () => { + expect(buildTemporaryWorktreeBranchName(() => "f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe( + `${WORKTREE_BRANCH_PREFIX}/f4ae4e0e`, + ); + }); + + it("matches legacy UUID-shaped temporary worktree refs from older mobile builds", () => { + expect( + isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`), + ).toBe(true); + }); + + it("rejects UUID-shaped refs that are not RFC 4122 v4", () => { + // version nibble is not 4 + expect( + isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-1d48-b4f2-9cf0aa54ab12`), + ).toBe(false); + // variant nibble is not [89ab] + expect( + isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-c4f2-9cf0aa54ab12`), + ).toBe(false); + }); + it("rejects non-temporary refName names", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); expect(isTemporaryWorktreeBranch("main")).toBe(false); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index ae50b148835..71fe2e806cf 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -11,7 +11,13 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +// Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` +// via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly +// that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads +// eligible for branch regeneration without loosening beyond what was ever generated. +const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( + `^${WORKTREE_BRANCH_PREFIX}\\/(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, +); /** * Sanitize an arbitrary string into a valid, lowercase git refName fragment. @@ -89,7 +95,12 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { export function buildTemporaryWorktreeBranchName( randomHex: (byteLength: number) => string, ): string { - const token = randomHex(4).toLowerCase(); + // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback + // still produces the canonical temporary branch form. + const token = randomHex(4) + .toLowerCase() + .replace(/[^0-9a-f]/g, "") + .slice(0, 8); return `${WORKTREE_BRANCH_PREFIX}/${token}`; } From 020179c19a0073d130c30083c7623cf1196688e7 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:50:20 +0530 Subject: [PATCH 13/21] fix(web): use message-square icon for settled icon-less project threads in sidebar v2 (#4279) --- apps/web/src/components/ProjectFavicon.tsx | 30 ++++++++++++++++++---- apps/web/src/components/SidebarV2.tsx | 2 ++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index f7603a36e9a..bc3e8ee832f 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; +import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; @@ -10,29 +11,46 @@ export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; className?: string | undefined; + fallbackIcon?: ComponentType<{ className?: string }>; }) { const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", cwd: input.cwd, }); + const FallbackIcon = input.fallbackIcon ?? FolderIcon; if (!src || isProjectFaviconFallbackUrl(src)) { - return ; + return ; } - return ; + return ( + + ); } -function ProjectFaviconFallback({ className }: { readonly className?: string | undefined }) { - return ; +function ProjectFaviconFallback({ + className, + icon: Icon, +}: { + readonly className?: string | undefined; + readonly icon: ComponentType<{ className?: string }>; +}) { + return ; } function ProjectFaviconImage({ src, className, + fallbackIcon: FallbackIcon, }: { readonly src: string; readonly className?: string | undefined; + readonly fallbackIcon: ComponentType<{ className?: string }>; }) { const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", @@ -40,7 +58,9 @@ function ProjectFaviconImage({ return ( <> - {status !== "loaded" ? : null} + {status !== "loaded" ? ( + + ) : null} {title} From 18b468871e6a3bfad207109898011ea330afeadb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 16:21:45 +0200 Subject: [PATCH 14/21] Stabilize sidebar settling animations (#4280) --- apps/web/src/components/SidebarV2.tsx | 44 ++++++++++++++++++--------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f3f03dc8b61..352a2fc471e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -129,10 +129,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - // Marks where active work transitions into history: a quiet labeled - // rule above the first settled row, so the tail reads as a named zone - // rather than an unexplained gap. - showSettledGap?: boolean; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -398,12 +394,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { data-thread-item className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]" > - {props.showSettledGap ? ( -
    - Settled - -
    - ) : null}
      - {orderedThreads.map((thread, threadIndex) => { + {orderedThreads.flatMap((thread, threadIndex) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const isSettledRow = settledThreadKeys.has(threadKey); // Settled is the ONLY thing that collapses a row: every @@ -1544,10 +1534,14 @@ export default function SidebarV2() { scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), ); const showSettledGap = !isCard && previousWasCard; - return ( + const row = ( ); + if (!showSettledGap) return [row]; + // The divider is its own keyed list item (not part of the first + // settled row): it keeps one stable DOM node at the boundary, + // so settling a thread slides it instead of teleporting it + // along with whichever row happens to be first in the tail — + // and row heights stay independent of neighbor classification. + return [ +
    • +
      + + Settled + + +
      +
    • , + row, + ]; })} {hiddenSettledCount > 0 ? (
    • From e5fba263e6820350e4edc7e5d93955aabef53df1 Mon Sep 17 00:00:00 2001 From: Henry Zhang <113233555+caezium@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:35:38 +0800 Subject: [PATCH 15/21] Restore Copy Link in chat link context menu (#4161) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 61 ++++----- .../chat/externalLinkContextMenu.test.ts | 128 ++++++++++++++++++ .../chat/externalLinkContextMenu.ts | 78 +++++++++++ 3 files changed, 230 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/components/chat/externalLinkContextMenu.test.ts create mode 100644 apps/web/src/components/chat/externalLinkContextMenu.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c85340c715b..fac3bf7d245 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,10 @@ import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + resolveExternalWebLinkHost, + showExternalLinkContextMenu, +} from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, @@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); -function resolveExternalLinkHost(href: string | undefined): string | null { - if (!href) return null; - try { - const url = new URL(href); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.hostname || null; - } catch { - return null; - } -} - const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( @@ -1393,7 +1387,7 @@ function ChatMarkdown({ const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { - const faviconHost = resolveExternalLinkHost(href); + const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -1410,37 +1404,30 @@ function ChatMarkdown({ } }} onContextMenu={(event) => { - if (!canOpenInPreview || !href) return; + if (!canOpenInPreview || !href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; - void (async () => { - let operation = "show-link-context-menu"; - try { - const clicked = await api.contextMenu.show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "open-in-browser") { - operation = "open-link-in-preview"; - const result = await openExternalLinkInPreview(href); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure({ operation, target: href }, result.cause); - } - return; + void showExternalLinkContextMenu({ + href, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInPreview: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); } - if (clicked === "open-external") { - operation = "open-link-external"; - await api.shell.openExternal(href); - } - } catch (cause) { + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); - } - })(); + }, + }); }} > {faviconHost ? ( diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts new file mode 100644 index 00000000000..64935d53e46 --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu"; + +function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) { + const showContextMenu = vi.fn().mockResolvedValue(selection); + const openInPreview = vi.fn().mockResolvedValue(undefined); + const openExternal = vi.fn().mockResolvedValue(undefined); + const copyLink = vi.fn().mockResolvedValue(undefined); + const reportFailure = vi.fn(); + + return { + showContextMenu, + openInPreview, + openExternal, + copyLink, + reportFailure, + }; +} + +describe("external chat link context menu", () => { + it("offers both open actions and Copy Link", async () => { + const harness = createHarness(null); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs?topic=menus#copy", + position: { x: 12, y: 24 }, + ...harness, + }); + + expect(harness.showContextMenu).toHaveBeenCalledWith( + [ + { id: "open-in-preview", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, + ], + { x: 12, y: 24 }, + ); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("copies the exact destination without opening it", async () => { + const harness = createHarness("copy-link"); + const href = "https://example.com/docs?topic=menus#copy"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness.copyLink).toHaveBeenCalledWith(href); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-preview" as const, "openInPreview" as const], + ["open-external" as const, "openExternal" as const], + ])("preserves the %s action", async (selection, expectedCallback) => { + const harness = createHarness(selection); + const href = "https://example.com/docs"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness[expectedCallback]).toHaveBeenCalledWith(href); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("reports the selected action when it fails", async () => { + const harness = createHarness("copy-link"); + const cause = new Error("clipboard denied"); + harness.copyLink.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("copy-link", cause); + }); + + it("reports the menu operation when the native menu cannot be shown", async () => { + const harness = createHarness(null); + const cause = new Error("menu unavailable"); + harness.showContextMenu.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-preview" as const, "openInPreview" as const, "open-link-in-preview"], + ["open-external" as const, "openExternal" as const, "open-link-external"], + ])("reports a failed %s action", async (selection, callback, operation) => { + const harness = createHarness(selection); + const cause = new Error("open failed"); + harness[callback].mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause); + }); + + it.each([ + ["https://example.com", "example.com"], + ["http://localhost:3000/path", "localhost"], + ["#details", null], + ["mailto:hello@example.com", null], + ["file:///tmp/example.txt", null], + ["javascript:void(0)", null], + ["not a URL", null], + [undefined, null], + ])("resolves the external web-link host for %s as %s", (href, expected) => { + expect(resolveExternalWebLinkHost(href)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts new file mode 100644 index 00000000000..398ca40da51 --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -0,0 +1,78 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; + +export type ExternalLinkContextMenuAction = "open-in-preview" | "open-external" | "copy-link"; + +export type ExternalLinkContextMenuFailureOperation = + | "show-link-context-menu" + | "open-link-in-preview" + | "open-link-external" + | "copy-link"; + +const FAILURE_OPERATION_BY_ACTION = { + "open-in-preview": "open-link-in-preview", + "open-external": "open-link-external", + "copy-link": "copy-link", +} as const satisfies Record; + +const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ + { id: "open-in-preview", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, +] as const satisfies readonly ContextMenuItem[]; + +interface ShowExternalLinkContextMenuOptions { + readonly href: string; + readonly position: { readonly x: number; readonly y: number }; + readonly showContextMenu: ( + items: readonly ContextMenuItem[], + position: { readonly x: number; readonly y: number }, + ) => Promise; + readonly openInPreview: (href: string) => Promise; + readonly openExternal: (href: string) => Promise; + readonly copyLink: (href: string) => Promise; + readonly reportFailure: ( + operation: ExternalLinkContextMenuFailureOperation, + cause: unknown, + ) => void; +} + +export function resolveExternalWebLinkHost(href: string | undefined): string | null { + if (!href) return null; + try { + const url = new URL(href); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.hostname || null; + } catch { + return null; + } +} + +export async function showExternalLinkContextMenu({ + href, + position, + showContextMenu, + openInPreview, + openExternal, + copyLink, + reportFailure, +}: ShowExternalLinkContextMenuOptions): Promise { + let action: ExternalLinkContextMenuAction | null; + try { + action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position); + } catch (cause) { + reportFailure("show-link-context-menu", cause); + return; + } + + try { + if (action === "open-in-preview") { + await openInPreview(href); + } else if (action === "open-external") { + await openExternal(href); + } else if (action === "copy-link") { + await copyLink(href); + } + } catch (cause) { + if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause); + } +} From f74eb62661734927aea45a243e2c3effced43fb9 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:53:07 +0530 Subject: [PATCH 16/21] fix(desktop): handle EPIPE errors on stdout/stderr to prevent crash dialog (#4213) --- apps/desktop/src/main.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7a51700e0fd..9795f04e8ae 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,9 @@ +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EPIPE") throw err; + }); +} + import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; From 18fa89c4adaae722e619f6abde093becf80efc08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:26:41 +0200 Subject: [PATCH 17/21] Preserve draft thread highlighting during promotion (#4283) Co-authored-by: codex --- apps/web/src/components/Sidebar.tsx | 13 ++++++++++--- apps/web/src/threadRoutes.test.ts | 28 ++++++++++++++++++++++++++++ apps/web/src/threadRoutes.ts | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf3fa30cf8d..a765fe64d92 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -118,7 +118,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, - resolveThreadRouteRef, + resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -3068,10 +3068,17 @@ export default function Sidebar() { const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); - const routeThreadRef = useParams({ + const routeTarget = useParams({ strict: false, - select: (params) => resolveThreadRouteRef(params), + select: (params) => resolveThreadRouteTarget(params), }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index d15a233a304..644cdccb5c1 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore"; import { buildDraftThreadRouteParams, buildThreadRouteParams, + resolveActiveThreadRouteRef, resolveThreadRouteRef, resolveThreadRouteTarget, } from "./threadRoutes"; @@ -64,4 +65,31 @@ describe("threadRoutes", () => { draftId: "draft-1", }); }); + + it("resolves the backing thread while a draft route is being promoted", () => { + const target = resolveThreadRouteTarget({ draftId: "draft-1" }); + + expect( + resolveActiveThreadRouteRef(target, { + environmentId: "env-1" as never, + threadId: ThreadId.make("draft-thread"), + promotedTo: scopeThreadRef("env-2" as never, ThreadId.make("server-thread")), + }), + ).toEqual({ + environmentId: "env-2", + threadId: "server-thread", + }); + }); + + it("does not treat a draft's reserved thread ref as an active sidebar thread", () => { + const target = resolveThreadRouteTarget({ draftId: "draft-1" }); + + expect( + resolveActiveThreadRouteRef(target, { + environmentId: "env-1" as never, + threadId: ThreadId.make("draft-thread"), + promotedTo: null, + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 19a7d5ca603..a4d853c0a7f 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -12,6 +12,12 @@ export type ThreadRouteTarget = draftId: DraftId; }; +type DraftThreadRouteState = { + environmentId: EnvironmentId; + threadId: ThreadId; + promotedTo?: ScopedThreadRef | null; +}; + export function buildThreadRouteParams(ref: ScopedThreadRef): { environmentId: EnvironmentId; threadId: ThreadId; @@ -57,3 +63,20 @@ export function resolveThreadRouteTarget( draftId: params.draftId as DraftId, }; } + +/** + * Resolves the thread represented by either a canonical thread route or a + * draft route whose promotion to a server thread has been recorded. + */ +export function resolveActiveThreadRouteRef( + target: ThreadRouteTarget | null, + draftThread: DraftThreadRouteState | null, +): ScopedThreadRef | null { + if (target?.kind === "server") { + return target.threadRef; + } + if (target?.kind !== "draft" || !draftThread?.promotedTo) { + return null; + } + return draftThread.promotedTo; +} From 7e2bb475042ccfd196779831e69f5b19a38a2423 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:36:22 +0200 Subject: [PATCH 18/21] Move mobile working timer into the thread timeline (#4285) --- .../src/features/threads/ThreadComposer.tsx | 17 ++++- .../features/threads/ThreadDetailScreen.tsx | 69 ++----------------- .../src/features/threads/ThreadFeed.tsx | 45 +++++++++++- apps/mobile/src/lib/threadActivity.test.ts | 14 ++++ apps/mobile/src/lib/threadActivity.ts | 18 ++++- 5 files changed, 92 insertions(+), 71 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e712664d30a..c1a6f2c235e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -26,7 +26,13 @@ import { type ViewStyle, } from "react-native"; import ImageViewing from "react-native-image-viewing"; -import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { + FadeIn, + FadeInDown, + FadeOut, + FadeOutDown, + LinearTransition, +} from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( const isReconnecting = props.status.kind !== "unavailable"; return ( - + - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 32527b0b719..5cb04290f66 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,21 +14,18 @@ import type { ServerConfig as T3ServerConfig, ThreadId, } from "@t3tools/contracts"; -import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; -import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppText as Text } from "../../components/AppText"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { PendingApproval, PendingUserInput, @@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray Date.now()); - - useEffect(() => { - const intervalId = setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - return () => clearInterval(intervalId); - }, [props.startedAt]); - - const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; - - return ( - - - - - - - - - - Working for {durationLabel} - - - - - ); -}); - export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const insets = useSafeAreaInsets(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; @@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; - const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; + const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a @@ -411,6 +359,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} + activeWorkStartedAt={props.activeWorkStartedAt} listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} @@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - - {props.activeWorkStartedAt ? ( - - ) : null} - + {props.activePendingApproval || props.activePendingUserInput ? ( ) : null} - + ; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; @@ -816,6 +818,10 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "working") { + return ; + } + if (entry.type === "turn-fold") { return ( Date.now()); + + useEffect(() => { + const intervalId = setInterval(() => { + setNowMs(Date.now()); + }, 1_000); + return () => clearInterval(intervalId); + }, [props.startedAt]); + + const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; + + return ( + + + + + + + + Working for {durationLabel} + + + ); +}); + function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; @@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, expandedTurnIds, expandedWorkGroupIds, + props.activeWorkStartedAt, ), - [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], + [ + expandedTurnIds, + expandedWorkGroupIds, + props.activeWorkStartedAt, + props.feed, + props.latestTurn, + ], ); // The empty↔filled key below remounts the list, which resets its imperative @@ -1761,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }} /> - {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? ( + {props.feed.length === 0 && + props.activeWorkStartedAt === null && + props.contentPresentation.kind === "ready" ? ( { }); }); + it("appends active work as a normal timeline row", () => { + const startedAt = "2026-04-01T00:00:01.000Z"; + const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); + + expect(presented).toEqual([ + { + type: "working", + id: "working-indicator-row", + createdAt: startedAt, + }, + ]); + expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]); + }); + it("models work-log overflow as list rows", () => { const activity = ( id: string, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..6278247dc69 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -98,6 +98,11 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | Extract + | { + readonly type: "working"; + readonly id: string; + readonly createdAt: string; + } | { readonly type: "activity-group"; readonly id: string; @@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation( latestTurn: ThreadFeedLatestTurn | null, expandedTurnIds: ReadonlySet, expandedWorkGroupIds: ReadonlySet = new Set(), + activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", + (entry) => + entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", ); const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); const collapsedEntryIds = new Set(); @@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation( appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); } } + if (activeWorkStartedAt !== null) { + result.push({ + type: "working", + id: "working-indicator-row", + createdAt: activeWorkStartedAt, + }); + } return result; } function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, ): void { if (entry.type !== "activity-group") { From 376c149eac201fd1492c7b7512a1de96c3b345b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:49:46 +0200 Subject: [PATCH 19/21] Stabilize PR status lookups and provider session lifecycle (#4281) Co-authored-by: Claude Fable 5 --- apps/server/src/git/GitManager.test.ts | 180 +++++++++++++++++- apps/server/src/git/GitManager.ts | 173 +++++++++++++++-- apps/server/src/server.test.ts | 3 + .../src/sourceControl/GitHubCli.test.ts | 55 ++++++ .../src/sourceControl/gitHubPullRequests.ts | 18 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 41 ++++ apps/server/src/vcs/GitVcsDriverCore.ts | 14 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 10 + apps/server/src/vcs/VcsStatusBroadcaster.ts | 7 +- 9 files changed, 465 insertions(+), 36 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..464c3afad64 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -50,6 +50,8 @@ interface FakeGhScenario { }; repositoryCloneUrls?: Record; failWith?: GitHubCli.GitHubCliError; + /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ + failAfterCalls?: number; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -382,7 +384,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const args = [...input.args]; ghCalls.push(args.join(" ")); - if (scenario.failWith) { + if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { return Effect.fail(scenario.failWith); } @@ -1336,6 +1338,182 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status keeps the last known PR when a later lookup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-sticky", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // An explicit invalidation (user refresh, git action) bypasses the PR + // cache and forces a live lookup — which now fails. The badge must keep + // the last known PR instead of blanking out. + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(214); + }), + ); + + it.effect( + "status does not reuse a stale PR after the branch is retargeted to a different upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-retarget"]); + + const originRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originRemote]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-retarget"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-retarget", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // Retarget the branch to a different remote/upstream (e.g. the PR was + // reopened against a fork). The previously cached PR belonged to the + // old upstream and must not be shown against the new one. + const forkRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork", forkRemote]); + yield* runGit(repoDir, ["push", "fork", "feature/pr-retarget"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to=fork/feature/pr-retarget", + "feature/pr-retarget", + ]); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + + it.effect("status keeps the last known PR when the branch gains its first upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const existingPr = { + number: 215, + title: "Sticky first-push PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/215", + baseRefName: "main", + headRefName: "feature/pr-sticky-first-push", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(215); + + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(215); + }), + ); + + it.effect("status drops the last known PR when the tracked remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]); + + const existingPr = { + number: 216, + title: "Old remote PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/216", + baseRefName: "main", + headRefName: "feature/pr-repointed", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(216); + + const replacementRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]); + yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]); + yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..58bd8602df1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -32,6 +32,7 @@ import { import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, + normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -95,6 +96,9 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; +const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const PR_LOOKUP_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -142,6 +146,7 @@ interface BranchHeadContext { headSelectors: ReadonlyArray; preferredHeadSelector: string; remoteName: string | null; + headRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -768,6 +773,145 @@ export const make = Effect.gen(function* () { normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); + // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh + // on their own, slower cadence: ahead/behind counts stay fresh on every + // status poll while the PR association is re-fetched at most once per + // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump + // the epoch (invalidateStatus) to bypass the cache immediately. + const prLookupEpochByCwd = new Map(); + const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; + const bumpPrLookupEpoch = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.map((cacheKey) => { + prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); + }), + ); + // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the + // segments can contain a NUL byte, and refs are never empty, so "" decodes + // back to a null upstreamRef. + const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => + [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + const prLookupCache = yield* Cache.makeWith( + (key: string) => { + const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); + const details = { + branch, + upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, + }; + return resolveBranchHeadContext(cwd, details).pipe( + Effect.flatMap((headContext) => + findLatestPrForHeadContext(cwd, headContext).pipe( + Effect.map((latest) => ({ latest, headContext })), + ), + ), + ); + }, + { + capacity: PR_LOOKUP_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), + }, + ); + // A transient lookup failure (rate limit, network blip) must not clear an + // already-known PR badge, so the last successful answer per branch sticks + // around as the fallback. Keep the resolved head context with it so a + // branch retargeted to another remote/fork cannot inherit the old badge. + interface LastKnownPr { + readonly pr: ReturnType | null; + readonly upstreamRef: string | null; + readonly headBranch: string; + readonly remoteName: string | null; + readonly headRemoteUrlKey: string | null; + } + const lastKnownPrByBranchKey = new Map(); + const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => { + if ( + !lastKnownPrByBranchKey.has(branchKey) && + lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = lastKnownPrByBranchKey.keys().next().value; + if (oldestKey !== undefined) { + lastKnownPrByBranchKey.delete(oldestKey); + } + } + lastKnownPrByBranchKey.set(branchKey, entry); + }; + const resolveLastKnownPr = ( + branchKey: string, + current: Pick, + ): ReturnType | null => { + const lastKnown = lastKnownPrByBranchKey.get(branchKey); + if (!lastKnown) return null; + if (lastKnown.headBranch !== current.headBranch) { + return null; + } + + // The normalized URL catches both remote-alias changes and an existing + // alias being repointed. It also lets an upstream appear after `push -u` + // without invalidating the fallback when it still targets the same repo. + if (lastKnown.headRemoteUrlKey !== null || current.headRemoteUrlKey !== null) { + return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; + } + + // If neither remote URL is available, fall back to the remote identity + // encoded by tracked branches. A null-to-non-null transition is allowed + // because that is the expected first-push case. + if (lastKnown.upstreamRef !== null && current.upstreamRef !== null) { + return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; + } + return lastKnown.pr; + }; + const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, + ) { + // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first + // `push -u`) must not orphan the fallback value for the same branch. + const branchKey = `${cwd}\u0000${details.branch}`; + return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + Effect.map(({ latest, headContext }) => { + if (!latest) return { pr: null, headContext }; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") { + return { pr: null, headContext }; + } + return { pr: toStatusPr(latest), headContext }; + }), + Effect.tap(({ pr, headContext }) => + Effect.sync(() => + rememberLastKnownPr(branchKey, { + pr, + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + Effect.map(({ pr }) => pr), + Effect.catch((error) => + Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( + Effect.annotateLogs({ + operation: "lookupStatusPr", + branch: details.branch, + errorTag: + typeof error === "object" && error !== null && "_tag" in error + ? String(error._tag) + : typeof error, + }), + Effect.andThen(resolveBranchHeadContext(cwd, details)), + Effect.map((headContext) => + resolveLastKnownPr(branchKey, { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + ), + ); + }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, options?: GitVcsDriver.GitRemoteStatusOptions, @@ -781,19 +925,11 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* findLatestPr(cwd, { + ? yield* lookupStatusPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => { - if (!latest) return null; - // On the default branch, only surface open PRs. - // Merged/closed matches are usually reverse-merge history, not the thread's PR context. - if (details.isDefaultBranch && latest.state !== "open") return null; - return toStatusPr(latest); - }), - Effect.orElseSucceed(() => null), - ) + isDefaultBranch: details.isDefaultBranch, + }) : null; return { @@ -837,6 +973,7 @@ export const make = Effect.gen(function* () { ) { if (!remoteName) { return { + remoteUrlKey: null, repositoryNameWithOwner: null, ownerLogin: null, }; @@ -845,6 +982,7 @@ export const make = Effect.gen(function* () { const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { + remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), }; @@ -915,6 +1053,9 @@ export const make = Effect.gen(function* () { preferredHeadSelector: ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, remoteName, + headRemoteUrlKey: + remoteRepository.remoteUrlKey ?? + (remoteName === null ? originRepository.remoteUrlKey : null), headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -960,11 +1101,10 @@ export const make = Effect.gen(function* () { return null; }); - const findLatestPr = Effect.fn("findLatestPr")(function* ( + const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + headContext: BranchHeadContext, ) { - const headContext = yield* resolveBranchHeadContext(cwd, details); const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { @@ -991,7 +1131,6 @@ export const make = Effect.gen(function* () { } return parsed[0] ?? null; }); - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, @@ -1442,6 +1581,10 @@ export const make = Effect.gen(function* () { function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); + // Full invalidation is the explicit-freshness path (git actions, user + // refresh); it also bypasses the slow PR-lookup cache. The periodic + // status poll only invalidates local/remote and keeps the PR cache warm. + yield* bumpPrLookupEpoch(cwd); }, ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8a5e0b713e6..c8c4ff377e8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5285,6 +5285,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, @@ -5331,6 +5332,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, @@ -5407,6 +5409,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Deferred.succeed(localRefreshStarted, undefined).pipe( Effect.ignore, diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..5daf7676d60 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -208,6 +208,61 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () => + // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as + // {id, name} only. These entries must decode instead of being dropped, + // with nameWithOwner rebuilt from the owner login. + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "OPEN", + mergedAt: null, + isCrossRepository: false, + headRepository: { + id: "R_kgDORLtfbQ", + name: "codething-mvp", + }, + headRepositoryOwner: { + id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3", + login: "pingdotgg", + }, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "t3code/codex-turn-mapping", + }); + + assert.deepStrictEqual(result, [ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index d9dcb7f9ad1..ded3c0a90b0 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -30,17 +30,21 @@ const GitHubPullRequestSchema = Schema.Struct({ mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), + // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was + // added later. Both fields stay optional so a version-drifted gh CLI can + // never fail the decode and silently drop the PR from the list. headRepository: Schema.optional( Schema.NullOr( Schema.Struct({ - nameWithOwner: Schema.String, + nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), headRepositoryOwner: Schema.optional( Schema.NullOr( Schema.Struct({ - login: Schema.String, + login: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), @@ -71,11 +75,15 @@ function normalizeGitHubPullRequestState(input: { function normalizeGitHubPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedGitHubPullRequestRecord { - const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const headRepositoryName = trimOptionalString(raw.headRepository?.name); const headRepositoryOwnerLogin = trimOptionalString(raw.headRepositoryOwner?.login) ?? - (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") - ? (headRepositoryNameWithOwner.split("/")[0] ?? null) + (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); + const headRepositoryNameWithOwner = + explicitNameWithOwner ?? + (headRepositoryOwnerLogin && headRepositoryName + ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); return { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9ffd3ed696d..ecd2d0ad2b0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -690,6 +690,47 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); + describe("remote operations", () => { + it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + + const reusedForSsh = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "git@github.com:pingdotgg/t3code.git", + }); + assert.equal(reusedForSsh, "origin"); + + const reusedForSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com/pingdotgg/t3code", + }); + assert.equal(reusedForSshScheme, "origin"); + + const reusedForSshWithPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code.git", + }); + assert.equal(reusedForSshWithPort, "origin"); + + const addedForFork = yield* driver.ensureRemote({ + cwd, + preferredName: "octocat", + url: "git@github.com:octocat/t3code.git", + }); + assert.equal(addedForFork, "octocat"); + assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); + }), + ); + }); + describe("commit context", () => { it.effect("stages selected files and commits only those files", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 471ec10b566..33eb6d40d76 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -25,7 +25,7 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -235,14 +235,6 @@ function sanitizeRemoteName(value: string): string { return sanitized.length > 0 ? sanitized : "fork"; } -function normalizeRemoteUrl(value: string): string { - return value - .trim() - .replace(/\/+$/g, "") - .replace(/\.git$/i, "") - .toLowerCase(); -} - function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); for (const line of stdout.split("\n")) { @@ -1092,7 +1084,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "ensureRemote", )(function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); const remoteFetchUrls = yield* runGitStdout( "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, @@ -1100,7 +1092,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { return remoteName; } } diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 032e48e4612..ee595b1f836 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -94,6 +94,11 @@ function makeTestLayer(state: { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), }), ), ); @@ -206,6 +211,11 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), }), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c238154f58c..b02ca9deb66 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -366,10 +366,9 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { - concurrency: "unbounded", - discard: true, - }); + // invalidateStatus (not the two partial invalidations) so an explicit + // refresh also bypasses GitManager's slow PR-lookup cache. + yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, From 9fe4832a3f87e48152f915cfac9f85ec78e14e24 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:24:09 +0530 Subject: [PATCH 20/21] fix: open command palette instead of custom dialog for new thread picker in SidebarV2 (#4269) --- apps/web/src/commandPaletteBus.ts | 30 ++++ apps/web/src/commandPaletteContext.tsx | 29 ---- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 77 +++++++-- apps/web/src/components/Sidebar.tsx | 7 +- apps/web/src/components/SidebarV2.tsx | 156 ++---------------- .../src/components/chat/DraftHeroHeadline.tsx | 6 +- apps/web/src/newThreadPickerBus.ts | 14 -- apps/web/src/routes/_chat.index.tsx | 6 +- apps/web/src/routes/_chat.tsx | 8 +- 10 files changed, 118 insertions(+), 217 deletions(-) create mode 100644 apps/web/src/commandPaletteBus.ts delete mode 100644 apps/web/src/commandPaletteContext.tsx delete mode 100644 apps/web/src/newThreadPickerBus.ts diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts new file mode 100644 index 00000000000..2a953132992 --- /dev/null +++ b/apps/web/src/commandPaletteBus.ts @@ -0,0 +1,30 @@ +// Tiny event bus allowing components to programmatically open the command palette +// without owning its React state. +const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; + +export interface CommandPaletteOpenDetail { + readonly open?: "add-project" | "new-thread-in"; +} + +export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { + window.dispatchEvent( + new CustomEvent(COMMAND_PALETTE_OPEN_EVENT, detail ? { detail } : undefined), + ); +} + +export function onOpenCommandPalette( + listener: (detail: CommandPaletteOpenDetail) => void, +): () => void { + const handler = (event: Event) => { + listener((event as CustomEvent).detail ?? {}); + }; + window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); + return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); +} + +/** Read at event time so consumers do not subscribe to transient dialog state. */ +export function isCommandPaletteOpen(): boolean { + return ( + typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null + ); +} diff --git a/apps/web/src/commandPaletteContext.tsx b/apps/web/src/commandPaletteContext.tsx deleted file mode 100644 index 8dae5fed3b5..00000000000 --- a/apps/web/src/commandPaletteContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { createContext, use, type ReactNode } from "react"; - -const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null); - -export function OpenAddProjectCommandPaletteProvider(props: { - readonly children: ReactNode; - readonly openAddProject: () => void; -}) { - return ( - - {props.children} - - ); -} - -export function useOpenAddProjectCommandPalette(): () => void { - const openAddProject = use(OpenAddProjectCommandPaletteContext); - if (!openAddProject) { - throw new Error("Command palette actions must be used inside CommandPalette"); - } - return openAddProject; -} - -/** Read at event time so the chat tree does not subscribe to transient dialog state. */ -export function isCommandPaletteOpen(): boolean { - return ( - typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null - ); -} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 04bd3a97c05..ad303f607e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,7 +112,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9d824e0f72d..8c7e6d287ca 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -43,7 +43,7 @@ import { type ReactNode, } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; + import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -77,6 +77,7 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; +import { onOpenCommandPalette } from "../commandPaletteBus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; @@ -334,7 +335,7 @@ function errorMessage(error: unknown): string { } interface CommandPaletteOpenIntent { - readonly kind: "add-project"; + readonly kind: "add-project" | "new-thread-in"; } interface CommandPaletteUiState { @@ -346,6 +347,7 @@ type CommandPaletteUiAction = | { readonly _tag: "SetOpen"; readonly open: boolean } | { readonly _tag: "Toggle" } | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "ClearOpenIntent" }; function reduceCommandPaletteUiState( @@ -362,6 +364,8 @@ function reduceCommandPaletteUiState( return { open: !state.open, openIntent: null }; case "OpenAddProject": return { open: true, openIntent: { kind: "add-project" } }; + case "OpenNewThreadIn": + return { open: true, openIntent: { kind: "new-thread-in" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } @@ -375,6 +379,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); + const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); @@ -409,20 +414,32 @@ export function CommandPalette({ children }: { children: ReactNode }) { return () => window.removeEventListener("keydown", onKeyDown); }, [keybindings, terminalOpen, toggleOpen]); + useEffect( + () => + onOpenCommandPalette((detail) => { + if (detail.open === "new-thread-in") { + openNewThreadIn(); + } else if (detail.open === "add-project") { + openAddProject(); + } else { + setOpen(true); + } + }), + [openAddProject, openNewThreadIn, setOpen], + ); + return ( - - - - {children} - - - - + + + {children} + + + ); } @@ -960,6 +977,36 @@ function OpenCommandPaletteDialog(props: { openAddProjectFlow(); }, [clearOpenIntent, openAddProjectFlow, openIntent]); + useLayoutEffect(() => { + if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) { + return; + } + clearOpenIntent(); + setAddProjectCloneFlow(null); + setViewStack([]); + setQuery(""); + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + const prioritized = currentPrefix + ? [ + ...projectThreadItems.filter((item) => item.value === currentPrefix), + ...projectThreadItems.filter((item) => item.value !== currentPrefix), + ] + : projectThreadItems; + pushPaletteView({ + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: prioritized }], + }); + }, [ + clearOpenIntent, + currentProjectEnvironmentId, + currentProjectId, + openIntent, + projectThreadItems, + ]); + const actionItems: Array = []; if (projects.length > 0) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a765fe64d92..d3e3f836c00 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -177,7 +177,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, @@ -3086,7 +3086,10 @@ export default function Sidebar() { : false, ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 352a2fc471e..32b3ae5e1ca 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -57,14 +57,8 @@ import { useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; -import { onOpenNewThreadPicker } from "../newThreadPickerBus"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; -import { - resolveThreadActionProjectRef, - startNewThreadFromContext, - startNewThreadInProjectFromContext, -} from "../lib/chatThreadActions"; +import { openCommandPalette } from "../commandPaletteBus"; +import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; @@ -630,7 +624,10 @@ export default function SidebarV2() { reportFailure: false, }); const newThreadContext = useHandleNewThread(); - const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); @@ -1301,12 +1298,8 @@ export default function SidebarV2() { // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette - // uses. The chevron menu is the explicit project picker the flat list no - // longer gets from per-project headers. - const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); - // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in - // v2 with multiple projects it opens this picker via the event bus. - useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); + // uses. The command palette already offers a "New thread in..." submenu + // for multi-project setups. const handleNewThreadClick = useCallback(() => { // One project: nothing to pick, create immediately. if (projects.length <= 1) { @@ -1319,57 +1312,9 @@ export default function SidebarV2() { }); return; } - setNewThreadPickerOpen(true); + if (isMobile) setOpenMobile(false); + openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projects.length, setOpenMobile]); - const createThreadInProject = useCallback( - (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { - setNewThreadPickerOpen(false); - if (isMobile) setOpenMobile(false); - const project = projects.find( - (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, - ); - if (!project) return; - void startNewThreadInProjectFromContext( - { - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }, - scopeProjectRef(project.environmentId, project.id), - ); - }, - [isMobile, newThreadContext, projects, setOpenMobile], - ); - const contextualProjectRef = resolveThreadActionProjectRef({ - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }); - const newThreadTargetRef = scopedProject - ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) - : contextualProjectRef; - const newThreadTargetProject = newThreadTargetRef - ? (projects.find( - (project) => - project.environmentId === newThreadTargetRef.environmentId && - project.id === newThreadTargetRef.projectId, - ) ?? null) - : null; - // Picker order: the contextual default first (preselected), everything else - // after — the common case is Enter/click on the top row. - const newThreadPickerProjects = useMemo(() => { - if (!newThreadTargetProject) return projects; - return [ - newThreadTargetProject, - ...projects.filter( - (project) => - project.environmentId !== newThreadTargetProject.environmentId || - project.id !== newThreadTargetProject.id, - ), - ]; - }, [newThreadTargetProject, projects]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to @@ -1639,87 +1584,6 @@ export default function SidebarV2() { - - - - New thread in… - -
      { - if ( - event.key !== "ArrowDown" && - event.key !== "ArrowUp" && - event.key !== "Home" && - event.key !== "End" - ) { - return; - } - const container = event.currentTarget; - const options = [...container.querySelectorAll("button")]; - if (options.length === 0) return; - const currentIndex = options.findIndex((option) => option === document.activeElement); - const nextIndex = - event.key === "Home" - ? 0 - : event.key === "End" - ? options.length - 1 - : event.key === "ArrowDown" - ? (currentIndex + 1) % options.length - : (currentIndex - 1 + options.length) % options.length; - event.preventDefault(); - options[nextIndex]?.focus(); - }} - > - {newThreadPickerProjects.map((project, index) => { - const isDefault = index === 0 && newThreadTargetProject !== null; - return ( - - ); - })} - -
      -
      -
      ); } diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 6a88fb8da19..3f50d3818b5 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,9 +1,9 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { FolderPlusIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; -import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext"; +import { openCommandPalette } from "~/commandPaletteBus"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useProjects, useThreadShells } from "~/state/entities"; import { sortScopedProjectsForSidebar } from "../Sidebar.logic"; @@ -29,7 +29,7 @@ export function DraftHeroHeadline({ const projects = useProjects(); const threads = useThreadShells(); const handleNewThread = useNewThreadHandler(); - const openAddProject = useOpenAddProjectCommandPalette(); + const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const orderedProjects = useMemo( () => sortScopedProjectsForSidebar(projects, threads, "updated_at"), diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts deleted file mode 100644 index a1cc7560e20..00000000000 --- a/apps/web/src/newThreadPickerBus.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Tiny event bus connecting the global chat.new shortcut (handled in the -// _chat route layout) to SidebarV2's project picker dialog. The route layer -// can't render the picker itself — it lives with the sidebar — and the -// sidebar can't own the window keydown handler without racing the layout's. -const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; - -export function openNewThreadPicker(): void { - window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); -} - -export function onOpenNewThreadPicker(listener: () => void): () => void { - window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); - return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); -} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 8aadbf9ea20..6e8dbe33ff5 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,9 +1,9 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, Link } from "@tanstack/react-router"; import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { openCommandPalette } from "../commandPaletteBus"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; @@ -105,7 +105,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { } function NoProjectsHero() { - const openAddProject = useOpenAddProjectCommandPalette(); + const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); return ( diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index b590c59ed9d..57824fe5188 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -2,9 +2,9 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; -import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; -import { openNewThreadPicker } from "../newThreadPickerBus"; +import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -80,11 +80,11 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - // Sidebar v2 routes creation through its project picker whenever + // Sidebar v2 routes creation through the command palette whenever // there is a real choice to make; v1 (and single-project setups) // keep the immediate contextual create. if (sidebarV2Enabled && projectCount > 1) { - openNewThreadPicker(); + openCommandPalette({ open: "new-thread-in" }); return; } void startNewThreadFromContext({ From 9a0a07167f0623c3a7db0ffeff2e3939760309df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 18:11:07 +0200 Subject: [PATCH 21/21] fix(server): don't drop sticky PR fallback when remote URL can't be resolved (#4289) Co-authored-by: Claude Fable 5 --- apps/server/src/git/GitManager.test.ts | 48 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 23 ++++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 464c3afad64..000a9d99c6b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1514,6 +1514,54 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status keeps the last known PR when the current remote URL can't be resolved", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-config-hiccup"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-config-hiccup"]); + + const existingPr = { + number: 217, + title: "Config hiccup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/217", + baseRefName: "main", + headRefName: "feature/pr-config-hiccup", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(217); + + // `remote.origin.url` reads go through readConfigValueNullable, which + // maps ANY failed read (a real "no remote configured" state or a + // transient git-config hiccup) to null the same way. Unsetting the + // key here reproduces that ambiguity without touching branch + // tracking (refs/remotes/origin/* and branch..remote are + // untouched) — the remote identity has not actually changed, so the + // sticky PR must survive even though the current lookup can no + // longer resolve a remote URL to compare against. + yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(217); + }), + ); + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 58bd8602df1..e59613f7cc8 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -846,16 +846,25 @@ export const make = Effect.gen(function* () { } // The normalized URL catches both remote-alias changes and an existing - // alias being repointed. It also lets an upstream appear after `push -u` - // without invalidating the fallback when it still targets the same repo. - if (lastKnown.headRemoteUrlKey !== null || current.headRemoteUrlKey !== null) { + // alias being repointed. Both sides must be resolved before treating a + // mismatch as real: `readConfigValueNullable` swallows any git-config + // read failure into `null`, so a transient failure to resolve the + // *current* remote URL must read as "unknown", not as "no remote" — the + // latter would otherwise drop an already-known PR badge on every hiccup. + if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) { return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; } - // If neither remote URL is available, fall back to the remote identity - // encoded by tracked branches. A null-to-non-null transition is allowed - // because that is the expected first-push case. - if (lastKnown.upstreamRef !== null && current.upstreamRef !== null) { + // If the remote URL can't be compared, fall back to the remote identity + // encoded by tracked branches — same "both sides known" requirement, for + // the same reason. A null-to-non-null transition (upstream/remoteName) + // is allowed because that is the expected first-push case. + if ( + lastKnown.upstreamRef !== null && + current.upstreamRef !== null && + lastKnown.remoteName !== null && + current.remoteName !== null + ) { return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; } return lastKnown.pr;