import { ANTIGRAVITY_DEFAULT_MODEL, ProviderDriverKind, type ServerProvider,} from "@t3tools/contracts";import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";import type { RightPanelSurface } from "../rightPanelStore";import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, RunId, TurnItemId, type OrchestrationV2ProjectedTurnItem,} from "@t3tools/contracts";import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";import * as DateTime from "effect/DateTime";import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, TurnDiffSummary } from "../types";import { makeThreadFixture } from "../test-fixtures";import { agentControlledBrowserCloseConfirmation, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getAntigravitySendBlockReason, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, resolveProactiveTurnDiffAction, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, shouldRenderPreviewMiniPlayer, MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveDraftPromotionNavigationTarget, resolveEffectiveInteractionMode, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldShowComposerContextStrip, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, toolGroupConsumesUpwardNavigation,} from "./ChatView.logic"; describe("toolGroupConsumesUpwardNavigation", () => { class ScrollElement extends EventTarget { scrollTop = 0; scrollHeight = 100; clientHeight = 100; overflowY = "visible"; constructor( readonly parentElement: ScrollElement | null = null, readonly isToolGroup = false, ) { super(); } closest(selector: string): ScrollElement | null { if (selector !== "[data-tool-group-scroll]") return null; return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); } } beforeEach(() => { vi.stubGlobal("Element", ScrollElement); vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ overflowY: element.overflowY, })); }); afterEach(() => vi.unstubAllGlobals()); it("releases upward navigation when an overflowing group is at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each([ { overflowY: "auto", scrollTop: 1 }, { overflowY: "auto", scrollTop: 0.25 }, { overflowY: "scroll", scrollTop: 80 }, ])("consumes upward navigation within a scrolled group: %j", (scroll) => { const group = Object.assign(new ScrollElement(null, true), { scrollHeight: 300, ...scroll, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); }); it.each([100, 300])( "consumes scrolling in a nested result with a group content height of %i", (scrollHeight) => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight, }); const result = Object.assign(new ScrollElement(group), { overflowY: "auto", scrollHeight: 300, scrollTop: 0.25, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); }, ); it("releases upward navigation when the group and nested result are both at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); const result = Object.assign(new ScrollElement(group), { overflowY: "scroll", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }); it("ignores targets outside a tool group and non-element targets", () => { const outside = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); }); it("does not consume scrolling from an ancestor beyond the tool group", () => { const timeline = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); const group = new ScrollElement(timeline, true); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each(["hidden", "clip", "visible"])( "ignores a non-scrollable child with overflow-y %s", (overflowY) => { const group = new ScrollElement(null, true); const result = Object.assign(new ScrollElement(group), { overflowY, scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }, ); it("does not consume programmatic scrolling on an overflow-hidden group", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "hidden", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); });}); 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";const helloWorldTemplate: CodexArtifactTemplate = { artifactKind: "document", displayName: "Hello World", skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", skillName: "artifact-template-hello-world",}; function makeThread(overrides: Partial<Thread> = {}): Thread { return makeThreadFixture({ id: threadId, environmentId, projectId, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", runtime: null, messages: [], proposedPlans: [], createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestRun: null, branch: null, worktreePath: null, ...overrides, });} const completedTurn = { runId: RunId.make("turn-1"), status: "completed" as const, requestedAt: now, startedAt: "2026-03-29T00:00:01.000Z", completedAt: "2026-03-29T00:00:10.000Z", assistantMessageId: null,}; const readySession = { status: "completed" as const, providerName: "codex", providerInstanceId: ProviderInstanceId.make("codex"), activeRunId: null, lastError: null, updatedAt: "2026-03-29T00:00:10.000Z",}; describe("resolveDraftPromotionNavigationTarget", () => { const serverThreadRef = { environmentId, threadId }; const preparingRun = { ...completedTurn, status: "preparing" as const, startedAt: null, completedAt: null, }; it("stays on the draft while the workspace is still preparing", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: preparingRun }), backgroundSubmissionPending: false, }), ).toBeNull(); expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread(), backgroundSubmissionPending: false, }), ).toBeNull(); }); it("navigates once the run starts or startup stops", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: completedTurn }), backgroundSubmissionPending: false, }), ).toBe(serverThreadRef); expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: { ...preparingRun, status: "failed" as const }, }), backgroundSubmissionPending: false, }), ).toBe(serverThreadRef); }); it("defers while a background submission is pending", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); });}); describe("resolveEffectiveInteractionMode", () => { it("forces build mode when legacy plan mode is disabled", () => { expect( resolveEffectiveInteractionMode({ planModeEnabled: false, composerInteractionMode: "plan", threadInteractionMode: "plan", }), ).toBe("default"); }); it("uses the saved mode while legacy plan mode is enabled", () => { expect( resolveEffectiveInteractionMode({ planModeEnabled: true, composerInteractionMode: null, threadInteractionMode: "plan", }), ).toBe("plan"); });}); 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("shouldShowComposerContextStrip", () => { it("shows git context while composing a new thread", () => { expect( shouldShowComposerContextStrip({ isDraftHeroState: true, isGitRepo: true, hasActiveProject: true, persistInActiveThreads: false, }), ).toBe(true); }); it("keeps git context in an active thread only when requested", () => { expect( shouldShowComposerContextStrip({ isDraftHeroState: false, isGitRepo: true, hasActiveProject: true, persistInActiveThreads: true, }), ).toBe(true); expect( shouldShowComposerContextStrip({ isDraftHeroState: false, isGitRepo: true, hasActiveProject: true, persistInActiveThreads: false, }), ).toBe(false); }); it("hides git context without a git-backed project", () => { expect( shouldShowComposerContextStrip({ isDraftHeroState: true, isGitRepo: false, hasActiveProject: true, persistInActiveThreads: true, }), ).toBe(false); expect( shouldShowComposerContextStrip({ isDraftHeroState: true, isGitRepo: true, hasActiveProject: false, persistInActiveThreads: true, }), ).toBe(false); });});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,Lines 1–500 of 1636. Load more
import { ANTIGRAVITY_DEFAULT_MODEL, ProviderDriverKind, type ServerProvider, } from "@t3tools/contracts"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import type { RightPanelSurface } from "../rightPanelStore"; import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, RunId, TurnItemId, type OrchestrationV2ProjectedTurnItem, } from "@t3tools/contracts"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import * as DateTime from "effect/DateTime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, TurnDiffSummary } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { agentControlledBrowserCloseConfirmation, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getAntigravitySendBlockReason, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, resolveProactiveTurnDiffAction, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, shouldRenderPreviewMiniPlayer, MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveDraftPromotionNavigationTarget, resolveEffectiveInteractionMode, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldShowComposerContextStrip, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, toolGroupConsumesUpwardNavigation, } from "./ChatView.logic"; describe("toolGroupConsumesUpwardNavigation", () => { class ScrollElement extends EventTarget { scrollTop = 0; scrollHeight = 100; clientHeight = 100; overflowY = "visible"; constructor( readonly parentElement: ScrollElement | null = null, readonly isToolGroup = false, ) { super(); } closest(selector: string): ScrollElement | null { if (selector !== "[data-tool-group-scroll]") return null; return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); } } beforeEach(() => { vi.stubGlobal("Element", ScrollElement); vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ overflowY: element.overflowY, })); }); afterEach(() => vi.unstubAllGlobals()); it("releases upward navigation when an overflowing group is at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each([ { overflowY: "auto", scrollTop: 1 }, { overflowY: "auto", scrollTop: 0.25 }, { overflowY: "scroll", scrollTop: 80 }, ])("consumes upward navigation within a scrolled group: %j", (scroll) => { const group = Object.assign(new ScrollElement(null, true), { scrollHeight: 300, ...scroll, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); }); it.each([100, 300])( "consumes scrolling in a nested result with a group content height of %i", (scrollHeight) => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight, }); const result = Object.assign(new ScrollElement(group), { overflowY: "auto", scrollHeight: 300, scrollTop: 0.25, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); }, ); it("releases upward navigation when the group and nested result are both at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); const result = Object.assign(new ScrollElement(group), { overflowY: "scroll", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }); it("ignores targets outside a tool group and non-element targets", () => { const outside = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); }); it("does not consume scrolling from an ancestor beyond the tool group", () => { const timeline = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); const group = new ScrollElement(timeline, true); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each(["hidden", "clip", "visible"])( "ignores a non-scrollable child with overflow-y %s", (overflowY) => { const group = new ScrollElement(null, true); const result = Object.assign(new ScrollElement(group), { overflowY, scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }, ); it("does not consume programmatic scrolling on an overflow-hidden group", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "hidden", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); }); }); 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"; const helloWorldTemplate: CodexArtifactTemplate = { artifactKind: "document", displayName: "Hello World", skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", skillName: "artifact-template-hello-world", }; function makeThread(overrides: Partial<Thread> = {}): Thread { return makeThreadFixture({ id: threadId, environmentId, projectId, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", runtime: null, messages: [], proposedPlans: [], createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestRun: null, branch: null, worktreePath: null, ...overrides, }); } const completedTurn = { runId: RunId.make("turn-1"), status: "completed" as const, requestedAt: now, startedAt: "2026-03-29T00:00:01.000Z", completedAt: "2026-03-29T00:00:10.000Z", assistantMessageId: null, }; const readySession = { status: "completed" as const, providerName: "codex", providerInstanceId: ProviderInstanceId.make("codex"), activeRunId: null, lastError: null, updatedAt: "2026-03-29T00:00:10.000Z", }; describe("resolveDraftPromotionNavigationTarget", () => { const serverThreadRef = { environmentId, threadId }; const preparingRun = { ...completedTurn, status: "preparing" as const, startedAt: null, completedAt: null, }; it("stays on the draft while the workspace is still preparing", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: preparingRun }), backgroundSubmissionPending: false, }), ).toBeNull(); expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread(), backgroundSubmissionPending: false, }), ).toBeNull(); }); it("navigates once the run starts or startup stops", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: completedTurn }), backgroundSubmissionPending: false, }), ).toBe(serverThreadRef); expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: { ...preparingRun, status: "failed" as const }, }), backgroundSubmissionPending: false, }), ).toBe(serverThreadRef); }); it("defers while a background submission is pending", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestRun: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); }); }); describe("resolveEffectiveInteractionMode", () => { it("forces build mode when legacy plan mode is disabled", () => { expect( resolveEffectiveInteractionMode({ planModeEnabled: false, composerInteractionMode: "plan", threadInteractionMode: "plan", }), ).toBe("default"); }); it("uses the saved mode while legacy plan mode is enabled", () => { expect( resolveEffectiveInteractionMode({ planModeEnabled: true, composerInteractionMode: null, threadInteractionMode: "plan", }), ).toBe("plan"); }); }); 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("shouldShowComposerContextStrip", () => { it("shows git context while composing a new thread", () => { expect( shouldShowComposerContextStrip({ isDraftHeroState: true, isGitRepo: true, hasActiveProject: true, persistInActiveThreads: false, }), ).toBe(true); }); it("keeps git context in an active thread only when requested", () => { expect( shouldShowComposerContextStrip({ isDraftHeroState: false, isGitRepo: true, hasActiveProject: true, persistInActiveThreads: true, }), ).toBe(true); expect( shouldShowComposerContextStrip({ isDraftHeroState: false, isGitRepo: true, hasActiveProject: true, persistInActiveThreads: false, }), ).toBe(false); }); it("hides git context without a git-backed project", () => { expect( shouldShowComposerContextStrip({ isDraftHeroState: true, isGitRepo: false, hasActiveProject: true, persistInActiveThreads: true, }), ).toBe(false); expect( shouldShowComposerContextStrip({ isDraftHeroState: true, isGitRepo: true, hasActiveProject: false, persistInActiveThreads: true, }), ).toBe(false); }); }); 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,