import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId,} from "@t3tools/contracts";import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types";import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread,} from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local");const projectId = ProjectId.make("project-1");const threadId = ThreadId.make("thread-1");const now = "2026-03-29T00:00:00.000Z"; describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( shouldDockDraftHeroForSubmission({ isDraftHeroState: true, activeThreadKey: "environment-local:thread-1", submissionIntent: "background", }), ).toBe(false); }); it("keeps the composer in the hero layout until navigation after server promotion", () => { expect( resolveDraftHeroState({ isLocalDraftThread: false, hasTimelineEntries: true, isWorking: true, draftHeroDockRequested: false, backgroundSubmissionPending: true, }), ).toBe(true); }); it("does not auto-navigate a background submission after server promotion", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, serverThreadStarted: true, backgroundSubmissionPending: true, }), ).toBeNull(); });}); describe("shouldReleaseTimelineAnchorForToolActivity", () => { const activeTurnId = TurnId.make("active-turn"); const anchorMessageId = MessageId.make("anchored-message"); const activeToolEntry = { id: "tool-entry", kind: "work" as const, createdAt: now, entry: { id: "active-tool", createdAt: now, turnId: activeTurnId, label: "Run command", tone: "tool" as const, command: "git status", }, }; it("releases the send anchor for tool activity in the active turn", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(true); }); it("keeps the anchor while the user reads history", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: false, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(false); }); it("ignores tool activity from earlier turns", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { ...activeToolEntry.entry, turnId: TurnId.make("previous-turn"), }, }, ], }), ).toBe(false); }); it("ignores thinking and error rows without tool activity", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { id: "thinking-entry", createdAt: now, turnId: activeTurnId, label: "Thinking", tone: "thinking", }, }, { ...activeToolEntry, id: "error-entry", entry: { id: "error-entry", createdAt: now, turnId: activeTurnId, label: "Provider error", tone: "error", }, }, ], }), ).toBe(false); }); it("does nothing without an anchor or running turn", () => { const input = { anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }; expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( false, ); expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( false, ); });}); describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); it("shows a persistent reconnect after the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); scheduleEnvironmentReconnectWarning(showWarning); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); expect(showWarning).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(showWarning).toHaveBeenCalledOnce(); }); it("cancels the warning when the connection recovers during the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); const cancel = scheduleEnvironmentReconnectWarning(showWarning); cancel(); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); expect(showWarning).not.toHaveBeenCalled(); }); it("does not reuse elapsed grace from another environment", () => { const anotherEnvironmentId = EnvironmentId.make("environment-remote"); expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( false, ); });}); function makeThread(overrides: Partial<Thread> = {}): Thread { return { id: threadId, environmentId, projectId, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", session: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestTurn: null, branch: null, worktreePath: null, ...overrides, };} const completedTurn = { turnId: TurnId.make("turn-1"), state: "completed" as const, requestedAt: now, startedAt: "2026-03-29T00:00:01.000Z", completedAt: "2026-03-29T00:00:10.000Z", assistantMessageId: null,}; const readySession = { threadId, status: "ready" as const, providerName: "codex", providerInstanceId: ProviderInstanceId.make("codex"), runtimeMode: "full-access" as const, activeTurnId: null, lastError: null, updatedAt: "2026-03-29T00:00:10.000Z",}; describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { environmentId, id: threadId, projectId, title: "Loading thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", branch: "main", worktreePath: null, latestTurn: null, createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, snoozedUntil: null, snoozedAt: null, session: null, latestUserMessageAt: now, hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, } satisfies ThreadShell; expect(buildLoadingThreadFromShell(shell)).toMatchObject({ environmentId, id: threadId, projectId, title: "Loading thread", branch: "main", deletedAt: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], }); });}); describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }; it("updates a stale local thread branch to the active checkout", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, currentBranch: "feature/thread", nextBranch: "feature/checkout", }), ).toEqual({ branch: "feature/checkout", worktreePath: null }); }); it("does not write metadata when the model and branch are unchanged", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, nextModelSelection: modelSelection, currentBranch: "feature/current", nextBranch: "feature/current", }), ).toBeNull(); });}); describe("buildThreadTurnInterruptInput", () => { it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); expect( buildThreadTurnInterruptInput( makeThread({ session: { ...readySession, status: "running", activeTurnId, }, }), ), ).toEqual({ threadId, turnId: activeTurnId }); }); it("omits a turn id when the session is not running", () => { expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ threadId, }); });}); describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ prompt: "\uFFFC", imageCount: 0, terminalContexts: [ { id: "ctx-expired", threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", createdAt: now, }, ], }); expect(state.trimmedPrompt).toBe(""); expect(state.sendableTerminalContexts).toEqual([]); expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(false); }); it("keeps text sendable while excluding expired terminal pills", () => { const state = deriveComposerSendState({ prompt: `yoo \uFFFC waddup`, imageCount: 0, terminalContexts: [ { id: "ctx-expired", threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", createdAt: now, }, ], }); expect(state.trimmedPrompt).toBe("yoo waddup"); expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); it("treats element contexts as sendable content (no text, no images, no terminals)", () => { const state = deriveComposerSendState({ prompt: "", imageCount: 0, terminalContexts: [], elementContextCount: 1, }); expect(state.trimmedPrompt).toBe(""); expect(state.expiredTerminalContextCount).toBe(0); expect(state.hasSendableContent).toBe(true); }); it("does NOT treat zero element contexts as sendable", () => { expect( deriveComposerSendState({ prompt: "", imageCount: 0, terminalContexts: [], elementContextCount: 0, }).hasSendableContent, ).toBe(false); });}); describe("buildExpiredTerminalContextToastCopy", () => { it("formats empty and omission guidance", () => { expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ title: "Expired terminal context won't be sent", description: "Remove it or re-add it to include terminal output.", }); expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ title: "Expired terminal contexts omitted from message", description: "Re-add it if you want that terminal output included.", }); });}); describe("getStartedThreadModelChangeBlockReason", () => { const providers = [ { instanceId: ProviderInstanceId.make("codex"), }, { instanceId: ProviderInstanceId.make("grok"), requiresNewThreadForModelChange: true, }, ]; it("allows model changes before a provider session has started", () => { expect( getStartedThreadModelChangeBlockReason({ providers, hasStartedSession: false, currentModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build", }, nextModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-other", }, }), ).toBeNull(); }); it("allows unchanged model selections for restricted providers", () => { expect( getStartedThreadModelChangeBlockReason({ providers, hasStartedSession: true, currentModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build", }, nextModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build", }, }), ).toBeNull();Lines 1–500 of 906. Load more
import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId, } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( shouldDockDraftHeroForSubmission({ isDraftHeroState: true, activeThreadKey: "environment-local:thread-1", submissionIntent: "background", }), ).toBe(false); }); it("keeps the composer in the hero layout until navigation after server promotion", () => { expect( resolveDraftHeroState({ isLocalDraftThread: false, hasTimelineEntries: true, isWorking: true, draftHeroDockRequested: false, backgroundSubmissionPending: true, }), ).toBe(true); }); it("does not auto-navigate a background submission after server promotion", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, serverThreadStarted: true, backgroundSubmissionPending: true, }), ).toBeNull(); }); }); describe("shouldReleaseTimelineAnchorForToolActivity", () => { const activeTurnId = TurnId.make("active-turn"); const anchorMessageId = MessageId.make("anchored-message"); const activeToolEntry = { id: "tool-entry", kind: "work" as const, createdAt: now, entry: { id: "active-tool", createdAt: now, turnId: activeTurnId, label: "Run command", tone: "tool" as const, command: "git status", }, }; it("releases the send anchor for tool activity in the active turn", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(true); }); it("keeps the anchor while the user reads history", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: false, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(false); }); it("ignores tool activity from earlier turns", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { ...activeToolEntry.entry, turnId: TurnId.make("previous-turn"), }, }, ], }), ).toBe(false); }); it("ignores thinking and error rows without tool activity", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { id: "thinking-entry", createdAt: now, turnId: activeTurnId, label: "Thinking", tone: "thinking", }, }, { ...activeToolEntry, id: "error-entry", entry: { id: "error-entry", createdAt: now, turnId: activeTurnId, label: "Provider error", tone: "error", }, }, ], }), ).toBe(false); }); it("does nothing without an anchor or running turn", () => { const input = { anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }; expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( false, ); expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( false, ); }); }); describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); it("shows a persistent reconnect after the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); scheduleEnvironmentReconnectWarning(showWarning); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); expect(showWarning).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(showWarning).toHaveBeenCalledOnce(); }); it("cancels the warning when the connection recovers during the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); const cancel = scheduleEnvironmentReconnectWarning(showWarning); cancel(); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); expect(showWarning).not.toHaveBeenCalled(); }); it("does not reuse elapsed grace from another environment", () => { const anotherEnvironmentId = EnvironmentId.make("environment-remote"); expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( false, ); }); }); function makeThread(overrides: Partial<Thread> = {}): Thread { return { id: threadId, environmentId, projectId, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", session: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestTurn: null, branch: null, worktreePath: null, ...overrides, }; } const completedTurn = { turnId: TurnId.make("turn-1"), state: "completed" as const, requestedAt: now, startedAt: "2026-03-29T00:00:01.000Z", completedAt: "2026-03-29T00:00:10.000Z", assistantMessageId: null, }; const readySession = { threadId, status: "ready" as const, providerName: "codex", providerInstanceId: ProviderInstanceId.make("codex"), runtimeMode: "full-access" as const, activeTurnId: null, lastError: null, updatedAt: "2026-03-29T00:00:10.000Z", }; describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { environmentId, id: threadId, projectId, title: "Loading thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", branch: "main", worktreePath: null, latestTurn: null, createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, snoozedUntil: null, snoozedAt: null, session: null, latestUserMessageAt: now, hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, } satisfies ThreadShell; expect(buildLoadingThreadFromShell(shell)).toMatchObject({ environmentId, id: threadId, projectId, title: "Loading thread", branch: "main", deletedAt: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], }); }); }); describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }; it("updates a stale local thread branch to the active checkout", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, currentBranch: "feature/thread", nextBranch: "feature/checkout", }), ).toEqual({ branch: "feature/checkout", worktreePath: null }); }); it("does not write metadata when the model and branch are unchanged", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, nextModelSelection: modelSelection, currentBranch: "feature/current", nextBranch: "feature/current", }), ).toBeNull(); }); }); describe("buildThreadTurnInterruptInput", () => { it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); expect( buildThreadTurnInterruptInput( makeThread({ session: { ...readySession, status: "running", activeTurnId, }, }), ), ).toEqual({ threadId, turnId: activeTurnId }); }); it("omits a turn id when the session is not running", () => { expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ threadId, }); }); }); describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ prompt: "\uFFFC", imageCount: 0, terminalContexts: [ { id: "ctx-expired", threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", createdAt: now, }, ], }); expect(state.trimmedPrompt).toBe(""); expect(state.sendableTerminalContexts).toEqual([]); expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(false); }); it("keeps text sendable while excluding expired terminal pills", () => { const state = deriveComposerSendState({ prompt: `yoo \uFFFC waddup`, imageCount: 0, terminalContexts: [ { id: "ctx-expired", threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", createdAt: now, }, ], }); expect(state.trimmedPrompt).toBe("yoo waddup"); expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); it("treats element contexts as sendable content (no text, no images, no terminals)", () => { const state = deriveComposerSendState({ prompt: "", imageCount: 0, terminalContexts: [], elementContextCount: 1, }); expect(state.trimmedPrompt).toBe(""); expect(state.expiredTerminalContextCount).toBe(0); expect(state.hasSendableContent).toBe(true); }); it("does NOT treat zero element contexts as sendable", () => { expect( deriveComposerSendState({ prompt: "", imageCount: 0, terminalContexts: [], elementContextCount: 0, }).hasSendableContent, ).toBe(false); }); }); describe("buildExpiredTerminalContextToastCopy", () => { it("formats empty and omission guidance", () => { expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ title: "Expired terminal context won't be sent", description: "Remove it or re-add it to include terminal output.", }); expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ title: "Expired terminal contexts omitted from message", description: "Re-add it if you want that terminal output included.", }); }); }); describe("getStartedThreadModelChangeBlockReason", () => { const providers = [ { instanceId: ProviderInstanceId.make("codex"), }, { instanceId: ProviderInstanceId.make("grok"), requiresNewThreadForModelChange: true, }, ]; it("allows model changes before a provider session has started", () => { expect( getStartedThreadModelChangeBlockReason({ providers, hasStartedSession: false, currentModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build", }, nextModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-other", }, }), ).toBeNull(); }); it("allows unchanged model selections for restricted providers", () => { expect( getStartedThreadModelChangeBlockReason({ providers, hasStartedSession: true, currentModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build", }, nextModelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build", }, }), ).toBeNull();