1636 lines · 50.1 KB · TypeScript
RawDownload
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.