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 { 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 );} 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, };} 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.Lines 1–500 of 1109. 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 { 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 ); } 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, }; } 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.