import { ANTIGRAVITY_DEFAULT_MODEL, type AssetCreateUrlInput, type AssetCreateUrlResult, type ChatFileAttachment, type EnvironmentId, isProviderDriverKind, ProjectId, type MessageId, type ModelSelection, type ProviderInteractionMode, ProviderDriverKind, type ProviderInstanceId, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, type TurnId,} from "@t3tools/contracts";import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";import { squashAtomCommandFailure, type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { videoMimeType } from "@t3tools/shared/video";import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";import { type ChatMessage, isImageAttachment, type SessionPhase, type Thread, type ThreadShell, type TurnDiffSummary,} from "../types";import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";import * as Schema from "effect/Schema";import { appAtomRegistry } from "../rpc/atomRegistry";import { environmentThreadDetails } from "../state/threads";import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, type TerminalContextDraft,} from "../lib/terminalContext";import type { DraftThreadEnvMode } from "../composerDraftStore";import type { ComposerSubmissionIntent } from "../composer-logic";import type { TimelineEntry } from "../session-logic";import type { DesktopPreviewOverlay } from "../previewStateStore";import type { RightPanelSurface } from "../rightPanelStore";import { NO_PROVIDER_MODEL_SELECTION, resolveSelectableProviderInstanceEntry, type ProviderInstanceEntry,} from "../providerInstances"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], desktopByTabId: Readonly<Record<string, Pick<DesktopPreviewOverlay, "controller"> | undefined>>,): string | null { const activeBrowserCount = surfaces.filter( (surface) => surface.kind === "preview" && surface.resourceId !== null && desktopByTabId[surface.resourceId]?.controller === "agent", ).length; if (activeBrowserCount === 0) return null; if (activeBrowserCount === 1) { return [ "Close browser while the agent is using it?", "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", ].join("\n"); } return [ `Close ${activeBrowserCount} browsers while the agent is using them?`, "The agent is actively controlling these browsers. Closing them may interrupt the current browser actions.", ].join("\n");} export function shouldRenderPreviewMiniPlayer( miniPlayerTabId: string | null, renderedRightPanelSurface: RightPanelSurface | null,): boolean { return ( miniPlayerTabId !== null && !( renderedRightPanelSurface?.kind === "preview" && renderedRightPanelSurface.resourceId === miniPlayerTabId ) );} export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null,): boolean { return targetKey !== null && targetKey !== previousTargetKey;} interface ProactivePanelObservation { threadKey: string; runningTurnId: TurnId | null | undefined; targetKey: string | null | undefined; userActionTurnId: TurnId | null; userActionRevision: number;} /** Capture user intent before loading or metadata writes can defer panel activation. */export function observeProactivePanelUserChoice( previous: ProactivePanelObservation | null, input: { threadKey: string; runningTurnId: TurnId | null; userActionRevision: number },): ProactivePanelObservation { const sameThread = previous?.threadKey === input.threadKey; const newTurn = sameThread && input.runningTurnId !== null && input.runningTurnId !== previous.userActionTurnId; return { threadKey: input.threadKey, runningTurnId: sameThread ? previous.runningTurnId : undefined, targetKey: sameThread ? previous.targetKey : undefined, userActionTurnId: input.runningTurnId ?? (sameThread ? previous.userActionTurnId : null), userActionRevision: !sameThread || newTurn ? input.userActionRevision : previous.userActionRevision, };} /** Follow a changed server link only when the panel still shows the previous linked PR. */export function shouldRetargetThreadPullRequestPanel( previous: ThreadLinkedPullRequest | null, current: ThreadLinkedPullRequest | null, surface: RightPanelSurface | null,): boolean { if (previous === null || current === null || surface?.kind !== "pull-request") return false; const previousRepository = previous.repository.toLowerCase(); return ( (previous.projectId !== current.projectId || previousRepository !== current.repository.toLowerCase() || previous.number !== current.number) && surface.projectId === previous.projectId && surface.repository.toLowerCase() === previousRepository && surface.number === previous.number );} export function shouldOpenProactiveTurnDiff(input: { previousRunningTurnId: TurnId | null | undefined; runningTurnId: TurnId | null; settledTurnId: TurnId | null; turnCompleted: boolean;}): boolean { return ( input.runningTurnId === null && input.turnCompleted && input.settledTurnId !== null && (input.previousRunningTurnId === undefined || input.settledTurnId === input.previousRunningTurnId) );} export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined; isGitRepo: boolean | undefined;}): "defer" | "ignore" | "open" { if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; if (input.isGitRepo === undefined) return "defer"; if ( !input.isGitRepo || input.checkpoint.status !== "ready" || input.checkpoint.files.length === 0 ) { return "ignore"; } return "open";} export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate,): string | null { return appendCodexArtifactTemplateUsePrompt(currentDraft, template) === currentDraft ? null : codexArtifactTemplateUsePrompt(template);} export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; submissionIntent: ComposerSubmissionIntent;}): boolean { return ( input.submissionIntent === "foreground" && input.isDraftHeroState && input.activeThreadKey !== null );} export function shouldReleaseTimelineAnchorForToolActivity(input: { anchorMessageId: MessageId | null; liveFollowEnabled: boolean; runningTurnId: TurnId | null; timelineEntries: ReadonlyArray<TimelineEntry>;}): boolean { if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { return false; } return input.timelineEntries.some((timelineEntry) => { if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) { return false; } const entry = timelineEntry.entry; return ( entry.tone === "tool" || entry.itemType !== undefined || entry.requestKind !== undefined || (entry.command?.trim().length ?? 0) > 0 ); });} export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { const elementTarget = target instanceof Element ? target : null; const group = elementTarget?.closest<HTMLElement>("[data-tool-group-scroll]"); if (!group) return false; // A nested result or the group itself can consume an upward scroll. for (let element = elementTarget; element; element = element.parentElement) { if (element.scrollTop > 0) { const overflowY = getComputedStyle(element).overflowY; if (overflowY === "auto" || overflowY === "scroll") return true; } if (element === group) break; } return false;} export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; hasTimelineEntries: boolean; isWorking: boolean; draftHeroDockRequested: boolean; backgroundSubmissionPending: boolean;}): boolean { if (input.backgroundSubmissionPending) { return true; } return ( input.isLocalDraftThread && !input.hasTimelineEntries && !input.isWorking && !input.draftHeroDockRequested );} /** * Keep painted timelines on screen across thread jumps. Remounting LegendList * (or handing it an empty first paint) punches a hole through the chat pane — * white in light mode — so cmd+1/2/3 spam flashes even when the destination * is already cached. * * Stored at module scope because ChatView remounts when the thread route * changes (same pattern as the thread-error banner session dismissals). * Remember more than the last thread so jumping back to cmd+1 does not show * cmd+3's messages, and so a cached destination can paint on the first frame. */export type HeldThreadTimeline<T extends readonly unknown[]> = { threadKey: string | null; entries: T; markdownCwd?: string | null; workspaceRoot?: string | null;}; const MAX_REMEMBERED_THREAD_TIMELINES = 16; let rememberedThreadTimelines = new Map<string, HeldThreadTimeline<readonly unknown[]>>();let rememberedThreadTimelineOrder: string[] = [];let lastReadyThreadKey: string | null = null; function rememberThreadTimelineEntries(held: HeldThreadTimeline<readonly unknown[]>): void { if (held.threadKey === null) { return; } rememberedThreadTimelines.set(held.threadKey, held); rememberedThreadTimelineOrder = [ ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey), held.threadKey, ]; while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) { const evicted = rememberedThreadTimelineOrder.shift(); if (evicted !== undefined) { rememberedThreadTimelines.delete(evicted); } } lastReadyThreadKey = held.threadKey;} export function rememberReadyThreadTimeline<T extends readonly unknown[]>( held: HeldThreadTimeline<T>,): void { if (held.threadKey === null || held.entries.length === 0) { return; } rememberThreadTimelineEntries(held);} export function peekRememberedThreadTimeline<T extends readonly unknown[]>( threadKey: string | null,): T | null { if (threadKey === null) { return null; } return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null;} export function peekHeldThreadTimeline< T extends readonly unknown[],>(): HeldThreadTimeline<T> | null { if (lastReadyThreadKey === null) { return null; } const held = rememberedThreadTimelines.get(lastReadyThreadKey); if (held === undefined || held.entries.length === 0) { return null; } return held as HeldThreadTimeline<T>;} export function resetHeldThreadTimeline(): void { rememberedThreadTimelines = new Map(); rememberedThreadTimelineOrder = []; lastReadyThreadKey = null;} export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { if (left === null || right === null) { return false; } const leftRef = parseScopedThreadKey(left); const rightRef = parseScopedThreadKey(right); return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId;} /** True while we still paint another thread's last snapshot. */export function isPaintOnlyThreadTimeline( displayThreadKey: string | null, activeThreadKey: string | null,): boolean { return ( displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey );} export function resolveThreadSwitchTimeline<T extends readonly unknown[]>(input: { loading: boolean; activeThreadKey: string | null; nextEntries: T; rememberedForActive?: T | null; lastReady?: HeldThreadTimeline<T> | null;}): { entries: T; displayThreadKey: string | null } { if (input.nextEntries.length > 0) { return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; } const rememberedForActive = input.rememberedForActive ?? peekRememberedThreadTimeline<T>(input.activeThreadKey); if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) { return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; } const lastReady = input.lastReady ?? peekHeldThreadTimeline<T>(); if ( input.loading && lastReady !== null && lastReady.threadKey !== null && lastReady.threadKey !== input.activeThreadKey && lastReady.entries.length > 0 && threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey) ) { return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey }; } return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };} export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined; backgroundSubmissionPending: boolean;}): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { return null; } const sessionStatus = input.serverThread?.session?.status; const turnStarted = input.serverThread?.latestTurn?.startedAt != null; const startupStopped = sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; // Keep local preparation feedback mounted until the server can render the // running turn or its startup error on the canonical thread route. return turnStarted || startupStopped ? input.serverThreadRef : null;} export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); return () => globalThis.clearTimeout(timeoutId);} export function hasEnvironmentReconnectWarningGraceElapsed( activeEnvironmentId: EnvironmentId | null, elapsedEnvironmentId: EnvironmentId | null,): boolean { return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId;} export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise<unknown>,): boolean { if (projectRef === null) return false; void handleNewThread(projectRef); return true;} export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; currentBranch: string | null; nextBranch?: string;}): { modelSelection?: ModelSelection; branch?: string; worktreePath?: null;} | null { const nextModelSelection = input.nextModelSelection; const modelSelectionChanged = nextModelSelection !== undefined && (nextModelSelection.model !== input.currentModelSelection.model || nextModelSelection.instanceId !== input.currentModelSelection.instanceId || JSON.stringify(nextModelSelection.options ?? null) !== JSON.stringify(input.currentModelSelection.options ?? null)); const branchChanged = input.nextBranch !== undefined && input.nextBranch !== input.currentBranch; if (!modelSelectionChanged && !branchChanged) { return null; } return { ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}), ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}), };} export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection,): Thread { return { id: threadId, environmentId: draftThread.environmentId, projectId: draftThread.projectId, title: "New thread", modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, session: null, messages: [], createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, checkpoints: [], pullRequests: [], activities: [], proposedPlans: [], };} export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { return { ...shell, messages: [], proposedPlans: [], activities: [], checkpoints: [], deletedAt: null, };Lines 1–500 of 1250. Load more
import { ANTIGRAVITY_DEFAULT_MODEL, type AssetCreateUrlInput, type AssetCreateUrlResult, type ChatFileAttachment, type EnvironmentId, isProviderDriverKind, ProjectId, type MessageId, type ModelSelection, type ProviderInteractionMode, ProviderDriverKind, type ProviderInstanceId, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { videoMimeType } from "@t3tools/shared/video"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; import { type ChatMessage, isImageAttachment, type SessionPhase, type Thread, type ThreadShell, type TurnDiffSummary, } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; import { NO_PROVIDER_MODEL_SELECTION, resolveSelectableProviderInstanceEntry, type ProviderInstanceEntry, } from "../providerInstances"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], desktopByTabId: Readonly<Record<string, Pick<DesktopPreviewOverlay, "controller"> | undefined>>, ): string | null { const activeBrowserCount = surfaces.filter( (surface) => surface.kind === "preview" && surface.resourceId !== null && desktopByTabId[surface.resourceId]?.controller === "agent", ).length; if (activeBrowserCount === 0) return null; if (activeBrowserCount === 1) { return [ "Close browser while the agent is using it?", "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", ].join("\n"); } return [ `Close ${activeBrowserCount} browsers while the agent is using them?`, "The agent is actively controlling these browsers. Closing them may interrupt the current browser actions.", ].join("\n"); } export function shouldRenderPreviewMiniPlayer( miniPlayerTabId: string | null, renderedRightPanelSurface: RightPanelSurface | null, ): boolean { return ( miniPlayerTabId !== null && !( renderedRightPanelSurface?.kind === "preview" && renderedRightPanelSurface.resourceId === miniPlayerTabId ) ); } export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null, ): boolean { return targetKey !== null && targetKey !== previousTargetKey; } interface ProactivePanelObservation { threadKey: string; runningTurnId: TurnId | null | undefined; targetKey: string | null | undefined; userActionTurnId: TurnId | null; userActionRevision: number; } /** Capture user intent before loading or metadata writes can defer panel activation. */ export function observeProactivePanelUserChoice( previous: ProactivePanelObservation | null, input: { threadKey: string; runningTurnId: TurnId | null; userActionRevision: number }, ): ProactivePanelObservation { const sameThread = previous?.threadKey === input.threadKey; const newTurn = sameThread && input.runningTurnId !== null && input.runningTurnId !== previous.userActionTurnId; return { threadKey: input.threadKey, runningTurnId: sameThread ? previous.runningTurnId : undefined, targetKey: sameThread ? previous.targetKey : undefined, userActionTurnId: input.runningTurnId ?? (sameThread ? previous.userActionTurnId : null), userActionRevision: !sameThread || newTurn ? input.userActionRevision : previous.userActionRevision, }; } /** Follow a changed server link only when the panel still shows the previous linked PR. */ export function shouldRetargetThreadPullRequestPanel( previous: ThreadLinkedPullRequest | null, current: ThreadLinkedPullRequest | null, surface: RightPanelSurface | null, ): boolean { if (previous === null || current === null || surface?.kind !== "pull-request") return false; const previousRepository = previous.repository.toLowerCase(); return ( (previous.projectId !== current.projectId || previousRepository !== current.repository.toLowerCase() || previous.number !== current.number) && surface.projectId === previous.projectId && surface.repository.toLowerCase() === previousRepository && surface.number === previous.number ); } export function shouldOpenProactiveTurnDiff(input: { previousRunningTurnId: TurnId | null | undefined; runningTurnId: TurnId | null; settledTurnId: TurnId | null; turnCompleted: boolean; }): boolean { return ( input.runningTurnId === null && input.turnCompleted && input.settledTurnId !== null && (input.previousRunningTurnId === undefined || input.settledTurnId === input.previousRunningTurnId) ); } export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined; isGitRepo: boolean | undefined; }): "defer" | "ignore" | "open" { if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; if (input.isGitRepo === undefined) return "defer"; if ( !input.isGitRepo || input.checkpoint.status !== "ready" || input.checkpoint.files.length === 0 ) { return "ignore"; } return "open"; } export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, ): string | null { return appendCodexArtifactTemplateUsePrompt(currentDraft, template) === currentDraft ? null : codexArtifactTemplateUsePrompt(template); } export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; submissionIntent: ComposerSubmissionIntent; }): boolean { return ( input.submissionIntent === "foreground" && input.isDraftHeroState && input.activeThreadKey !== null ); } export function shouldReleaseTimelineAnchorForToolActivity(input: { anchorMessageId: MessageId | null; liveFollowEnabled: boolean; runningTurnId: TurnId | null; timelineEntries: ReadonlyArray<TimelineEntry>; }): boolean { if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { return false; } return input.timelineEntries.some((timelineEntry) => { if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) { return false; } const entry = timelineEntry.entry; return ( entry.tone === "tool" || entry.itemType !== undefined || entry.requestKind !== undefined || (entry.command?.trim().length ?? 0) > 0 ); }); } export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { const elementTarget = target instanceof Element ? target : null; const group = elementTarget?.closest<HTMLElement>("[data-tool-group-scroll]"); if (!group) return false; // A nested result or the group itself can consume an upward scroll. for (let element = elementTarget; element; element = element.parentElement) { if (element.scrollTop > 0) { const overflowY = getComputedStyle(element).overflowY; if (overflowY === "auto" || overflowY === "scroll") return true; } if (element === group) break; } return false; } export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; hasTimelineEntries: boolean; isWorking: boolean; draftHeroDockRequested: boolean; backgroundSubmissionPending: boolean; }): boolean { if (input.backgroundSubmissionPending) { return true; } return ( input.isLocalDraftThread && !input.hasTimelineEntries && !input.isWorking && !input.draftHeroDockRequested ); } /** * Keep painted timelines on screen across thread jumps. Remounting LegendList * (or handing it an empty first paint) punches a hole through the chat pane — * white in light mode — so cmd+1/2/3 spam flashes even when the destination * is already cached. * * Stored at module scope because ChatView remounts when the thread route * changes (same pattern as the thread-error banner session dismissals). * Remember more than the last thread so jumping back to cmd+1 does not show * cmd+3's messages, and so a cached destination can paint on the first frame. */ export type HeldThreadTimeline<T extends readonly unknown[]> = { threadKey: string | null; entries: T; markdownCwd?: string | null; workspaceRoot?: string | null; }; const MAX_REMEMBERED_THREAD_TIMELINES = 16; let rememberedThreadTimelines = new Map<string, HeldThreadTimeline<readonly unknown[]>>(); let rememberedThreadTimelineOrder: string[] = []; let lastReadyThreadKey: string | null = null; function rememberThreadTimelineEntries(held: HeldThreadTimeline<readonly unknown[]>): void { if (held.threadKey === null) { return; } rememberedThreadTimelines.set(held.threadKey, held); rememberedThreadTimelineOrder = [ ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey), held.threadKey, ]; while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) { const evicted = rememberedThreadTimelineOrder.shift(); if (evicted !== undefined) { rememberedThreadTimelines.delete(evicted); } } lastReadyThreadKey = held.threadKey; } export function rememberReadyThreadTimeline<T extends readonly unknown[]>( held: HeldThreadTimeline<T>, ): void { if (held.threadKey === null || held.entries.length === 0) { return; } rememberThreadTimelineEntries(held); } export function peekRememberedThreadTimeline<T extends readonly unknown[]>( threadKey: string | null, ): T | null { if (threadKey === null) { return null; } return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null; } export function peekHeldThreadTimeline< T extends readonly unknown[], >(): HeldThreadTimeline<T> | null { if (lastReadyThreadKey === null) { return null; } const held = rememberedThreadTimelines.get(lastReadyThreadKey); if (held === undefined || held.entries.length === 0) { return null; } return held as HeldThreadTimeline<T>; } export function resetHeldThreadTimeline(): void { rememberedThreadTimelines = new Map(); rememberedThreadTimelineOrder = []; lastReadyThreadKey = null; } export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { if (left === null || right === null) { return false; } const leftRef = parseScopedThreadKey(left); const rightRef = parseScopedThreadKey(right); return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId; } /** True while we still paint another thread's last snapshot. */ export function isPaintOnlyThreadTimeline( displayThreadKey: string | null, activeThreadKey: string | null, ): boolean { return ( displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey ); } export function resolveThreadSwitchTimeline<T extends readonly unknown[]>(input: { loading: boolean; activeThreadKey: string | null; nextEntries: T; rememberedForActive?: T | null; lastReady?: HeldThreadTimeline<T> | null; }): { entries: T; displayThreadKey: string | null } { if (input.nextEntries.length > 0) { return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; } const rememberedForActive = input.rememberedForActive ?? peekRememberedThreadTimeline<T>(input.activeThreadKey); if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) { return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; } const lastReady = input.lastReady ?? peekHeldThreadTimeline<T>(); if ( input.loading && lastReady !== null && lastReady.threadKey !== null && lastReady.threadKey !== input.activeThreadKey && lastReady.entries.length > 0 && threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey) ) { return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey }; } return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; } export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined; backgroundSubmissionPending: boolean; }): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { return null; } const sessionStatus = input.serverThread?.session?.status; const turnStarted = input.serverThread?.latestTurn?.startedAt != null; const startupStopped = sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; // Keep local preparation feedback mounted until the server can render the // running turn or its startup error on the canonical thread route. return turnStarted || startupStopped ? input.serverThreadRef : null; } export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); return () => globalThis.clearTimeout(timeoutId); } export function hasEnvironmentReconnectWarningGraceElapsed( activeEnvironmentId: EnvironmentId | null, elapsedEnvironmentId: EnvironmentId | null, ): boolean { return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; } export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise<unknown>, ): boolean { if (projectRef === null) return false; void handleNewThread(projectRef); return true; } export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; currentBranch: string | null; nextBranch?: string; }): { modelSelection?: ModelSelection; branch?: string; worktreePath?: null; } | null { const nextModelSelection = input.nextModelSelection; const modelSelectionChanged = nextModelSelection !== undefined && (nextModelSelection.model !== input.currentModelSelection.model || nextModelSelection.instanceId !== input.currentModelSelection.instanceId || JSON.stringify(nextModelSelection.options ?? null) !== JSON.stringify(input.currentModelSelection.options ?? null)); const branchChanged = input.nextBranch !== undefined && input.nextBranch !== input.currentBranch; if (!modelSelectionChanged && !branchChanged) { return null; } return { ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}), ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}), }; } export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection, ): Thread { return { id: threadId, environmentId: draftThread.environmentId, projectId: draftThread.projectId, title: "New thread", modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, session: null, messages: [], createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, checkpoints: [], pullRequests: [], activities: [], proposedPlans: [], }; } export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { return { ...shell, messages: [], proposedPlans: [], activities: [], checkpoints: [], deletedAt: null, };