diff --git a/apps/web/package.json b/apps/web/package.json index 7b23de3679d04f64be5519c899c4ac725a07d6bd..b540fe35fa1b668188a005f8e58adce4043ab2a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "@vitejs/plugin-react": "^5.1.4", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "msw": "^2.12.10", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js new file mode 100644 index 0000000000000000000000000000000000000000..daa58d0f1205be30a0ca846db04219e190aa5e9f --- /dev/null +++ b/apps/web/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.10' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 58a0b3f8a5d9c50dbebba1b9cc0fd569853e6ab5..5af8c217b594815e4379897785db0bb6bc8b0160 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1,279 +1,141 @@ import "../index.css"; -import { type MessageId, type ThreadId } from "@t3tools/contracts"; import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from "@tanstack/react-router"; + ORCHESTRATION_WS_METHODS, + type MessageId, + type OrchestrationReadModel, + type ProjectId, + type ProviderSessionId, + type ServerConfig, + type ThreadId, + type WsWelcomePayload, + WS_CHANNELS, + WS_METHODS, +} from "@t3tools/contracts"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { HttpResponse, http, ws } from "msw"; +import { setupWorker } from "msw/browser"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import type { ChatMessage, Thread } from "../types"; -import ChatView from "./ChatView"; +import { getRouter } from "../router"; +import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; -const mocks = vi.hoisted(() => { - return { - markThreadVisited: vi.fn(), - setThreadError: vi.fn(), - setRuntimeMode: vi.fn(), - setThreadBranch: vi.fn(), - storeState: { - projects: [] as unknown[], - threads: [] as Thread[], - runtimeMode: "full-access", - markThreadVisited: vi.fn(), - setError: vi.fn(), - setRuntimeMode: vi.fn(), - setThreadBranch: vi.fn(), - }, - composerDraft: { - prompt: "", - images: [] as unknown[], - nonPersistedImageIds: [] as string[], - model: null, - effort: null, - }, - composerStore: { - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - setPrompt: vi.fn(), - setModel: vi.fn(), - setEffort: vi.fn(), - addImage: vi.fn(), - addImages: vi.fn(), - removeImage: vi.fn(), - clearPersistedAttachments: vi.fn(), - syncPersistedAttachments: vi.fn(), - clearComposerContent: vi.fn(), - clearDraftThread: vi.fn(), - setDraftThreadContext: vi.fn(), - }, - terminalStore: { - terminalStateByThreadId: {} as Record, - setTerminalOpen: vi.fn(), - setTerminalHeight: vi.fn(), - splitTerminal: vi.fn(), - newTerminal: vi.fn(), - setActiveTerminal: vi.fn(), - closeTerminal: vi.fn(), - }, - terminalState: { - terminalOpen: false, - terminalHeight: 280, - terminalIds: ["default"], - runningTerminalIds: [], - activeTerminalId: "default", - terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], - activeTerminalGroupId: "group-default", - }, - }; -}); - -vi.mock("@tanstack/react-query", async () => { - const actual = await vi.importActual( - "@tanstack/react-query", - ); - return { - ...actual, - useQueryClient: () => ({}), - useMutation: () => ({ - mutateAsync: vi.fn(async () => ({})), - isPending: false, - }), - useQuery: () => ({ - data: undefined, - isLoading: false, - error: null, - }), - }; -}); - -vi.mock("../store", () => ({ - useStore: (selector: (store: typeof mocks.storeState) => unknown) => selector(mocks.storeState), -})); - -vi.mock("../composerDraftStore", () => ({ - useComposerThreadDraft: () => mocks.composerDraft, - useComposerDraftStore: (selector: (store: typeof mocks.composerStore) => unknown) => - selector(mocks.composerStore), -})); - -vi.mock("../terminalStateStore", () => ({ - useTerminalStateStore: (selector: (store: typeof mocks.terminalStore) => unknown) => - selector(mocks.terminalStore), - selectThreadTerminalState: () => mocks.terminalState, -})); - -vi.mock("../hooks/useTurnDiffSummaries", () => ({ - useTurnDiffSummaries: () => ({ - turnDiffSummaries: [], - inferredCheckpointTurnCountByTurnId: {}, - }), -})); - -vi.mock("../hooks/useTheme", () => ({ - useTheme: () => ({ resolvedTheme: "light" as const }), -})); - -vi.mock("../nativeApi", () => ({ - readNativeApi: () => null, - ensureNativeApi: () => { - throw new Error("Native API unavailable in browser test"); - }, -})); - -vi.mock("./BranchToolbar", () => ({ - default: () => null, -})); - -vi.mock("./GitActionsControl", () => ({ - default: () => null, -})); - -vi.mock("./ProjectScriptsControl", () => ({ - default: () => null, -})); - -vi.mock("./ThreadTerminalDrawer", () => ({ - default: () => null, -})); - -vi.mock("./ComposerPromptEditor", () => ({ - ComposerPromptEditor: () => null, -})); - -vi.mock("./ui/sidebar", () => ({ - SidebarTrigger: () => null, -})); - const THREAD_ID = "thread-browser-test" as ThreadId; +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 TALL_IMAGE_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( - "", -)}`; +const ATTACHMENT_SVG = ""; -interface RenderMeasureOptions { - timelineWidthPx: number; - messages: ChatMessage[]; - targetMessageId: MessageId; -} - -function createTestRouter(threadId: ThreadId) { - const rootRoute = createRootRoute({ - component: () => , - }); - const threadRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/$threadId", - component: () => , - }); - return createRouter({ - routeTree: rootRoute.addChildren([threadRoute]), - history: createMemoryHistory({ - initialEntries: [`/${threadId}`], - }), - context: {}, - }); -} - -function createThread(messages: ChatMessage[]): Thread { - return { - id: THREAD_ID, - codexThreadId: null, - projectId: "project-1" as Thread["projectId"], - title: "Browser test thread", - model: "gpt-5", - session: null, - messages, - error: null, - createdAt: NOW_ISO, - latestTurn: null, - lastVisitedAt: NOW_ISO, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], +interface WsRequestEnvelope { + id: string; + body: { + _tag: string; + [key: string]: unknown; }; } +interface TestFixture { + snapshot: OrchestrationReadModel; + serverConfig: ServerConfig; + welcome: WsWelcomePayload; +} + +let fixture: TestFixture; +const wsLink = ws.link(/ws(s)?:\/\/.*/); + function isoAt(offsetSeconds: number): string { return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); } -function createUserMessage({ - id, - text, - offsetSeconds, - attachments, -}: { +function createBaseServerConfig(): ServerConfig { + return { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: NOW_ISO, + }, + ], + availableEditors: [], + }; +} + +function createUserMessage(options: { id: MessageId; text: string; offsetSeconds: number; - attachments?: ChatMessage["attachments"]; -}): ChatMessage { + attachments?: Array<{ + type: "image"; + id: string; + name: string; + mimeType: string; + sizeBytes: number; + }>; +}) { return { - id, - role: "user", - text, - ...(attachments && attachments.length > 0 ? { attachments } : {}), - createdAt: isoAt(offsetSeconds), - completedAt: isoAt(offsetSeconds + 1), + 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({ - id, - text, - offsetSeconds, -}: { +function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number; -}): ChatMessage { +}) { return { - id, - role: "assistant", - text, - createdAt: isoAt(offsetSeconds), - completedAt: isoAt(offsetSeconds + 1), + id: options.id, + role: "assistant" as const, + text: options.text, + turnId: null, streaming: false, + createdAt: isoAt(options.offsetSeconds), + updatedAt: isoAt(options.offsetSeconds + 1), }; } -function createImageAttachments(count: number): NonNullable { - return Array.from({ length: count }, (_, index) => ({ - type: "image" as const, - id: `attachment-${index + 1}`, - name: `attachment-${index + 1}.svg`, - mimeType: "image/svg+xml", - sizeBytes: 128, - previewUrl: TALL_IMAGE_DATA_URI, - })); -} - -function createConversationWithTargetUser(options: { +function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; - targetAttachments?: ChatMessage["attachments"]; -}): ChatMessage[] { - const messages: ChatMessage[] = []; + targetAttachmentCount?: number; +}): OrchestrationReadModel { + const messages: Array = []; + for (let index = 0; index < 22; index += 1) { - const userId = (`msg-user-${index}` as MessageId); - const assistantId = (`msg-assistant-${index}` as MessageId); 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: isTarget ? options.targetAttachments : undefined, + ...(attachments ? { attachments } : {}), }), ); messages.push( @@ -284,9 +146,149 @@ function createConversationWithTargetUser(options: { }), ); } - return messages; + + return { + snapshotSequence: 1, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Browser test thread", + model: "gpt-5", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + messages, + activities: [], + checkpoints: [], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + providerSessionId: "session-1" as ProviderSessionId, + providerThreadId: null, + approvalPolicy: "on-failure", + sandboxMode: "workspace-write", + 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 resolveWsRpc(tag: string): unknown { + 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, + 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, + }; + } + return {}; +} + +const worker = setupWorker( + wsLink.addEventListener("connection", ({ client }) => { + client.send( + JSON.stringify({ + type: "push", + channel: WS_CHANNELS.serverWelcome, + data: fixture.welcome, + }), + ); + client.addEventListener("message", (event) => { + const rawData = event.data; + if (typeof rawData !== "string") return; + let request: WsRequestEnvelope; + try { + request = JSON.parse(rawData) as WsRequestEnvelope; + } catch { + return; + } + const method = request.body?._tag; + if (typeof method !== "string") return; + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(method), + }), + ); + }); + }), + 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()); @@ -299,8 +301,24 @@ async function waitForLayout(): Promise { await nextFrame(); } +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + const timeoutAt = performance.now() + 8_000; + while (performance.now() < timeoutAt) { + const element = query(); + if (element) return element; + await nextFrame(); + } + throw new Error(errorMessage); +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); + if (images.length === 0) { + return; + } await Promise.all( images.map( (image) => @@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise { await waitForLayout(); } -async function renderAndMeasureUserRow({ - timelineWidthPx, - messages, - targetMessageId, -}: RenderMeasureOptions): Promise<{ +async function renderAndMeasureUserRow(options: { + timelineWidthPx: number; + targetMessageId: MessageId; + snapshot: OrchestrationReadModel; +}): Promise<{ measuredRowHeightPx: number; timelineWidthMeasuredPx: number; renderedInVirtualizedRegion: boolean; }> { + fixture = buildFixture(options.snapshot); + const host = document.createElement("div"); - host.style.width = `${timelineWidthPx}px`; + host.style.width = `${options.timelineWidthPx}px`; host.style.height = "920px"; host.style.display = "flex"; host.style.overflow = "hidden"; document.body.append(host); - mocks.storeState.threads = [createThread(messages)]; - const router = createTestRouter(THREAD_ID); + const router = getRouter( + createMemoryHistory({ + initialEntries: [`/${THREAD_ID}`], + }), + ); const root: Root = createRoot(host); root.render(); - await waitForLayout(); - const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain"); - if (!(scrollContainer instanceof HTMLDivElement)) { - root.unmount(); - throw new Error("Unable to find ChatView message scroll container."); - } - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); + try { + await waitForLayout(); - const row = host.querySelector( - `[data-message-id="${targetMessageId}"][data-message-role="user"]`, - ); - if (!(row instanceof HTMLElement)) { - root.unmount(); - throw new Error("Unable to locate targeted user message row."); - } - await waitForImagesToLoad(row); + const scrollContainer = await waitForElement( + () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), + "Unable to find ChatView message scroll container.", + ); - const timelineRoot = row.closest("div.max-w-3xl"); - if (!(timelineRoot instanceof HTMLElement)) { - root.unmount(); - throw new Error("Unable to locate timeline root container."); - } + let row: HTMLElement | null = null; + const timeoutAt = performance.now() + 8_000; + while (performance.now() < timeoutAt) { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await waitForLayout(); + row = host.querySelector( + `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`, + ); + if (row) { + break; + } + } + if (!row) { + throw new Error("Unable to locate targeted user message row."); + } + + await waitForImagesToLoad(row); + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); - const measuredRowHeightPx = row.getBoundingClientRect().height; - const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; - const renderedInVirtualizedRegion = row.closest("[data-index]") instanceof HTMLElement; + 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."); + } - root.unmount(); - host.remove(); + const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; + let measuredRowHeightPx = 0; + let renderedInVirtualizedRegion = false; + const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`; + const measureTimeoutAt = performance.now() + 4_000; + while (performance.now() < measureTimeoutAt) { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + const measuredRow = host.querySelector(rowSelector); + if (!measuredRow) { + continue; + } + measuredRowHeightPx = measuredRow.getBoundingClientRect().height; + renderedInVirtualizedRegion = measuredRow.closest("[data-index]") instanceof HTMLElement; + if (measuredRowHeightPx > 0) { + break; + } + } + if (measuredRowHeightPx <= 0) { + throw new Error("Unable to measure targeted user row height."); + } - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; + return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; + } finally { + root.unmount(); + host.remove(); + } } -describe("ChatView timeline estimator parity", () => { +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 worker.stop(); + }); + beforeEach(() => { + localStorage.clear(); document.body.innerHTML = ""; - mocks.storeState.projects = []; - mocks.storeState.threads = []; - mocks.storeState.runtimeMode = "full-access"; - mocks.composerDraft.prompt = ""; - mocks.composerDraft.images = []; - mocks.composerDraft.nonPersistedImageIds = []; - mocks.composerDraft.model = null; - mocks.composerDraft.effort = null; + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + runtimeMode: "full-access", + }); }); afterEach(() => { @@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => { await renderAndMeasureUserRow({ timelineWidthPx: 960, targetMessageId, - messages: createConversationWithTargetUser({ + snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, }), @@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => { it("tracks additional rendered wrapping when ChatView width narrows", async () => { const userText = "x".repeat(2_400); const targetMessageId = "msg-user-target-wrap" as MessageId; - const messages = createConversationWithTargetUser({ + const snapshot = createSnapshotForTargetUser({ targetMessageId, targetText: userText, }); const desktop = await renderAndMeasureUserRow({ timelineWidthPx: 960, targetMessageId, - messages, + snapshot, }); const mobile = await renderAndMeasureUserRow({ timelineWidthPx: 360, targetMessageId, - messages, + snapshot, }); const estimatedDesktopPx = estimateTimelineMessageHeight( @@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => { it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { const targetMessageId = "msg-user-target-attachments" as MessageId; - const attachments = createImageAttachments(3); const userText = "message with image attachments"; const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = await renderAndMeasureUserRow({ timelineWidthPx: 960, targetMessageId, - messages: createConversationWithTargetUser({ + snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, - targetAttachments: attachments, + targetAttachmentCount: 3, }), }); @@ -471,7 +544,7 @@ describe("ChatView timeline estimator parity", () => { { role: "user", text: userText, - attachments: attachments.map((attachment) => ({ id: attachment.id })), + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], }, { timelineWidthPx: timelineWidthMeasuredPx }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e9f81c44534e8b44c440f1a59487827d285531cc..3101b9403bb7dfd0332ed5b0e69184cfb6b359db 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3650,7 +3650,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
+
{virtualizedRowCount > 0 && (
{virtualRows.map((virtualRow: VirtualItem) => { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b735484b2b874710fa0c81436648bf91da8edcee..9dd95b37d877e6a383929ced8e7d39b9130f9954 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,40 +1,17 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; -import { StoreProvider } from "./store"; +import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@xterm/xterm/css/xterm.css"; import "./index.css"; -import { APP_DISPLAY_NAME } from "./branding"; import { isElectron } from "./env"; -import { routeTree } from "./routeTree.gen"; +import { getRouter } from "./router"; const history = isElectron ? createHashHistory() : createBrowserHistory(); -const queryClient = new QueryClient(); -document.title = APP_DISPLAY_NAME; - -const router = createRouter({ - routeTree, - history, - context: { - queryClient, - }, - Wrap: ({ children }) => ( - - {children} - - ), -}); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} +const router = getRouter(history); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts new file mode 100644 index 0000000000000000000000000000000000000000..0192ee0c6cf890a9c6ae2ce2d415bbd1d43d6413 --- /dev/null +++ b/apps/web/src/router.ts @@ -0,0 +1,34 @@ +import { createElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; +import { StoreProvider } from "./store"; + +type RouterHistory = NonNullable[0]["history"]>; + +export function getRouter(history: RouterHistory) { + const queryClient = new QueryClient(); + + return createRouter({ + routeTree, + history, + context: { + queryClient, + }, + Wrap: ({ children }) => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(StoreProvider, null, children), + ), + }); +} + +export type AppRouter = ReturnType; + +declare module "@tanstack/react-router" { + interface Register { + router: AppRouter; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4616543d552bc50b8c0f90b41226dd4df0ccc1f4..89037c218bbd9f43e74bc1cf9277af163e3b13ad 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,9 @@ export const Route = createRootRouteWithContext<{ }>()({ component: RootRouteView, errorComponent: RootRouteErrorView, + head: () => ({ + meta: [{ name: "title", content: APP_DISPLAY_NAME }], + }), }); function RootRouteView() { @@ -152,9 +155,7 @@ function EventRouter() { latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); syncServerReadModel(snapshot); const activeThreadIds = new Set( - snapshot.threads - .filter((t) => t.deletedAt === null) - .map((t) => t.id), + snapshot.threads.filter((t) => t.deletedAt === null).map((t) => t.id), ); removeOrphanedTerminalStates(activeThreadIds); if (pending) { @@ -195,11 +196,13 @@ function EventRouter() { if (hasRunningSubprocess === null) { return; } - useTerminalStateStore.getState().setTerminalActivity( - ThreadId.makeUnsafe(event.threadId), - event.terminalId, - hasRunningSubprocess, - ); + useTerminalStateStore + .getState() + .setTerminalActivity( + ThreadId.makeUnsafe(event.threadId), + event.terminalId, + hasRunningSubprocess, + ); }); const unsubWelcome = onServerWelcome((payload) => { void (async () => { @@ -276,7 +279,13 @@ function EventRouter() { unsubWelcome(); unsubServerConfigUpdated(); }; - }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]); + }, [ + navigate, + queryClient, + removeOrphanedTerminalStates, + setProjectExpanded, + syncServerReadModel, + ]); return null; } diff --git a/bun.lock b/bun.lock index 5178098e6545799438643731ac3d4076360451f7..312b75e9132ee6ba18453ec46537d9f95958c892 100644 --- a/bun.lock +++ b/bun.lock @@ -94,6 +94,7 @@ "@vitejs/plugin-react": "^5.1.4", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "msw": "^2.12.10", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", diff --git a/package.json b/package.json index f79062d7f40d30668028039d9a8e1452ca59454c..907b6fde1e4607e7f8c709e22e8e34137abe24a6 100644 --- a/package.json +++ b/package.json @@ -56,5 +56,10 @@ "bun": "^1.3.9", "node": "^24.13.1" }, - "packageManager": "bun@1.3.9" -} + "packageManager": "bun@1.3.9", + "msw": { + "workerDirectory": [ + "apps/web/public" + ] + } +} \ No newline at end of file