1223 lines · 36.5 KB · TypeScript
RawDownload
import {  EnvironmentId,  MessageId,  ProjectId,  ProviderInstanceId,  ThreadId,  TurnId,} from "@t3tools/contracts";import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types";import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";import type { RightPanelSurface } from "../rightPanelStore";import {  MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,  MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,  agentControlledBrowserCloseConfirmation,  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,  codexArtifactTemplatePromptToAppend,  shouldDockDraftHeroForSubmission,  shouldReleaseTimelineAnchorForToolActivity,  shouldOpenProactivePullRequest,  shouldOpenProactiveTurnDiff,  shouldShowBranchMismatchBanner,  shouldShowPlanFollowUpPrompt,  shouldWriteThreadErrorToCurrentServerThread,  toolGroupConsumesUpwardNavigation,} from "./ChatView.logic"; describe("agent browser close confirmation", () => {  const surfaces = [    { id: "browser:one", kind: "preview", resourceId: "tab-1" },    { id: "browser:two", kind: "preview", resourceId: "tab-2" },    { id: "diff", kind: "diff" },  ] satisfies RightPanelSurface[];   it("only warns for browsers under active agent control", () => {    expect(      agentControlledBrowserCloseConfirmation(surfaces, {        "tab-1": { controller: "none" },        "tab-2": { controller: "human" },      }),    ).toBeNull();     expect(      agentControlledBrowserCloseConfirmation([surfaces[0]!], {        "tab-1": { controller: "agent" },      }),    ).toBe(      [        "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"),    );  });   it("counts every agent-controlled browser in a bulk close", () => {    expect(      agentControlledBrowserCloseConfirmation(surfaces, {        "tab-1": { controller: "agent" },        "tab-2": { controller: "agent" },      }),    ).toContain("Close 2 browsers");  });}); describe("proactive panels", () => {  it("opens a pull request only after a newly observed link appears", () => {    expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false);    expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true);    expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false);    expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false);  });   it("opens the diff only when the observed running turn settles", () => {    const turnId = TurnId.make("turn-1");    expect(      shouldOpenProactiveTurnDiff({        previousRunningTurnId: undefined,        runningTurnId: null,        settledTurnId: turnId,        turnCompleted: true,      }),    ).toBe(false);    expect(      shouldOpenProactiveTurnDiff({        previousRunningTurnId: turnId,        runningTurnId: null,        settledTurnId: turnId,        turnCompleted: true,      }),    ).toBe(true);    expect(      shouldOpenProactiveTurnDiff({        previousRunningTurnId: turnId,        runningTurnId: TurnId.make("turn-2"),        settledTurnId: turnId,        turnCompleted: true,      }),    ).toBe(false);    expect(      shouldOpenProactiveTurnDiff({        previousRunningTurnId: turnId,        runningTurnId: null,        settledTurnId: turnId,        turnCompleted: false,      }),    ).toBe(false);  });}); 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",}; describe("artifact template composer insertion", () => {  it("does not insert an already-present prompt", () => {    const prompt = "Create a document using this $artifact-template-hello-world about…";     expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull();  });}); 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 },        serverThread: makeThread({ latestTurn: completedTurn }),        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"),

Lines 1–500 of 1223.