import { type EnvironmentId, isProviderDriverKind, ProjectId, type ModelSelection, type ProviderDriverKind, type ServerProvider, type ScopedThreadRef, type ThreadId, type TurnId, } from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread } 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"; 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 LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); 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 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, ); } export function buildThreadTurnInterruptInput(thread: Pick): { threadId: ThreadId; turnId?: TurnId; } { const runningTurnId = thread.session?.status === "running" ? thread.session.activeTurnId : null; return { threadId: thread.id, ...(runningTurnId !== null ? { turnId: runningTurnId } : {}), }; } 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); } export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; } for (const attachment of message.attachments) { if (attachment.type !== "image") { 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 (attachment.type !== "image") 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 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; } // 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.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), ); } // `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?.session?.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; 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; } 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 = environmentThreadDetails.detailAtom(threadRef); const getThread = () => appAtomRegistry.get(threadAtom); const thread = getThread(); if (threadHasStarted(thread)) { return true; } return await new Promise((resolve) => { let settled = false; let timeoutId: ReturnType | null = null; const finish = (result: boolean) => { if (settled) { return; } settled = true; if (timeoutId !== null) { globalThis.clearTimeout(timeoutId); } unsubscribe(); resolve(result); }; const unsubscribe = appAtomRegistry.subscribe(threadAtom, (thread) => { if (!threadHasStarted(thread)) { return; } finish(true); }); if (threadHasStarted(getThread())) { finish(true); return; } timeoutId = globalThis.setTimeout(() => { finish(false); }, timeoutMs); }); } export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; latestUserMessageId: ChatMessage["id"] | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; } export function createLocalDispatchSnapshot( activeThread: Thread | undefined, options?: { preparingWorktree?: boolean }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user"); return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), latestUserMessageId: latestUserMessage?.id ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, latestTurnCompletedAt: latestTurn?.completedAt ?? null, sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, }; } export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; latestTurn: Thread["latestTurn"] | null; latestUserMessageId: ChatMessage["id"] | null; session: Thread["session"] | 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; } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; const latestUserMessageChanged = input.localDispatch.latestUserMessageId !== input.latestUserMessageId; const latestTurnChanged = input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); if (input.phase === "running") { // Steering adds a user message to the current running turn without // necessarily changing any of the turn timestamps. Treat that projected // message as the server acknowledgment so the composer does not remain // stuck in its local "Sending" state until the turn settles. if (latestUserMessageChanged) { return true; } if (!latestTurnChanged) { return false; } if (latestTurn?.startedAt === null || latestTurn === null) { return false; } if ( session?.activeTurnId !== null && session?.activeTurnId !== undefined && latestTurn?.turnId !== session.activeTurnId ) { return false; } return true; } return ( latestTurnChanged || input.localDispatch.sessionStatus !== (session?.status ?? null) || input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); }