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 TurnId,} from "@t3tools/contracts";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 { shallow } from "zustand/vanilla/shallow";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 previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey;} export function shouldOpenProactiveTurnDiff(input: { previousRunningTurnId: TurnId | null | undefined; runningTurnId: TurnId | null; settledTurnId: TurnId | null; turnCompleted: boolean;}): boolean { return ( input.previousRunningTurnId !== undefined && input.previousRunningTurnId !== null && input.runningTurnId === null && input.turnCompleted && input.settledTurnId === input.previousRunningTurnId );} export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined; isGitRepo: boolean | undefined; activeSurfaceKind: RightPanelSurface["kind"] | null;}): "defer" | "ignore" | "open" { if (input.activeSurfaceKind === "pull-request") return "ignore"; 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 );} 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: [], activities: [], proposedPlans: [], };} export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { return { ...shell, messages: [], proposedPlans: [], activities: [], checkpoints: [], deletedAt: null, };} export function shouldWriteThreadErrorToCurrentServerThread(input: { activeServerThread: | { environmentId: EnvironmentId; id: ThreadId; } | null | undefined; routeThreadRef: ScopedThreadRef; targetThreadId: ThreadId;}): boolean { return Boolean( input.activeServerThread && input.targetThreadId === input.routeThreadRef.threadId && input.activeServerThread.environmentId === input.routeThreadRef.environmentId && input.activeServerThread.id === input.targetThreadId, );} export function buildThreadTurnInterruptInput(thread: Pick<Thread, "id" | "session">): { threadId: ThreadId; turnId?: TurnId;} { const runningTurnId = thread.session?.status === "running" ? thread.session.activeTurnId : null; return { threadId: thread.id, ...(runningTurnId !== null ? { turnId: runningTurnId } : {}), };} /** Use the same enabled instance for the composer, provider status, and chat actions. */export function resolveComposerProviderSelection(input: { entries: ReadonlyArray<ProviderInstanceEntry>; candidateInstanceIds: ReadonlyArray<ProviderInstanceId | null | undefined>; lockedProvider: ProviderDriverKind | null; lockedInstanceId: ProviderInstanceId | null | undefined;}) { const requestedInstanceId = input.candidateInstanceIds.find( (candidate) => candidate != null && candidate !== NO_PROVIDER_MODEL_SELECTION.instanceId, ); const requestedDriverKind = input.lockedProvider ?? input.entries.find((entry) => entry.instanceId === requestedInstanceId)?.driverKind ?? input.entries[0]?.driverKind ?? ProviderDriverKind.make("unconfigured"); const lockedContinuationGroupKey = input.lockedProvider ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId) ?.continuationGroupKey ?? null) : null; // Missing metadata must not move Antigravity history into another Google profile. const requiresExactInstance = input.lockedProvider === "antigravity" && input.lockedInstanceId != null && lockedContinuationGroupKey === null; const compatibleEntries = input.entries.filter( (entry) => (!input.lockedProvider || entry.driverKind === input.lockedProvider) && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey) && (!requiresExactInstance || entry.instanceId === input.lockedInstanceId), ); const selectedProviderEntry = input.candidateInstanceIds .map((candidate) => compatibleEntries.find( (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, ), ) .find((entry) => entry !== undefined) ?? resolveSelectableProviderInstanceEntry( compatibleEntries.filter((entry) => entry.driverKind === requestedDriverKind), undefined, ) ?? resolveSelectableProviderInstanceEntry(compatibleEntries, undefined); const unavailableProviderInstanceId = selectedProviderEntry ? undefined : input.lockedProvider ? (input.lockedInstanceId ?? requestedInstanceId) : requestedInstanceId; return { selectedProviderEntry, requestedDriverKind, lockedContinuationGroupKey, unavailableProviderInstanceId, };} /** Keep restored drafts and every plan control on the selected instance's supported mode. */export function resolveComposerInteractionMode(input: { planModeEnabled: boolean; provider: Pick<ServerProvider, "showInteractionModeToggle"> | null | undefined; interactionMode: ProviderInteractionMode;}): { enabled: boolean; interactionMode: ProviderInteractionMode } { const enabled = input.planModeEnabled && input.provider != null && input.provider.showInteractionModeToggle !== false; return { enabled, interactionMode: enabled ? input.interactionMode : "default", };} export function getAntigravitySendBlockReason( provider: | Pick<ServerProvider, "driver" | "installed" | "auth" | "models" | "status"> | null | undefined, model: string,): string | null { if (provider?.driver !== "antigravity") return null; if (!provider.installed) { return "Install Antigravity in provider settings before sending."; } if (provider.auth.status === "unauthenticated") { return "Sign in to Antigravity in provider settings before sending."; } const slug = model.trim(); if (slug.length === 0) return "Choose an Antigravity model before sending."; // A restart clears the account status and catalog. Session startup checks // saved credentials and validates the model before sending the prompt. if (provider.auth.status === "unknown") return null; if (provider.models.length === 0) { return "Refresh Antigravity models in provider settings before sending."; } // A saved model that left the catalog is kept in the picker as unavailable // so the user sees what the thread used. The server rejects it at turn // start, so block here unless the provider is in an error state, where a // retry with the same model is the right move. if ( provider.status === "ready" && slug !== ANTIGRAVITY_DEFAULT_MODEL && !provider.models.some((entry) => entry.slug === slug || entry.aliases?.includes(slug)) ) { return "That Antigravity model is no longer available. Choose another model."; } return null;} /** * Maps each user message to the checkpoint turn count a revert should target. * Returns `previous` when the result is unchanged: streaming text deltas * rebuild `timelineEntries` per token, and the timeline row projection only * reuses rows while this Map keeps its identity. */export function buildRevertTurnCountByUserMessageId( input: { supportsConversationRollback: boolean; timelineEntries: ReadonlyArray<TimelineEntry>; turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; inferredCheckpointTurnCountByTurnId: Readonly<Record<string, number | undefined>>; }, previous: Map<MessageId, number> | null = null,): Map<MessageId, number> { const byUserMessageId = new Map<MessageId, number>(); const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; for (let index = 0; index < entryCount; index += 1) { const entry = input.timelineEntries[index]; if (!entry || entry.kind !== "message" || entry.message.role !== "user") { continue; } for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) { const nextEntry = input.timelineEntries[nextIndex]; if (!nextEntry || nextEntry.kind !== "message") { continue; } if (nextEntry.message.role === "user") { break; } const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); if (!summary) {Lines 1–500 of 1034. 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 TurnId, } from "@t3tools/contracts"; 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 { shallow } from "zustand/vanilla/shallow"; 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 previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey; } export function shouldOpenProactiveTurnDiff(input: { previousRunningTurnId: TurnId | null | undefined; runningTurnId: TurnId | null; settledTurnId: TurnId | null; turnCompleted: boolean; }): boolean { return ( input.previousRunningTurnId !== undefined && input.previousRunningTurnId !== null && input.runningTurnId === null && input.turnCompleted && input.settledTurnId === input.previousRunningTurnId ); } export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined; isGitRepo: boolean | undefined; activeSurfaceKind: RightPanelSurface["kind"] | null; }): "defer" | "ignore" | "open" { if (input.activeSurfaceKind === "pull-request") return "ignore"; 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 ); } 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: [], activities: [], proposedPlans: [], }; } export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { return { ...shell, messages: [], proposedPlans: [], activities: [], checkpoints: [], deletedAt: null, }; } export function shouldWriteThreadErrorToCurrentServerThread(input: { activeServerThread: | { environmentId: EnvironmentId; id: ThreadId; } | null | undefined; routeThreadRef: ScopedThreadRef; targetThreadId: ThreadId; }): boolean { return Boolean( input.activeServerThread && input.targetThreadId === input.routeThreadRef.threadId && input.activeServerThread.environmentId === input.routeThreadRef.environmentId && input.activeServerThread.id === input.targetThreadId, ); } export function buildThreadTurnInterruptInput(thread: Pick<Thread, "id" | "session">): { threadId: ThreadId; turnId?: TurnId; } { const runningTurnId = thread.session?.status === "running" ? thread.session.activeTurnId : null; return { threadId: thread.id, ...(runningTurnId !== null ? { turnId: runningTurnId } : {}), }; } /** Use the same enabled instance for the composer, provider status, and chat actions. */ export function resolveComposerProviderSelection(input: { entries: ReadonlyArray<ProviderInstanceEntry>; candidateInstanceIds: ReadonlyArray<ProviderInstanceId | null | undefined>; lockedProvider: ProviderDriverKind | null; lockedInstanceId: ProviderInstanceId | null | undefined; }) { const requestedInstanceId = input.candidateInstanceIds.find( (candidate) => candidate != null && candidate !== NO_PROVIDER_MODEL_SELECTION.instanceId, ); const requestedDriverKind = input.lockedProvider ?? input.entries.find((entry) => entry.instanceId === requestedInstanceId)?.driverKind ?? input.entries[0]?.driverKind ?? ProviderDriverKind.make("unconfigured"); const lockedContinuationGroupKey = input.lockedProvider ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId) ?.continuationGroupKey ?? null) : null; // Missing metadata must not move Antigravity history into another Google profile. const requiresExactInstance = input.lockedProvider === "antigravity" && input.lockedInstanceId != null && lockedContinuationGroupKey === null; const compatibleEntries = input.entries.filter( (entry) => (!input.lockedProvider || entry.driverKind === input.lockedProvider) && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey) && (!requiresExactInstance || entry.instanceId === input.lockedInstanceId), ); const selectedProviderEntry = input.candidateInstanceIds .map((candidate) => compatibleEntries.find( (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, ), ) .find((entry) => entry !== undefined) ?? resolveSelectableProviderInstanceEntry( compatibleEntries.filter((entry) => entry.driverKind === requestedDriverKind), undefined, ) ?? resolveSelectableProviderInstanceEntry(compatibleEntries, undefined); const unavailableProviderInstanceId = selectedProviderEntry ? undefined : input.lockedProvider ? (input.lockedInstanceId ?? requestedInstanceId) : requestedInstanceId; return { selectedProviderEntry, requestedDriverKind, lockedContinuationGroupKey, unavailableProviderInstanceId, }; } /** Keep restored drafts and every plan control on the selected instance's supported mode. */ export function resolveComposerInteractionMode(input: { planModeEnabled: boolean; provider: Pick<ServerProvider, "showInteractionModeToggle"> | null | undefined; interactionMode: ProviderInteractionMode; }): { enabled: boolean; interactionMode: ProviderInteractionMode } { const enabled = input.planModeEnabled && input.provider != null && input.provider.showInteractionModeToggle !== false; return { enabled, interactionMode: enabled ? input.interactionMode : "default", }; } export function getAntigravitySendBlockReason( provider: | Pick<ServerProvider, "driver" | "installed" | "auth" | "models" | "status"> | null | undefined, model: string, ): string | null { if (provider?.driver !== "antigravity") return null; if (!provider.installed) { return "Install Antigravity in provider settings before sending."; } if (provider.auth.status === "unauthenticated") { return "Sign in to Antigravity in provider settings before sending."; } const slug = model.trim(); if (slug.length === 0) return "Choose an Antigravity model before sending."; // A restart clears the account status and catalog. Session startup checks // saved credentials and validates the model before sending the prompt. if (provider.auth.status === "unknown") return null; if (provider.models.length === 0) { return "Refresh Antigravity models in provider settings before sending."; } // A saved model that left the catalog is kept in the picker as unavailable // so the user sees what the thread used. The server rejects it at turn // start, so block here unless the provider is in an error state, where a // retry with the same model is the right move. if ( provider.status === "ready" && slug !== ANTIGRAVITY_DEFAULT_MODEL && !provider.models.some((entry) => entry.slug === slug || entry.aliases?.includes(slug)) ) { return "That Antigravity model is no longer available. Choose another model."; } return null; } /** * Maps each user message to the checkpoint turn count a revert should target. * Returns `previous` when the result is unchanged: streaming text deltas * rebuild `timelineEntries` per token, and the timeline row projection only * reuses rows while this Map keeps its identity. */ export function buildRevertTurnCountByUserMessageId( input: { supportsConversationRollback: boolean; timelineEntries: ReadonlyArray<TimelineEntry>; turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; inferredCheckpointTurnCountByTurnId: Readonly<Record<string, number | undefined>>; }, previous: Map<MessageId, number> | null = null, ): Map<MessageId, number> { const byUserMessageId = new Map<MessageId, number>(); const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; for (let index = 0; index < entryCount; index += 1) { const entry = input.timelineEntries[index]; if (!entry || entry.kind !== "message" || entry.message.role !== "user") { continue; } for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) { const nextEntry = input.timelineEntries[nextIndex]; if (!nextEntry || nextEntry.kind !== "message") { continue; } if (nextEntry.message.role === "user") { break; } const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); if (!summary) {