import { ANTIGRAVITY_DEFAULT_MODEL, type AssetCreateUrlInput, type AssetCreateUrlResult, type ChatFileAttachment, type EnvironmentId, isProviderDriverKind, ProjectId, type MessageId, type ModelSelection, type OrchestrationV2ProjectedTurnItem, type ProviderInteractionMode, ProviderDriverKind, type ProviderInstanceId, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, type ThreadId, type RunId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; 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 { presentThreadShell } from "@t3tools/client-runtime/state/shell"; import { type ChatMessage, isImageAttachment, type SessionPhase, type Thread, type TurnDiffSummary, } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells } from "../state/threads"; import { waitForAtomValue } from "../state/waitForAtomValue"; 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 function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], desktopByTabId: Readonly | 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: RunId | null | undefined; runningTurnId: RunId | null; settledTurnId: RunId | 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 | 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 const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function resolveEffectiveInteractionMode(input: { planModeEnabled: boolean; composerInteractionMode: ProviderInteractionMode | null; threadInteractionMode: ProviderInteractionMode | null | undefined; }): ProviderInteractionMode { if (!input.planModeEnabled) return "default"; return input.composerInteractionMode ?? input.threadInteractionMode ?? "default"; } 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: RunId | null; timelineEntries: ReadonlyArray; }): boolean { if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { return false; } return input.timelineEntries.some((timelineEntry) => { if (timelineEntry.kind !== "work" || timelineEntry.entry.runId !== 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("[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 | null | undefined; backgroundSubmissionPending: boolean; }): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { return null; } const latestRun = input.serverThread?.latestRun ?? null; const runStarted = latestRun?.startedAt != null; const startupStopped = latestRun?.status === "failed" || latestRun?.status === "interrupted" || latestRun?.status === "cancelled"; // Keep local preparation feedback mounted until the server can render the // running turn or its startup error on the canonical thread route. return runStarted || 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, ): 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 { const timestamp = DateTime.makeUnsafe(draftThread.createdAt); return presentThreadShell(draftThread.environmentId, { id: threadId, projectId: draftThread.projectId, title: "New thread", providerInstanceId: fallbackModelSelection.instanceId, modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, branch: draftThread.branch, worktreePath: draftThread.worktreePath, activeProviderThreadId: null, lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, forkedFrom: null, createdBy: "user", creationSource: "web", latestRunId: null, activeRunId: null, status: "idle", pendingRuntimeRequest: null, latestVisibleMessage: null, latestUserMessageAt: null, hasActionableProposedPlan: false, itemCount: 0, visibleItemCount: 0, createdAt: timestamp, updatedAt: timestamp, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, }); } export function shouldWriteThreadErrorToCurrentServerThread(input: { serverThread: | { environmentId: EnvironmentId; id: ThreadId; } | null | undefined; routeThreadRef: ScopedThreadRef; targetThreadId: ThreadId; }): boolean { return Boolean( input.serverThread && input.targetThreadId === input.routeThreadRef.threadId && input.serverThread.environmentId === input.routeThreadRef.environmentId && input.serverThread.id === input.targetThreadId, ); } /** Use the same enabled instance for the composer, provider status, and chat actions. */ export function resolveComposerProviderSelection(input: { entries: ReadonlyArray; candidateInstanceIds: ReadonlyArray; 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 | 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 | 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; } export function reconcileMountedTerminalThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; }): string[] { return reconcileRetainedMountedThreadIds({ currentThreadIds: input.currentThreadIds, openThreadIds: input.openThreadIds, activeThreadId: input.activeThreadId, activeThreadOpen: input.activeThreadTerminalOpen, maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); } export function reconcileRetainedMountedThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; activeThreadId: string | null; activeThreadOpen: boolean; maxHiddenThreadCount: number; retainInactiveActiveThread?: boolean; }): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( (threadId) => (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) && openThreadIdSet.has(threadId), ); const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount); const nextThreadIds = hiddenThreadIds.length > maxHiddenThreadCount ? hiddenThreadIds.slice(-maxHiddenThreadCount) : hiddenThreadIds; if ( input.activeThreadId && input.activeThreadOpen && !nextThreadIds.includes(input.activeThreadId) ) { nextThreadIds.push(input.activeThreadId); } return nextThreadIds; } export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; } URL.revokeObjectURL(previewUrl); } /** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */ export async function resolveFileAttachmentUrl(input: { attachment: ChatFileAttachment; environmentId: EnvironmentId; httpBaseUrl: string; createAssetUrl: (input: { environmentId: EnvironmentId; input: AssetCreateUrlInput; }) => Promise>; }): Promise { const { attachment } = input; const result = await input.createAssetUrl({ environmentId: input.environmentId, input: { resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType: videoMimeType(attachment) ?? attachment.mimeType, }, }, }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); if (url === null) throw new Error("The environment returned an invalid attachment URL."); return url; } export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; } for (const attachment of message.attachments) { if (!isImageAttachment(attachment)) { continue; } revokeBlobPreviewUrl(attachment.previewUrl); } } export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { if (message.role !== "user" || !message.attachments) { return []; } const previewUrls: string[] = []; for (const attachment of message.attachments) { if (!isImageAttachment(attachment)) continue; if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; previewUrls.push(attachment.previewUrl); } return previewUrls; } export interface PullRequestDialogState { initialReference: string | null; key: number; } export function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => { if (typeof reader.result === "string") { resolve(reader.result); return; } reject(new Error("Could not read image data.")); }); reader.addEventListener("error", () => { reject(reader.error ?? new Error("Failed to read image.")); }); reader.readAsDataURL(file); }); } export function resolveSendEnvMode(input: { requestedEnvMode: DraftThreadEnvMode; isGitRepo: boolean; }): DraftThreadEnvMode { return input.isGitRepo ? input.requestedEnvMode : "local"; } export function shouldShowComposerContextStrip(input: { isDraftHeroState: boolean; isGitRepo: boolean; hasActiveProject: boolean; persistInActiveThreads: boolean; }): boolean { return ( input.isGitRepo && input.hasActiveProject && (input.isDraftHeroState || input.persistInActiveThreads) ); } export function resolveBackgroundDraftWorkspaceOptions(input: { envMode: DraftThreadEnvMode; branch: string | null; startFromOrigin: boolean; }): { envMode: DraftThreadEnvMode; branch: string | null; worktreePath: null; startFromOrigin: boolean; } { return { envMode: input.envMode, branch: input.branch, worktreePath: null, startFromOrigin: input.envMode === "worktree" && input.startFromOrigin, }; } export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } try { return { ...image, previewUrl: URL.createObjectURL(image.file), }; } catch { return image; } } export function deriveComposerSendState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray; /** * Optional element-pick attachment count. Element contexts contribute to * "sendable content" exactly like images and (text-bearing) terminal * contexts do: a prompt of just element chips is still a valid send. */ elementContextCount?: number; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; expiredTerminalContextCount: number; hasSendableContent: boolean; } { const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; const elementContextCount = options.elementContextCount ?? 0; return { trimmedPrompt, sendableTerminalContexts, expiredTerminalContextCount, hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0 || elementContextCount > 0, }; } export function buildExpiredTerminalContextToastCopy( expiredTerminalContextCount: number, variant: "omitted" | "empty", ): { title: string; description: string } { const count = Math.max(1, Math.floor(expiredTerminalContextCount)); const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; if (variant === "empty") { return { title: `${noun} won't be sent`, description: "Remove it or re-add it to include terminal output.", }; } return { title: `${noun} omitted from message`, description: "Re-add it if you want that terminal output included.", }; } export function branchMismatchKey( threadId: string | null, mismatch: { threadBranch: string; currentBranch: string } | null, ): string | null { if (!threadId || !mismatch) { return null; } return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`; } // The mismatch banner only matters when the user is about to send: passive // reading of an old thread carries no risk (the branch picker tint already // covers ambient awareness). Draft content is the intent signal — composer // focus is useless here because ChatView autofocuses the composer on every // thread open. `wasShownForCurrentMismatch` keeps the banner mounted once // revealed so it doesn't flicker away when the draft is cleared. export function shouldShowBranchMismatchBanner(input: { hasMismatch: boolean; isDismissed: boolean; composerHasContent: boolean; wasShownForCurrentMismatch: boolean; }): boolean { if (!input.hasMismatch || input.isDismissed) { return false; } return input.composerHasContent || input.wasShownForCurrentMismatch; } export function shouldShowPlanFollowUpPrompt(input: { pendingUserInputCount: number; interactionMode: ProviderInteractionMode; latestTurnSettled: boolean; hasActionableProposedPlan: boolean; hasComposerAttachments: boolean; }): boolean { return ( input.pendingUserInputCount === 0 && input.interactionMode === "plan" && input.latestTurnSettled && input.hasActionableProposedPlan && !input.hasComposerAttachments ); } // Session-scoped (module-level so it survives ChatView remounts, e.g. route // changes). Durable cross-device dismissal is planned as a server-side ack. const sessionDismissedBranchMismatchKeys = new Set(); export function dismissBranchMismatchForSession(key: string): void { sessionDismissedBranchMismatchKeys.add(key); } export function isBranchMismatchDismissedForSession(key: string | null): boolean { return key !== null && sessionDismissedBranchMismatchKeys.has(key); } export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); } // `threadProvider` is the open branded driver kind carried by the session. // Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe // rollback / fork behavior — the routing layer is the right place to surface // "driver not installed" errors, not the lock state. // // `selectedProvider` takes the same open-string shape because the composer // now tracks the picker selection as a `ProviderInstanceId` (e.g. // `codex_personal`). Custom instance ids that don't directly match a // registered driver resolve to `null` here, which matches the existing // "unknown driver -> unlocked" semantics. Callers that want the lock to track // a custom instance's underlying driver kind should resolve the instance id // upstream and pass the correlated kind. export function deriveLockedProvider(input: { thread: Thread | null | undefined; selectedProvider: string | null; threadProvider: string | null; }): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; } const sessionProvider = input.thread?.runtime?.providerName ?? null; if (sessionProvider && isProviderDriverKind(sessionProvider)) { return sessionProvider; } const narrowedThreadProvider = input.threadProvider && isProviderDriverKind(input.threadProvider) ? input.threadProvider : null; const narrowedSelectedProvider = input.selectedProvider && isProviderDriverKind(input.selectedProvider) ? input.selectedProvider : null; return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } export function getStartedThreadModelChangeBlockReason(input: { providers: ReadonlyArray>; hasStartedSession: boolean; supportsProviderSwitchingViaHandoff?: boolean; currentModelSelection: ModelSelection; currentProviderInstanceId?: ModelSelection["instanceId"] | null | undefined; nextModelSelection: ModelSelection; }): { title: string; description: string } | null { if (!input.hasStartedSession) { return null; } const currentModelSelection = { ...input.currentModelSelection, instanceId: input.currentProviderInstanceId ?? input.currentModelSelection.instanceId, }; if ( currentModelSelection.instanceId === input.nextModelSelection.instanceId && currentModelSelection.model === input.nextModelSelection.model ) { return null; } if (currentModelSelection.instanceId !== input.nextModelSelection.instanceId) { if (input.supportsProviderSwitchingViaHandoff === true) { return null; } return { title: "Start a new chat to switch providers", description: "This thread does not support switching providers after it has started.", }; } const currentProvider = input.providers.find( (snapshot) => snapshot.instanceId === currentModelSelection.instanceId, ); const nextProvider = input.providers.find( (snapshot) => snapshot.instanceId === input.nextModelSelection.instanceId, ); if ( currentProvider?.requiresNewThreadForModelChange !== true && nextProvider?.requiresNewThreadForModelChange !== true ) { return null; } return { title: "Start a new chat to change models", description: "This provider does not allow switching models after a conversation has started.", }; } export async function waitForStartedServerThread( threadRef: ScopedThreadRef, timeoutMs = 1_000, ): Promise { const threadAtom = environmentThreadShells.threadShellAtom(threadRef); return waitForAtomValue({ registry: appAtomRegistry, atom: threadAtom, predicate: threadHasStarted, timeoutMs, }); } export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; submissionIntent: ComposerSubmissionIntent; latestUserMessageId: ChatMessage["id"] | null; latestRunId: RunId | null; latestRunRequestedAt: string | null; latestRunStartedAt: string | null; latestRunCompletedAt: string | null; runtimeStatus: NonNullable["status"] | null; runtimeUpdatedAt: string | null; } export function createLocalDispatchSnapshot( activeThread: Thread | undefined, options?: { preparingWorktree?: boolean; latestUserMessageId?: ChatMessage["id"] | null; submissionIntent?: ComposerSubmissionIntent; }, ): LocalDispatchSnapshot { const latestRun = activeThread?.latestRun ?? null; const runtime = activeThread?.runtime ?? null; return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), submissionIntent: options?.submissionIntent ?? "foreground", latestUserMessageId: options?.latestUserMessageId ?? null, latestRunId: latestRun?.runId ?? null, latestRunRequestedAt: latestRun?.requestedAt ?? null, latestRunStartedAt: latestRun?.startedAt ?? null, latestRunCompletedAt: latestRun?.completedAt ?? null, runtimeStatus: runtime?.status ?? null, runtimeUpdatedAt: runtime?.updatedAt ?? null, }; } /** * The timeline renders committed user rows from `visibleTurnItems`, but * `message.updated` can land in `projection.messages` one event earlier than * the matching `turn-item.updated`. Basing optimistic eviction on visible user * turn items avoids dropping steer rows in that gap. */ export function deriveCommittedServerUserMessageIds( visibleTurnItems: ReadonlyArray, ): ReadonlySet { return new Set( visibleTurnItems.flatMap((row) => row.item.type === "user_message" ? [row.item.messageId] : [], ), ); } export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; latestRun: Thread["latestRun"] | null; latestUserMessageId?: ChatMessage["id"] | null; runtime: Thread["runtime"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; threadError: string | null | undefined; }): boolean { if (!input.localDispatch) { return false; } if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } if (input.phase === "connecting") { return false; } const latestRun = input.latestRun ?? null; const runtime = input.runtime ?? null; const latestUserMessageChanged = input.localDispatch.latestUserMessageId !== (input.latestUserMessageId ?? null); const latestRunChanged = input.localDispatch.latestRunId !== (latestRun?.runId ?? null) || input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) || input.localDispatch.latestRunStartedAt !== (latestRun?.startedAt ?? null) || input.localDispatch.latestRunCompletedAt !== (latestRun?.completedAt ?? null); if (input.phase === "running") { if (latestUserMessageChanged) { return true; } if (!latestRunChanged) { return false; } if (latestRun?.startedAt === null || latestRun === null) { return false; } if ( runtime?.activeRunId !== null && runtime?.activeRunId !== undefined && latestRun?.runId !== runtime.activeRunId ) { return false; } return true; } return ( latestRunChanged || input.localDispatch.runtimeStatus !== (runtime?.status ?? null) || input.localDispatch.runtimeUpdatedAt !== (runtime?.updatedAt ?? null) ); }