// Production CSS is part of the behavior under test because row height depends on it. import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationEvent, type OrchestrationReadModel, type ProjectId, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, type TurnId, WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, removeInlineTerminalContextPlaceholder, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetNativeApiForTests } from "../nativeApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; vi.mock("../lib/gitStatusState", () => ({ useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), useGitStatuses: () => new Map(), refreshGitStatus: () => Promise.resolve(null), resetGitStatusStateForTests: () => undefined, })); const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; welcome: ServerLifecycleWelcomePayload; } let fixture: TestFixture; const rpcHarness = new BrowserWsRpcHarness(); const wsRequests = rpcHarness.requests; let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; const wsLink = ws.link(/ws(s)?:\/\/.*/); interface ViewportSpec { name: string; width: number; height: number; textTolerancePx: number; attachmentTolerancePx: number; } const DEFAULT_VIEWPORT: ViewportSpec = { name: "desktop", width: 960, height: 1_100, textTolerancePx: 44, attachmentTolerancePx: 56, }; const WIDE_FOOTER_VIEWPORT: ViewportSpec = { name: "wide-footer", width: 1_400, height: 1_100, textTolerancePx: 44, attachmentTolerancePx: 56, }; const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { name: "compact-footer", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56, }; const TEXT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, ] as const satisfies readonly ViewportSpec[]; interface UserRowMeasurement { measuredRowHeightPx: number; timelineWidthMeasuredPx: number; renderedInVirtualizedRegion: boolean; } interface MountedChatView { [Symbol.asyncDispose]: () => Promise; cleanup: () => Promise; measureUserRow: (targetMessageId: MessageId) => Promise; setViewport: (viewport: ViewportSpec) => Promise; setContainerSize: (viewport: Pick) => Promise; router: ReturnType; } function isoAt(offsetSeconds: number): string { return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); } function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], issues: [], providers: [ { provider: "codex", enabled: true, installed: true, version: "0.116.0", status: "ready", auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], }, ], availableEditors: [], observability: { logsDirectoryPath: "/repo/project/.t3/logs", localTracingEnabled: true, otlpTracesEnabled: false, otlpMetricsEnabled: false, }, settings: { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS, }, }; } function createUserMessage(options: { id: MessageId; text: string; offsetSeconds: number; attachments?: Array<{ type: "image"; id: string; name: string; mimeType: string; sizeBytes: number; }>; }) { return { id: options.id, role: "user" as const, text: options.text, ...(options.attachments ? { attachments: options.attachments } : {}), turnId: null, streaming: false, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; } function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { return { id: options.id, role: "assistant" as const, text: options.text, turnId: null, streaming: false, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; } function createTerminalContext(input: { id: string; terminalLabel: string; lineStart: number; lineEnd: number; text: string; }): TerminalContextDraft { return { id: input.id, threadId: THREAD_ID, terminalId: `terminal-${input.id}`, terminalLabel: input.terminalLabel, lineStart: input.lineStart, lineEnd: input.lineEnd, text: input.text, createdAt: NOW_ISO, }; } function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; targetAttachmentCount?: number; sessionStatus?: OrchestrationSessionStatus; }): OrchestrationReadModel { const messages: Array = []; for (let index = 0; index < 22; index += 1) { const isTarget = index === 3; const userId = `msg-user-${index}` as MessageId; const assistantId = `msg-assistant-${index}` as MessageId; const attachments = isTarget && (options.targetAttachmentCount ?? 0) > 0 ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ type: "image" as const, id: `attachment-${attachmentIndex + 1}`, name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; messages.push( createUserMessage({ id: isTarget ? options.targetMessageId : userId, text: isTarget ? options.targetText : `filler user message ${index}`, offsetSeconds: messages.length * 3, ...(attachments ? { attachments } : {}), }), ); messages.push( createAssistantMessage({ id: assistantId, text: `assistant filler ${index}`, offsetSeconds: messages.length * 3, }), ); } return { snapshotSequence: 1, projects: [ { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { provider: "codex", model: "gpt-5", }, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, }, ], threads: [ { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", modelSelection: { provider: "codex", model: "gpt-5", }, interactionMode: "default", runtimeMode: "full-access", branch: "main", worktreePath: null, latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, archivedAt: null, deletedAt: null, messages, activities: [], proposedPlans: [], checkpoints: [], session: { threadId: THREAD_ID, status: options.sessionStatus ?? "ready", providerName: "codex", runtimeMode: "full-access", activeTurnId: null, lastError: null, updatedAt: NOW_ISO, }, }, ], updatedAt: NOW_ISO, }; } function buildFixture(snapshot: OrchestrationReadModel): TestFixture { return { snapshot, serverConfig: createBaseServerConfig(), welcome: { cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, bootstrapThreadId: THREAD_ID, }, }; } function addThreadToSnapshot( snapshot: OrchestrationReadModel, threadId: ThreadId, ): OrchestrationReadModel { return { ...snapshot, snapshotSequence: snapshot.snapshotSequence + 1, threads: [ ...snapshot.threads, { id: threadId, projectId: PROJECT_ID, title: "New thread", modelSelection: { provider: "codex", model: "gpt-5", }, interactionMode: "default", runtimeMode: "full-access", branch: "main", worktreePath: null, latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, archivedAt: null, deletedAt: null, messages: [], activities: [], proposedPlans: [], checkpoints: [], session: { threadId, status: "ready", providerName: "codex", runtimeMode: "full-access", activeTurnId: null, lastError: null, updatedAt: NOW_ISO, }, }, ], }; } function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { return { sequence, eventId: EventId.makeUnsafe(`event-thread-created-${sequence}`), aggregateKind: "thread", aggregateId: threadId, occurredAt: NOW_ISO, commandId: null, causationEventId: null, correlationId: null, metadata: {}, type: "thread.created", payload: { threadId, projectId: PROJECT_ID, title: "New thread", modelSelection: { provider: "codex", model: "gpt-5", }, runtimeMode: "full-access", interactionMode: "default", branch: "main", worktreePath: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, }, }; } function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); } async function waitForWsClient(): Promise { await vi.waitFor( () => { expect( wsRequests.some( (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, ), ).toBe(true); expect( wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), ).toBe(true); expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( true, ); }, { timeout: 8_000, interval: 16 }, ); } async function waitForAppBootstrap(): Promise { await vi.waitFor( () => { expect(getServerConfig()).not.toBeNull(); expect(useStore.getState().bootstrapComplete).toBe(true); }, { timeout: 8_000, interval: 16 }, ); } async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { await waitForWsClient(); fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); sendOrchestrationDomainEvent( createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), ); await vi.waitFor( () => { expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); } function createDraftOnlySnapshot(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-draft-target" as MessageId, targetText: "draft thread", }); return { ...snapshot, threads: [], }; } function withProjectScripts( snapshot: OrchestrationReadModel, scripts: OrchestrationReadModel["projects"][number]["scripts"], ): OrchestrationReadModel { return { ...snapshot, projects: snapshot.projects.map((project) => project.id === PROJECT_ID ? { ...project, scripts: Array.from(scripts) } : project, ), }; } function setDraftThreadWithoutWorktree(): void { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, envMode: "local", }, }, projectDraftThreadIdByProjectId: { [PROJECT_ID]: THREAD_ID, }, }); } function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-target" as MessageId, targetText: "plan thread", }); const planMarkdown = [ "# Ship plan mode follow-up", "", "- Step 1: capture the thread-open trace", "- Step 2: identify the main-thread bottleneck", "- Step 3: keep collapsed cards cheap", "- Step 4: render the full markdown only on demand", "- Step 5: preserve export and save actions", "- Step 6: add regression coverage", "- Step 7: verify route transitions stay responsive", "- Step 8: confirm no server-side work changed", "- Step 9: confirm short plans still render normally", "- Step 10: confirm long plans stay collapsed by default", "- Step 11: confirm preview text is still useful", "- Step 12: confirm plan follow-up flow still works", "- Step 13: confirm timeline virtualization still behaves", "- Step 14: confirm theme styling still looks correct", "- Step 15: confirm save dialog behavior is unchanged", "- Step 16: confirm download behavior is unchanged", "- Step 17: confirm code fences do not parse until expand", "- Step 18: confirm preview truncation ends cleanly", "- Step 19: confirm markdown links still open in editor after expand", "- Step 20: confirm deep hidden detail only appears after expand", "", "```ts", "export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';", "```", ].join("\n"); return { ...snapshot, threads: snapshot.threads.map((thread) => thread.id === THREAD_ID ? Object.assign({}, thread, { proposedPlans: [ { id: "plan-browser-test", turnId: null, planMarkdown, implementedAt: null, implementationThreadId: null, createdAt: isoAt(1_000), updatedAt: isoAt(1_001), }, ], updatedAt: isoAt(1_001), }) : thread, ), }; } function createSnapshotWithPendingUserInput(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-pending-input-target" as MessageId, targetText: "question thread", }); return { ...snapshot, threads: snapshot.threads.map((thread) => thread.id === THREAD_ID ? Object.assign({}, thread, { interactionMode: "plan", activities: [ { id: EventId.makeUnsafe("activity-user-input-requested"), tone: "info", kind: "user-input.requested", summary: "User input requested", payload: { requestId: "req-browser-user-input", questions: [ { id: "scope", header: "Scope", question: "What should this change cover?", options: [ { label: "Tight", description: "Touch only the footer layout logic.", }, { label: "Broad", description: "Also adjust the related composer controls.", }, ], }, { id: "risk", header: "Risk", question: "How aggressive should the imaginary plan be?", options: [ { label: "Conservative", description: "Favor reliability and low-risk changes.", }, { label: "Balanced", description: "Mix quick wins with one structural improvement.", }, ], }, ], }, turnId: null, sequence: 1, createdAt: isoAt(1_000), }, ], updatedAt: isoAt(1_000), }) : thread, ), }; } function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-follow-up-target" as MessageId, targetText: "plan follow-up thread", }); return { ...snapshot, threads: snapshot.threads.map((thread) => thread.id === THREAD_ID ? Object.assign({}, thread, { interactionMode: "plan", latestTurn: { turnId: "turn-plan-follow-up" as TurnId, state: "completed", requestedAt: isoAt(1_000), startedAt: isoAt(1_001), completedAt: isoAt(1_010), assistantMessageId: null, }, proposedPlans: [ { id: "plan-follow-up-browser-test", turnId: "turn-plan-follow-up" as TurnId, planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.", implementedAt: null, implementationThreadId: null, createdAt: isoAt(1_002), updatedAt: isoAt(1_003), }, ], session: { ...thread.session, status: "ready", updatedAt: isoAt(1_010), }, updatedAt: isoAt(1_010), }) : thread, ), }; } function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { return customResult; } const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, hasOriginRemote: true, nextCursor: null, totalCount: 1, branches: [ { name: "main", current: true, isDefault: true, worktreePath: null, }, ], }; } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], truncated: false, }; } if (tag === WS_METHODS.shellOpenInEditor) { return null; } if (tag === WS_METHODS.terminalOpen) { return { threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", worktreePath: typeof body.worktreePath === "string" ? body.worktreePath : body.worktreePath === null ? null : null, status: "running", pid: 123, history: "", exitCode: null, exitSignal: null, updatedAt: NOW_ISO, }; } return {}; } const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { void rpcHarness.connect(client); client.addEventListener("message", (event) => { const rawData = event.data; if (typeof rawData !== "string") return; void rpcHarness.onMessage(rawData); }); }), http.get("*/attachments/:attachmentId", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { "Content-Type": "image/svg+xml", }, }), ), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); async function nextFrame(): Promise { await new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); } async function waitForLayout(): Promise { await nextFrame(); await nextFrame(); await nextFrame(); } async function setViewport(viewport: ViewportSpec): Promise { await page.viewport(viewport.width, viewport.height); await waitForLayout(); } async function waitForProductionStyles(): Promise { await vi.waitFor( () => { expect( getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), ).not.toBe(""); expect(getComputedStyle(document.body).marginTop).toBe("0px"); }, { timeout: 4_000, interval: 16, }, ); } async function waitForElement( query: () => T | null, errorMessage: string, ): Promise { let element: T | null = null; await vi.waitFor( () => { element = query(); expect(element, errorMessage).toBeTruthy(); }, { timeout: 8_000, interval: 16, }, ); if (!element) { throw new Error(errorMessage); } return element; } async function waitForURL( router: ReturnType, predicate: (pathname: string) => boolean, errorMessage: string, ): Promise { let pathname = ""; await vi.waitFor( () => { pathname = router.state.location.pathname; expect(predicate(pathname), errorMessage).toBe(true); }, { timeout: 8_000, interval: 16 }, ); return pathname; } async function waitForComposerEditor(): Promise { return waitForElement( () => document.querySelector('[contenteditable="true"]'), "Unable to find composer editor.", ); } async function waitForComposerMenuItem(itemId: string): Promise { return waitForElement( () => document.querySelector(`[data-composer-item-id="${itemId}"]`), `Unable to find composer menu item "${itemId}".`, ); } async function waitForSendButton(): Promise { return waitForElement( () => document.querySelector('button[aria-label="Send message"]'), "Unable to find send button.", ); } function findComposerProviderModelPicker(): HTMLButtonElement | null { return document.querySelector('[data-chat-provider-model-picker="true"]'); } function findButtonByText(text: string): HTMLButtonElement | null { return (Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === text, ) ?? null) as HTMLButtonElement | null; } async function waitForButtonByText(text: string): Promise { return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`); } function findButtonContainingText(text: string): HTMLButtonElement | null { return (Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes(text), ) ?? null) as HTMLButtonElement | null; } async function waitForButtonContainingText(text: string): Promise { return waitForElement( () => findButtonContainingText(text), `Unable to find button containing "${text}".`, ); } async function expectComposerActionsContained(): Promise { const footer = await waitForElement( () => document.querySelector('[data-chat-composer-footer="true"]'), "Unable to find composer footer.", ); const actions = await waitForElement( () => document.querySelector('[data-chat-composer-actions="right"]'), "Unable to find composer actions container.", ); await vi.waitFor( () => { const footerRect = footer.getBoundingClientRect(); const actionButtons = Array.from(actions.querySelectorAll("button")); expect(actionButtons.length).toBeGreaterThanOrEqual(1); const buttonRects = actionButtons.map((button) => button.getBoundingClientRect()); const firstTop = buttonRects[0]?.top ?? 0; for (const rect of buttonRects) { expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5); expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5); expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5); } }, { timeout: 8_000, interval: 16 }, ); } async function waitForInteractionModeButton( expectedLabel: "Build" | "Plan", ): Promise { return waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === expectedLabel, ) as HTMLButtonElement | null, `Unable to find ${expectedLabel} interaction mode button.`, ); } async function waitForServerConfigToApply(): Promise { await vi.waitFor( () => { expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( true, ); }, { timeout: 8_000, interval: 16 }, ); await waitForLayout(); } function dispatchChatNewShortcut(): void { const useMetaForMod = isMacPlatform(navigator.platform); window.dispatchEvent( new KeyboardEvent("keydown", { key: "o", shiftKey: true, metaKey: useMetaForMod, ctrlKey: !useMetaForMod, bubbles: true, cancelable: true, }), ); } async function triggerChatNewShortcutUntilPath( router: ReturnType, predicate: (pathname: string) => boolean, errorMessage: string, ): Promise { let pathname = router.state.location.pathname; const deadline = Date.now() + 8_000; while (Date.now() < deadline) { dispatchChatNewShortcut(); await waitForLayout(); pathname = router.state.location.pathname; if (predicate(pathname)) { return pathname; } } throw new Error(`${errorMessage} Last path: ${pathname}`); } async function waitForNewThreadShortcutLabel(): Promise { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); await newThreadButton.hover(); const shortcutLabel = isMacPlatform(navigator.platform) ? "New thread (⇧⌘O)" : "New thread (Ctrl+Shift+O)"; await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); } async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); if (images.length === 0) { return; } await Promise.all( images.map( (image) => new Promise((resolve) => { if (image.complete) { resolve(); return; } image.addEventListener("load", () => resolve(), { once: true }); image.addEventListener("error", () => resolve(), { once: true }); }), ), ); await waitForLayout(); } async function measureUserRow(options: { host: HTMLElement; targetMessageId: MessageId; }): Promise { const { host, targetMessageId } = options; const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; const scrollContainer = await waitForElement( () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), "Unable to find ChatView message scroll container.", ); let row: HTMLElement | null = null; await vi.waitFor( async () => { scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); await waitForLayout(); row = host.querySelector(rowSelector); expect(row, "Unable to locate targeted user message row.").toBeTruthy(); }, { timeout: 8_000, interval: 16, }, ); await waitForImagesToLoad(row!); scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); await nextFrame(); const timelineRoot = row!.closest('[data-timeline-root="true"]') ?? host.querySelector('[data-timeline-root="true"]'); if (!(timelineRoot instanceof HTMLElement)) { throw new Error("Unable to locate timeline root container."); } let timelineWidthMeasuredPx = 0; let measuredRowHeightPx = 0; let renderedInVirtualizedRegion = false; await vi.waitFor( async () => { scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); await nextFrame(); const measuredRow = host.querySelector(rowSelector); expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); }, { timeout: 4_000, interval: 16, }, ); return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; } async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; configureFixture?: (fixture: TestFixture) => void; resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); customWsRpcResolver = options.resolveRpc ?? null; await setViewport(options.viewport); await waitForProductionStyles(); const host = document.createElement("div"); host.style.position = "fixed"; host.style.top = "0"; host.style.left = "0"; host.style.width = "100vw"; host.style.height = "100vh"; host.style.display = "grid"; host.style.overflow = "hidden"; document.body.append(host); const router = getRouter( createMemoryHistory({ initialEntries: [`/${THREAD_ID}`], }), ); const screen = await render( , { container: host, }, ); await waitForWsClient(); await waitForAppBootstrap(); await waitForLayout(); const cleanup = async () => { customWsRpcResolver = null; await screen.unmount(); host.remove(); }; return { [Symbol.asyncDispose]: cleanup, cleanup, measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), setViewport: async (viewport: ViewportSpec) => { await setViewport(viewport); await waitForProductionStyles(); }, setContainerSize: async (viewport) => { host.style.width = `${viewport.width}px`; host.style.height = `${viewport.height}px`; await waitForLayout(); }, router, }; } async function measureUserRowAtViewport(options: { snapshot: OrchestrationReadModel; targetMessageId: MessageId; viewport: ViewportSpec; }): Promise { const mounted = await mountChatView({ viewport: options.viewport, snapshot: options.snapshot, }); try { return await mounted.measureUserRow(options.targetMessageId); } finally { await mounted.cleanup(); } } describe("ChatView timeline estimator parity (full app)", () => { beforeAll(async () => { fixture = buildFixture( createSnapshotForTargetUser({ targetMessageId: "msg-user-bootstrap" as MessageId, targetText: "bootstrap", }), ); await worker.start({ onUnhandledRequest: "bypass", quiet: true, serviceWorker: { url: "/mockServiceWorker.js", }, }); }); afterAll(async () => { await rpcHarness.disconnect(); await worker.stop(); }); beforeEach(async () => { await rpcHarness.reset({ resolveUnary: resolveWsRpc, getInitialStreamValues: (request) => { if (request._tag === WS_METHODS.subscribeServerLifecycle) { return [ { version: 1, sequence: 1, type: "welcome", payload: fixture.welcome, }, ]; } if (request._tag === WS_METHODS.subscribeServerConfig) { return [ { version: 1, type: "snapshot", config: fixture.serverConfig, }, ]; } return []; }, }); await __resetNativeApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); useStore.setState({ projects: [], threads: [], bootstrapComplete: false, }); useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ terminalStateByThreadId: {}, terminalLaunchContextByThreadId: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); }); afterEach(() => { customWsRpcResolver = null; document.body.innerHTML = ""; }); it.each(TEXT_VIEWPORT_MATRIX)( "keeps long user message estimate close at the $name viewport", async (viewport) => { const userText = "x".repeat(3_200); const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId; const mounted = await mountChatView({ viewport, snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, }), }); try { const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = await mounted.measureUserRow(targetMessageId); expect(renderedInVirtualizedRegion).toBe(true); const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: timelineWidthMeasuredPx }, ); expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( viewport.textTolerancePx, ); } finally { await mounted.cleanup(); } }, ); it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { const userText = "x".repeat(3_200); const targetMessageId = "msg-user-target-resize" as MessageId; const mounted = await mountChatView({ viewport: TEXT_VIEWPORT_MATRIX[0], snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, }), }); try { const measurements: Array< UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } > = []; for (const viewport of TEXT_VIEWPORT_MATRIX) { await mounted.setViewport(viewport); const measurement = await mounted.measureUserRow(targetMessageId); const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: measurement.timelineWidthMeasuredPx }, ); expect(measurement.renderedInVirtualizedRegion).toBe(true); expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( viewport.textTolerancePx, ); measurements.push({ ...measurement, viewport, estimatedHeightPx }); } expect( new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))) .size, ).toBeGreaterThanOrEqual(3); const byMeasuredWidth = measurements.toSorted( (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, ); const narrowest = byMeasuredWidth[0]!; const widest = byMeasuredWidth.at(-1)!; expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx); expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx); expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx); } finally { await mounted.cleanup(); } }); it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => { const userText = "x".repeat(2_400); const targetMessageId = "msg-user-target-wrap" as MessageId; const snapshot = createSnapshotForTargetUser({ targetMessageId, targetText: userText, }); const desktopMeasurement = await measureUserRowAtViewport({ viewport: TEXT_VIEWPORT_MATRIX[0], snapshot, targetMessageId, }); const mobileMeasurement = await measureUserRowAtViewport({ viewport: TEXT_VIEWPORT_MATRIX[2], snapshot, targetMessageId, }); const estimatedDesktopPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx }, ); const estimatedMobilePx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, ); const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; expect(measuredDeltaPx).toBeGreaterThan(0); expect(estimatedDeltaPx).toBeGreaterThan(0); const ratio = estimatedDeltaPx / measuredDeltaPx; expect(ratio).toBeGreaterThan(0.65); expect(ratio).toBeLessThan(1.35); }); it.each(ATTACHMENT_VIEWPORT_MATRIX)( "keeps user attachment estimate close at the $name viewport", async (viewport) => { const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId; const userText = "message with image attachments"; const mounted = await mountChatView({ viewport, snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, targetAttachmentCount: 2, }), }); try { const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = await mounted.measureUserRow(targetMessageId); expect(renderedInVirtualizedRegion).toBe(true); const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], }, { timelineWidthPx: timelineWidthMeasuredPx }, ); expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( viewport.attachmentTolerancePx, ); } finally { await mounted.cleanup(); } }, ); it("shows an explicit empty state for projects without threads in the sidebar", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), }); try { await expect.element(page.getByText("No threads yet")).toBeInTheDocument(); } finally { await mounted.cleanup(); } }); it("opens the project cwd for draft threads without a worktree path", async () => { setDraftThreadWithoutWorktree(); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, availableEditors: ["vscode"], }; }, }); try { await waitForServerConfigToApply(); const openButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Open", ) as HTMLButtonElement | null, "Unable to find Open button.", ); await vi.waitFor(() => { expect(openButton.disabled).toBe(false); }); openButton.click(); await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.shellOpenInEditor, ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", editor: "vscode", }); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-launch-context-target" as MessageId, targetText: "launch context worktree override", }); const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); if (targetThread) { Object.assign(targetThread, { branch: "feature/branch", worktreePath: "/repo/worktrees/feature-branch", }); } useTerminalStateStore.setState({ terminalStateByThreadId: { [THREAD_ID]: { terminalOpen: true, terminalHeight: 280, terminalIds: ["default"], runningTerminalIds: [], activeTerminalId: "default", terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], activeTerminalGroupId: "group-default", }, }, terminalLaunchContextByThreadId: { [THREAD_ID]: { cwd: "/repo/project", worktreePath: null, }, }, }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot, }); try { await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.terminalOpen, ) as | { _tag: string; cwd?: string; worktreePath?: string | null; env?: Record; } | undefined; expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, cwd: "/repo/project", worktreePath: null, env: { T3CODE_PROJECT_ROOT: "/repo/project", }, }); expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { setDraftThreadWithoutWorktree(); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, availableEditors: ["vscode-insiders"], }; }, }); try { await waitForServerConfigToApply(); const openButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Open", ) as HTMLButtonElement | null, "Unable to find Open button.", ); await vi.waitFor(() => { expect(openButton.disabled).toBe(false); }); openButton.click(); await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.shellOpenInEditor, ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", editor: "vscode-insiders", }); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("opens the project cwd with Trae when it is the only available editor", async () => { setDraftThreadWithoutWorktree(); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, availableEditors: ["trae"], }; }, }); try { await waitForServerConfigToApply(); const openButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Open", ) as HTMLButtonElement | null, "Unable to find Open button.", ); await vi.waitFor(() => { expect(openButton.disabled).toBe(false); }); openButton.click(); await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.shellOpenInEditor, ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", editor: "trae", }); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("filters the open picker menu and opens VSCodium from the menu", async () => { setDraftThreadWithoutWorktree(); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, availableEditors: ["vscode-insiders", "vscodium"], }; }, }); try { await waitForServerConfigToApply(); const menuButton = await waitForElement( () => document.querySelector('button[aria-label="Copy options"]'), "Unable to find Open picker button.", ); (menuButton as HTMLButtonElement).click(); await waitForElement( () => Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => item.textContent?.includes("VS Code Insiders"), ) ?? null, "Unable to find VS Code Insiders menu item.", ); expect( Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) => item.textContent?.includes("Zed"), ), ).toBe(false); const vscodiumItem = await waitForElement( () => Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => item.textContent?.includes("VSCodium"), ) ?? null, "Unable to find VSCodium menu item.", ); (vscodiumItem as HTMLElement).click(); await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.shellOpenInEditor, ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", editor: "vscodium", }); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("falls back to the first installed editor when the stored favorite is unavailable", async () => { localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium")); setDraftThreadWithoutWorktree(); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, availableEditors: ["vscode-insiders"], }; }, }); try { await waitForServerConfigToApply(); const openButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Open", ) as HTMLButtonElement | null, "Unable to find Open button.", ); await vi.waitFor(() => { expect(openButton.disabled).toBe(false); }); openButton.click(); await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.shellOpenInEditor, ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", editor: "vscode-insiders", }); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("runs project scripts from local draft threads at the project cwd", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, envMode: "local", }, }, projectDraftThreadIdByProjectId: { [PROJECT_ID]: THREAD_ID, }, }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ { id: "lint", name: "Lint", command: "bun run lint", icon: "lint", runOnWorktreeCreate: false, }, ]), }); try { const runButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.title === "Run Lint", ) as HTMLButtonElement | null, "Unable to find Run Lint button.", ); runButton.click(); await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.terminalOpen, ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, cwd: "/repo/project", env: { T3CODE_PROJECT_ROOT: "/repo/project", }, }); }, { timeout: 8_000, interval: 16 }, ); await vi.waitFor( () => { const writeRequest = wsRequests.find( (request) => request._tag === WS_METHODS.terminalWrite, ); expect(writeRequest).toMatchObject({ _tag: WS_METHODS.terminalWrite, threadId: THREAD_ID, data: "bun run lint\r", }); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("runs project scripts from worktree draft threads at the worktree cwd", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", branch: "feature/draft", worktreePath: "/repo/worktrees/feature-draft", envMode: "worktree", }, }, projectDraftThreadIdByProjectId: { [PROJECT_ID]: THREAD_ID, }, }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ { id: "test", name: "Test", command: "bun run test", icon: "test", runOnWorktreeCreate: false, }, ]), }); try { const runButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.title === "Run Test", ) as HTMLButtonElement | null, "Unable to find Run Test button.", ); runButton.click(); await vi.waitFor( () => { const openRequest = wsRequests.find( (request) => request._tag === WS_METHODS.terminalOpen, ); expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, cwd: "/repo/worktrees/feature-draft", env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft", }, }); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, envMode: "local", }, }, projectDraftThreadIdByProjectId: { [PROJECT_ID]: THREAD_ID, }, }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ { id: "setup", name: "Setup", command: "bun install", icon: "configure", runOnWorktreeCreate: true, }, ]), resolveRpc: (body) => { if (body._tag === WS_METHODS.gitResolvePullRequest) { return { pullRequest: { number: 1359, title: "Add thread archiving and settings navigation", url: "https://github.com/pingdotgg/t3code/pull/1359", baseBranch: "main", headBranch: "archive-settings-overhaul", state: "open", }, }; } if (body._tag === WS_METHODS.gitPreparePullRequestThread) { return { pullRequest: { number: 1359, title: "Add thread archiving and settings navigation", url: "https://github.com/pingdotgg/t3code/pull/1359", baseBranch: "main", headBranch: "archive-settings-overhaul", state: "open", }, branch: "archive-settings-overhaul", worktreePath: "/repo/worktrees/pr-1359", }; } return undefined; }, }); try { const branchButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "main", ) as HTMLButtonElement | null, "Unable to find branch selector button.", ); branchButton.click(); const branchInput = await waitForElement( () => document.querySelector('input[placeholder="Search branches..."]'), "Unable to find branch search input.", ); branchInput.focus(); await page.getByPlaceholder("Search branches...").fill("1359"); const checkoutItem = await waitForElement( () => Array.from(document.querySelectorAll("span")).find( (element) => element.textContent?.trim() === "Checkout Pull Request", ) as HTMLSpanElement | null, "Unable to find checkout pull request option.", ); checkoutItem.click(); const worktreeButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Worktree", ) as HTMLButtonElement | null, "Unable to find Worktree button.", ); worktreeButton.click(); await vi.waitFor( () => { const prepareRequest = wsRequests.find( (request) => request._tag === WS_METHODS.gitPreparePullRequestThread, ); expect(prepareRequest).toMatchObject({ _tag: WS_METHODS.gitPreparePullRequestThread, cwd: "/repo/project", reference: "1359", mode: "worktree", threadId: THREAD_ID, }); }, { timeout: 8_000, interval: 16 }, ); expect( wsRequests.some( (request) => request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", ), ).toBe(false); } finally { await mounted.cleanup(); } }); it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { useTerminalStateStore.setState({ terminalStateByThreadId: {}, }); useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", branch: "main", worktreePath: null, envMode: "worktree", }, }, projectDraftThreadIdByProjectId: { [PROJECT_ID]: THREAD_ID, }, }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ { id: "setup", name: "Setup", command: "bun install", icon: "configure", runOnWorktreeCreate: true, }, ]), resolveRpc: (body) => { if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { return { sequence: fixture.snapshot.snapshotSequence + 1, }; } return undefined; }, }); try { useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); expect(sendButton.disabled).toBe(false); sendButton.click(); await vi.waitFor( () => { const dispatchRequest = wsRequests.find( (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, ) as | { _tag: string; type?: string; bootstrap?: { createThread?: { projectId?: string }; prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; runSetupScript?: boolean; }; } | undefined; expect(dispatchRequest).toMatchObject({ _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, type: "thread.turn.start", bootstrap: { createThread: { projectId: PROJECT_ID, }, prepareWorktree: { projectCwd: "/repo/project", baseBranch: "main", branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), }, runSetupScript: true, }, }); }, { timeout: 8_000, interval: 16 }, ); expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( false, ); expect( wsRequests.some( (request) => request._tag === WS_METHODS.terminalWrite && request.threadId === THREAD_ID && request.data === "bun install\r", ), ).toBe(false); } finally { await mounted.cleanup(); } }); it("shows the send state once bootstrap dispatch is in flight", async () => { useTerminalStateStore.setState({ terminalStateByThreadId: {}, }); useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", branch: "main", worktreePath: null, envMode: "worktree", }, }, projectDraftThreadIdByProjectId: { [PROJECT_ID]: THREAD_ID, }, }); let resolveDispatch!: (value: { sequence: number }) => void; const dispatchPromise = new Promise<{ sequence: number }>((resolve) => { resolveDispatch = resolve; }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ { id: "setup", name: "Setup", command: "bun install", icon: "configure", runOnWorktreeCreate: true, }, ]), resolveRpc: (body) => { if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { return dispatchPromise; } return undefined; }, }); try { useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); expect(sendButton.disabled).toBe(false); sendButton.click(); await vi.waitFor( () => { expect( wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), ).toBe(true); expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); }, { timeout: 8_000, interval: 16 }, ); } finally { resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 }); await mounted.cleanup(); } }); it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-target-hotkey" as MessageId, targetText: "hotkey target", }), }); try { const initialModeButton = await waitForInteractionModeButton("Build"); expect(initialModeButton.title).toContain("enter plan mode"); window.dispatchEvent( new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true, }), ); await waitForLayout(); expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); const composerEditor = await waitForComposerEditor(); composerEditor.focus(); composerEditor.dispatchEvent( new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true, }), ); await vi.waitFor( async () => { expect((await waitForInteractionModeButton("Plan")).title).toContain( "return to normal build mode", ); }, { timeout: 8_000, interval: 16 }, ); composerEditor.dispatchEvent( new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true, }), ); await vi.waitFor( async () => { expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("keeps removed terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; useComposerDraftStore.getState().addTerminalContext( THREAD_ID, createTerminalContext({ id: "ctx-removed", terminalLabel: "Terminal 1", lineStart: 1, lineEnd: 2, text: "bun i\nno changes", }), ); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, targetText: "terminal pill backspace target", }), }); try { await vi.waitFor( () => { expect(document.body.textContent).toContain(removedLabel); }, { timeout: 8_000, interval: 16 }, ); const store = useComposerDraftStore.getState(); const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); store.setPrompt(THREAD_ID, nextPrompt.prompt); store.removeTerminalContext(THREAD_ID, "ctx-removed"); await vi.waitFor( () => { expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); expect(document.body.textContent).not.toContain(removedLabel); }, { timeout: 8_000, interval: 16 }, ); useComposerDraftStore.getState().addTerminalContext( THREAD_ID, createTerminalContext({ id: "ctx-added", terminalLabel: "Terminal 2", lineStart: 9, lineEnd: 10, text: "git status\nOn branch main", }), ); await vi.waitFor( () => { const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); expect(document.body.textContent).toContain(addedLabel); expect(document.body.textContent).not.toContain(removedLabel); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("disables send when the composer only contains an expired terminal pill", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( THREAD_ID, createTerminalContext({ id: "ctx-expired-only", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", }), ); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-expired-pill-disabled" as MessageId, targetText: "expired pill disabled target", }), }); try { await vi.waitFor( () => { expect(document.body.textContent).toContain(expiredLabel); }, { timeout: 8_000, interval: 16 }, ); const sendButton = await waitForSendButton(); expect(sendButton.disabled).toBe(true); } finally { await mounted.cleanup(); } }); it("warns when sending text while omitting expired terminal pills", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( THREAD_ID, createTerminalContext({ id: "ctx-expired-send-warning", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", }), ); useComposerDraftStore .getState() .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-expired-pill-warning" as MessageId, targetText: "expired pill warning target", }), }); try { await vi.waitFor( () => { expect(document.body.textContent).toContain(expiredLabel); }, { timeout: 8_000, interval: 16 }, ); const sendButton = await waitForSendButton(); expect(sendButton.disabled).toBe(false); sendButton.click(); await vi.waitFor( () => { expect(document.body.textContent).toContain( "Expired terminal context omitted from message", ); expect(document.body.textContent).not.toContain(expiredLabel); expect(document.body.textContent).toContain("yoowaddup"); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("shows a pointer cursor for the running stop button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-stop-button-cursor" as MessageId, targetText: "stop button cursor target", sessionStatus: "running", }), }); try { const stopButton = await waitForElement( () => document.querySelector('button[aria-label="Stop generation"]'), "Unable to find stop generation button.", ); expect(getComputedStyle(stopButton).cursor).toBe("pointer"); } finally { await mounted.cleanup(); } }); it("hides the archive action when the pointer leaves a thread row", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-archive-hover-test" as MessageId, targetText: "archive hover target", }), }); try { const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); await expect.element(threadRow).toBeInTheDocument(); const archiveButton = await waitForElement( () => document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), "Unable to find archive button.", ); const archiveAction = archiveButton.parentElement; expect( archiveAction, "Archive button should render inside a visibility wrapper.", ).not.toBeNull(); expect(getComputedStyle(archiveAction!).opacity).toBe("0"); await threadRow.hover(); await vi.waitFor( () => { expect(getComputedStyle(archiveAction!).opacity).toBe("1"); }, { timeout: 4_000, interval: 16 }, ); await page.getByTestId("composer-editor").hover(); await vi.waitFor( () => { expect(getComputedStyle(archiveAction!).opacity).toBe("0"); }, { timeout: 4_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("shows the confirm archive action after clicking the archive button", async () => { localStorage.setItem( "t3code:client-settings:v1", JSON.stringify({ ...DEFAULT_CLIENT_SETTINGS, confirmThreadArchive: true, }), ); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-archive-confirm-test" as MessageId, targetText: "archive confirm target", }), }); try { const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); await expect.element(threadRow).toBeInTheDocument(); await threadRow.hover(); const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); await expect.element(archiveButton).toBeInTheDocument(); await archiveButton.click(); const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`); await expect.element(confirmButton).toBeInTheDocument(); await expect.element(confirmButton).toBeVisible(); } finally { localStorage.removeItem("t3code:client-settings:v1"); await mounted.cleanup(); } }); it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-new-thread-test" as MessageId, targetText: "new thread selection test", }), }); try { // Wait for the sidebar to render with the project. const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); await newThreadButton.click(); // The route should change to a new draft thread ID. const newThreadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); const newThreadId = newThreadPath.slice(1) as ThreadId; // The composer editor should be present for the new draft thread. await waitForComposerEditor(); // Simulate the steady-state promotion path: the server emits // `thread.created`, the client materializes the thread incrementally, // and the draft is cleared by live batch effects. await promoteDraftThreadViaDomainEvent(newThreadId); // The route should still be on the new thread — not redirected away. await waitForURL( mounted.router, (path) => path === newThreadPath, "New thread should remain selected after server thread promotion clears the draft.", ); // The empty thread view and composer should still be visible. await expect .element(page.getByText("Send a message to start the conversation.")) .toBeInTheDocument(); await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); } finally { await mounted.cleanup(); } }); it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { codex: { provider: "codex", model: "gpt-5.3-codex", options: { reasoningEffort: "medium", fastMode: true, }, }, }, stickyActiveProvider: "codex", }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, targetText: "sticky codex traits test", }), }); try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); await newThreadButton.click(); const newThreadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", model: "gpt-5.3-codex", options: { fastMode: true, }, }, }, activeProvider: "codex", }); } finally { await mounted.cleanup(); } }); it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { claudeAgent: { provider: "claudeAgent", model: "claude-opus-4-6", options: { effort: "max", fastMode: true, }, }, }, stickyActiveProvider: "claudeAgent", }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, targetText: "sticky claude model test", }), }); try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); await newThreadButton.click(); const newThreadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new sticky claude draft thread UUID.", ); const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ modelSelectionByProvider: { claudeAgent: { provider: "claudeAgent", model: "claude-opus-4-6", options: { effort: "max", fastMode: true, }, }, }, activeProvider: "claudeAgent", }); } finally { await mounted.cleanup(); } }); it("falls back to defaults when no sticky composer settings exist", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-default-codex-traits-test" as MessageId, targetText: "default codex traits test", }), }); try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); await newThreadButton.click(); const newThreadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); } finally { await mounted.cleanup(); } }); it("prefers draft state over sticky composer settings and defaults", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { codex: { provider: "codex", model: "gpt-5.3-codex", options: { reasoningEffort: "medium", fastMode: true, }, }, }, stickyActiveProvider: "codex", }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, targetText: "draft codex traits precedence test", }), }); try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); await newThreadButton.click(); const threadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a sticky draft thread UUID.", ); const threadId = threadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", model: "gpt-5.3-codex", options: { fastMode: true, }, }, }, activeProvider: "codex", }); useComposerDraftStore.getState().setModelSelection(threadId, { provider: "codex", model: "gpt-5.4", options: { reasoningEffort: "low", fastMode: true, }, }); await newThreadButton.click(); await waitForURL( mounted.router, (path) => path === threadPath, "New-thread should reuse the existing project draft thread.", ); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", model: "gpt-5.4", options: { reasoningEffort: "low", fastMode: true, }, }, }, activeProvider: "codex", }); } finally { await mounted.cleanup(); } }); it("creates a new thread from the global chat.new shortcut", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-chat-shortcut-test" as MessageId, targetText: "chat shortcut test", }), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, keybindings: [ { command: "chat.new", shortcut: { key: "o", metaKey: false, ctrlKey: false, shiftKey: true, altKey: false, modKey: true, }, whenAst: { type: "not", node: { type: "identifier", name: "terminalFocus" }, }, }, ], }; }, }); try { await waitForNewThreadShortcutLabel(); await waitForServerConfigToApply(); const composerEditor = await waitForComposerEditor(); composerEditor.focus(); await waitForLayout(); await triggerChatNewShortcutUntilPath( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID from the shortcut.", ); } finally { await mounted.cleanup(); } }); it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId, targetText: "promoted draft shortcut test", }), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, keybindings: [ { command: "chat.new", shortcut: { key: "o", metaKey: false, ctrlKey: false, shiftKey: true, altKey: false, modKey: true, }, whenAst: { type: "not", node: { type: "identifier", name: "terminalFocus" }, }, }, ], }; }, }); try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); await waitForNewThreadShortcutLabel(); await waitForServerConfigToApply(); await newThreadButton.click(); const promotedThreadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a promoted draft thread UUID.", ); const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; await promoteDraftThreadViaDomainEvent(promotedThreadId); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, "Shortcut should create a fresh draft instead of reusing the promoted thread.", ); expect(freshThreadPath).not.toBe(promotedThreadPath); } finally { await mounted.cleanup(); } }); it("keeps long proposed plans lightweight until the user expands them", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithLongProposedPlan(), }); try { await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Expand plan", ) as HTMLButtonElement | null, "Unable to find Expand plan button.", ); expect(document.body.textContent).not.toContain("deep hidden detail only after expand"); const expandButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Expand plan", ) as HTMLButtonElement | null, "Unable to find Expand plan button.", ); expandButton.click(); await vi.waitFor( () => { expect(document.body.textContent).toContain("deep hidden detail only after expand"); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("uses the active worktree path when saving a proposed plan to the workspace", async () => { const snapshot = createSnapshotWithLongProposedPlan(); const threads = snapshot.threads.slice(); const targetThreadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); const targetThread = targetThreadIndex >= 0 ? threads[targetThreadIndex] : undefined; if (targetThread) { threads[targetThreadIndex] = { ...targetThread, worktreePath: "/repo/worktrees/plan-thread", }; } const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: { ...snapshot, threads, }, }); try { const planActionsButton = await waitForElement( () => document.querySelector('button[aria-label="Plan actions"]'), "Unable to find proposed plan actions button.", ); planActionsButton.click(); const saveToWorkspaceItem = await waitForElement( () => (Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find( (item) => item.textContent?.trim() === "Save to workspace", ) ?? null) as HTMLElement | null, 'Unable to find "Save to workspace" menu item.', ); saveToWorkspaceItem.click(); await vi.waitFor( () => { expect(document.body.textContent).toContain( "Enter a path relative to /repo/worktrees/plan-thread.", ); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("keeps pending-question footer actions inside the composer after a real resize", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPendingUserInput(), }); try { const firstOption = await waitForButtonContainingText("Tight"); firstOption.click(); await waitForButtonByText("Previous"); await waitForButtonByText("Submit answers"); await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT); await expectComposerActionsContained(); } finally { await mounted.cleanup(); } }); it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt(), }); try { const footer = await waitForElement( () => document.querySelector('[data-chat-composer-footer="true"]'), "Unable to find composer footer.", ); const initialModelPicker = await waitForElement( findComposerProviderModelPicker, "Unable to find provider model picker.", ); const initialModelPickerOffset = initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; await waitForButtonByText("Implement"); await waitForElement( () => document.querySelector('button[aria-label="Implementation actions"]'), "Unable to find implementation actions trigger.", ); await mounted.setContainerSize({ width: 440, height: WIDE_FOOTER_VIEWPORT.height, }); await expectComposerActionsContained(); const implementButton = await waitForButtonByText("Implement"); const implementActionsButton = await waitForElement( () => document.querySelector('button[aria-label="Implementation actions"]'), "Unable to find implementation actions trigger.", ); await vi.waitFor( () => { const implementRect = implementButton.getBoundingClientRect(); const implementActionsRect = implementActionsButton.getBoundingClientRect(); const compactModelPicker = findComposerProviderModelPicker(); expect(compactModelPicker).toBeTruthy(); const compactModelPickerOffset = compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left; expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( 1, ); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); it("keeps the slash-command menu visible above the composer", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ targetMessageId: "msg-user-command-menu-target" as MessageId, targetText: "command menu thread", }), }); try { await waitForComposerEditor(); await page.getByTestId("composer-editor").fill("/"); const menuItem = await waitForComposerMenuItem("slash:model"); const composerForm = await waitForElement( () => document.querySelector('[data-chat-composer-form="true"]'), "Unable to find composer form.", ); await vi.waitFor( () => { const menuRect = menuItem.getBoundingClientRect(); const composerRect = composerForm.getBoundingClientRect(); const hitTarget = document.elementFromPoint( menuRect.left + menuRect.width / 2, menuRect.top + menuRect.height / 2, ); expect(menuRect.width).toBeGreaterThan(0); expect(menuRect.height).toBeGreaterThan(0); expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); }, { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); } }); });