// 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 { getRouter } from "../router";
import { useStore } from "../store";
import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
import { estimateTimelineMessageHeight } from "./timelineHeight";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
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,
{ 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[];
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: [],
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,
}))
: 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);
},
{ 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,
branches: [
{
name: "main",
current: true,
isDefault: true,
worktreePath: null,
},
],
};
}
if (tag === WS_METHODS.gitStatus) {
return {
branch: "main",
hasWorkingTreeChanges: false,
workingTree: {
files: [],
insertions: 0,
deletions: 0,
},
hasUpstream: true,
aheadCount: 0,
behindCount: 0,
pr: 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",
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: "Chat" | "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 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 [];
},
});
__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,
});
});
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: 3,
}),
});
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" }, { id: "attachment-3" }],
},
{ 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("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("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("runs setup scripts 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",
});
},
{ timeout: 8_000, interval: 16 },
);
await vi.waitFor(
() => {
const openRequest = wsRequests.find(
(request) =>
request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359",
);
expect(openRequest).toMatchObject({
_tag: WS_METHODS.terminalOpen,
threadId: expect.any(String),
cwd: "/repo/worktrees/pr-1359",
env: {
T3CODE_PROJECT_ROOT: "/repo/project",
T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359",
},
});
},
{ timeout: 8_000, interval: 16 },
);
await vi.waitFor(
() => {
const writeRequest = wsRequests.find(
(request) =>
request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r",
);
expect(writeRequest).toMatchObject({
_tag: WS_METHODS.terminalWrite,
threadId: expect.any(String),
data: "bun install\r",
});
},
{ timeout: 8_000, interval: 16 },
);
} finally {
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("Chat");
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("Chat")).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 chat 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("Chat")).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("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();
}
});
});