authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-04 21:49:00-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 10:20:34-08:00
logf5bdc138f66a56ec71f3cb18f92a1ebf2be7cca2
treef934b922b0ff8668b5f25c5989cc545d25575d74
parent2d8ca4817593c2fda1ce0b16849fc9920591538f

Stabilize ChatView attachment height browser test with MSW

- Run ChatView browser tests through the real app router/store stack - Add MSW worker setup and mocked WS/attachment responses for full-app rendering - Extract shared `getRouter` setup and add a timeline root data hook for reliable measurement

9 files changed, 776 insertions(+), 323 deletions(-)

apps/web/package.json+1
...@@ -45,6 +45,7 @@...@@ -45,6 +45,7 @@
45 "@vitejs/plugin-react": "^5.1.4",45 "@vitejs/plugin-react": "^5.1.4",
46 "@vitest/browser-playwright": "^4.0.18",46 "@vitest/browser-playwright": "^4.0.18",
47 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",47 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
48 "msw": "^2.12.10",
48 "playwright": "^1.58.2",49 "playwright": "^1.58.2",
49 "tailwindcss": "^4.0.0",50 "tailwindcss": "^4.0.0",
50 "typescript": "catalog:",51 "typescript": "catalog:",
apps/web/public/mockServiceWorker.js created+349
...@@ -0,0 +1,349 @@
1/* eslint-disable */
2/* tslint:disable */
3
4/**
5 * Mock Service Worker.
6 * @see https://github.com/mswjs/msw
7 * - Please do NOT modify this file.
8 */
9
10const PACKAGE_VERSION = '2.12.10'
11const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
12const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13const activeClientIds = new Set()
14
15addEventListener('install', function () {
16 self.skipWaiting()
17})
18
19addEventListener('activate', function (event) {
20 event.waitUntil(self.clients.claim())
21})
22
23addEventListener('message', async function (event) {
24 const clientId = Reflect.get(event.source || {}, 'id')
25
26 if (!clientId || !self.clients) {
27 return
28 }
29
30 const client = await self.clients.get(clientId)
31
32 if (!client) {
33 return
34 }
35
36 const allClients = await self.clients.matchAll({
37 type: 'window',
38 })
39
40 switch (event.data) {
41 case 'KEEPALIVE_REQUEST': {
42 sendToClient(client, {
43 type: 'KEEPALIVE_RESPONSE',
44 })
45 break
46 }
47
48 case 'INTEGRITY_CHECK_REQUEST': {
49 sendToClient(client, {
50 type: 'INTEGRITY_CHECK_RESPONSE',
51 payload: {
52 packageVersion: PACKAGE_VERSION,
53 checksum: INTEGRITY_CHECKSUM,
54 },
55 })
56 break
57 }
58
59 case 'MOCK_ACTIVATE': {
60 activeClientIds.add(clientId)
61
62 sendToClient(client, {
63 type: 'MOCKING_ENABLED',
64 payload: {
65 client: {
66 id: client.id,
67 frameType: client.frameType,
68 },
69 },
70 })
71 break
72 }
73
74 case 'CLIENT_CLOSED': {
75 activeClientIds.delete(clientId)
76
77 const remainingClients = allClients.filter((client) => {
78 return client.id !== clientId
79 })
80
81 // Unregister itself when there are no more clients
82 if (remainingClients.length === 0) {
83 self.registration.unregister()
84 }
85
86 break
87 }
88 }
89})
90
91addEventListener('fetch', function (event) {
92 const requestInterceptedAt = Date.now()
93
94 // Bypass navigation requests.
95 if (event.request.mode === 'navigate') {
96 return
97 }
98
99 // Opening the DevTools triggers the "only-if-cached" request
100 // that cannot be handled by the worker. Bypass such requests.
101 if (
102 event.request.cache === 'only-if-cached' &&
103 event.request.mode !== 'same-origin'
104 ) {
105 return
106 }
107
108 // Bypass all requests when there are no active clients.
109 // Prevents the self-unregistered worked from handling requests
110 // after it's been terminated (still remains active until the next reload).
111 if (activeClientIds.size === 0) {
112 return
113 }
114
115 const requestId = crypto.randomUUID()
116 event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
117})
118
119/**
120 * @param {FetchEvent} event
121 * @param {string} requestId
122 * @param {number} requestInterceptedAt
123 */
124async function handleRequest(event, requestId, requestInterceptedAt) {
125 const client = await resolveMainClient(event)
126 const requestCloneForEvents = event.request.clone()
127 const response = await getResponse(
128 event,
129 client,
130 requestId,
131 requestInterceptedAt,
132 )
133
134 // Send back the response clone for the "response:*" life-cycle events.
135 // Ensure MSW is active and ready to handle the message, otherwise
136 // this message will pend indefinitely.
137 if (client && activeClientIds.has(client.id)) {
138 const serializedRequest = await serializeRequest(requestCloneForEvents)
139
140 // Clone the response so both the client and the library could consume it.
141 const responseClone = response.clone()
142
143 sendToClient(
144 client,
145 {
146 type: 'RESPONSE',
147 payload: {
148 isMockedResponse: IS_MOCKED_RESPONSE in response,
149 request: {
150 id: requestId,
151 ...serializedRequest,
152 },
153 response: {
154 type: responseClone.type,
155 status: responseClone.status,
156 statusText: responseClone.statusText,
157 headers: Object.fromEntries(responseClone.headers.entries()),
158 body: responseClone.body,
159 },
160 },
161 },
162 responseClone.body ? [serializedRequest.body, responseClone.body] : [],
163 )
164 }
165
166 return response
167}
168
169/**
170 * Resolve the main client for the given event.
171 * Client that issues a request doesn't necessarily equal the client
172 * that registered the worker. It's with the latter the worker should
173 * communicate with during the response resolving phase.
174 * @param {FetchEvent} event
175 * @returns {Promise<Client | undefined>}
176 */
177async function resolveMainClient(event) {
178 const client = await self.clients.get(event.clientId)
179
180 if (activeClientIds.has(event.clientId)) {
181 return client
182 }
183
184 if (client?.frameType === 'top-level') {
185 return client
186 }
187
188 const allClients = await self.clients.matchAll({
189 type: 'window',
190 })
191
192 return allClients
193 .filter((client) => {
194 // Get only those clients that are currently visible.
195 return client.visibilityState === 'visible'
196 })
197 .find((client) => {
198 // Find the client ID that's recorded in the
199 // set of clients that have registered the worker.
200 return activeClientIds.has(client.id)
201 })
202}
203
204/**
205 * @param {FetchEvent} event
206 * @param {Client | undefined} client
207 * @param {string} requestId
208 * @param {number} requestInterceptedAt
209 * @returns {Promise<Response>}
210 */
211async function getResponse(event, client, requestId, requestInterceptedAt) {
212 // Clone the request because it might've been already used
213 // (i.e. its body has been read and sent to the client).
214 const requestClone = event.request.clone()
215
216 function passthrough() {
217 // Cast the request headers to a new Headers instance
218 // so the headers can be manipulated with.
219 const headers = new Headers(requestClone.headers)
220
221 // Remove the "accept" header value that marked this request as passthrough.
222 // This prevents request alteration and also keeps it compliant with the
223 // user-defined CORS policies.
224 const acceptHeader = headers.get('accept')
225 if (acceptHeader) {
226 const values = acceptHeader.split(',').map((value) => value.trim())
227 const filteredValues = values.filter(
228 (value) => value !== 'msw/passthrough',
229 )
230
231 if (filteredValues.length > 0) {
232 headers.set('accept', filteredValues.join(', '))
233 } else {
234 headers.delete('accept')
235 }
236 }
237
238 return fetch(requestClone, { headers })
239 }
240
241 // Bypass mocking when the client is not active.
242 if (!client) {
243 return passthrough()
244 }
245
246 // Bypass initial page load requests (i.e. static assets).
247 // The absence of the immediate/parent client in the map of the active clients
248 // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
249 // and is not ready to handle requests.
250 if (!activeClientIds.has(client.id)) {
251 return passthrough()
252 }
253
254 // Notify the client that a request has been intercepted.
255 const serializedRequest = await serializeRequest(event.request)
256 const clientMessage = await sendToClient(
257 client,
258 {
259 type: 'REQUEST',
260 payload: {
261 id: requestId,
262 interceptedAt: requestInterceptedAt,
263 ...serializedRequest,
264 },
265 },
266 [serializedRequest.body],
267 )
268
269 switch (clientMessage.type) {
270 case 'MOCK_RESPONSE': {
271 return respondWithMock(clientMessage.data)
272 }
273
274 case 'PASSTHROUGH': {
275 return passthrough()
276 }
277 }
278
279 return passthrough()
280}
281
282/**
283 * @param {Client} client
284 * @param {any} message
285 * @param {Array<Transferable>} transferrables
286 * @returns {Promise<any>}
287 */
288function sendToClient(client, message, transferrables = []) {
289 return new Promise((resolve, reject) => {
290 const channel = new MessageChannel()
291
292 channel.port1.onmessage = (event) => {
293 if (event.data && event.data.error) {
294 return reject(event.data.error)
295 }
296
297 resolve(event.data)
298 }
299
300 client.postMessage(message, [
301 channel.port2,
302 ...transferrables.filter(Boolean),
303 ])
304 })
305}
306
307/**
308 * @param {Response} response
309 * @returns {Response}
310 */
311function respondWithMock(response) {
312 // Setting response status code to 0 is a no-op.
313 // However, when responding with a "Response.error()", the produced Response
314 // instance will have status code set to 0. Since it's not possible to create
315 // a Response instance with status code 0, handle that use-case separately.
316 if (response.status === 0) {
317 return Response.error()
318 }
319
320 const mockedResponse = new Response(response.body, response)
321
322 Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
323 value: true,
324 enumerable: true,
325 })
326
327 return mockedResponse
328}
329
330/**
331 * @param {Request} request
332 */
333async function serializeRequest(request) {
334 return {
335 url: request.url,
336 mode: request.mode,
337 method: request.method,
338 headers: Object.fromEntries(request.headers.entries()),
339 cache: request.cache,
340 credentials: request.credentials,
341 destination: request.destination,
342 integrity: request.integrity,
343 redirect: request.redirect,
344 referrer: request.referrer,
345 referrerPolicy: request.referrerPolicy,
346 body: await request.arrayBuffer(),
347 keepalive: request.keepalive,
348 }
349}
apps/web/src/components/ChatView.browser.tsx+358-285
...@@ -1,279 +1,141 @@...@@ -1,279 +1,141 @@
1import "../index.css";1import "../index.css";
22
3import { type MessageId, type ThreadId } from "@t3tools/contracts";
4import {3import {
5 Outlet,4 ORCHESTRATION_WS_METHODS,
6 RouterProvider,5 type MessageId,
7 createMemoryHistory,6 type OrchestrationReadModel,
8 createRootRoute,7 type ProjectId,
9 createRoute,8 type ProviderSessionId,
10 createRouter,9 type ServerConfig,
11} from "@tanstack/react-router";10 type ThreadId,
11 type WsWelcomePayload,
12 WS_CHANNELS,
13 WS_METHODS,
14} from "@t3tools/contracts";
15import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
16import { HttpResponse, http, ws } from "msw";
17import { setupWorker } from "msw/browser";
12import { createRoot, type Root } from "react-dom/client";18import { createRoot, type Root } from "react-dom/client";
13import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";19import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
1420
15import type { ChatMessage, Thread } from "../types";21import { getRouter } from "../router";
16import ChatView from "./ChatView";22import { useStore } from "../store";
17import { estimateTimelineMessageHeight } from "./timelineHeight";23import { estimateTimelineMessageHeight } from "./timelineHeight";
1824
19const mocks = vi.hoisted(() => {
20 return {
21 markThreadVisited: vi.fn(),
22 setThreadError: vi.fn(),
23 setRuntimeMode: vi.fn(),
24 setThreadBranch: vi.fn(),
25 storeState: {
26 projects: [] as unknown[],
27 threads: [] as Thread[],
28 runtimeMode: "full-access",
29 markThreadVisited: vi.fn(),
30 setError: vi.fn(),
31 setRuntimeMode: vi.fn(),
32 setThreadBranch: vi.fn(),
33 },
34 composerDraft: {
35 prompt: "",
36 images: [] as unknown[],
37 nonPersistedImageIds: [] as string[],
38 model: null,
39 effort: null,
40 },
41 composerStore: {
42 draftsByThreadId: {},
43 draftThreadsByThreadId: {},
44 setPrompt: vi.fn(),
45 setModel: vi.fn(),
46 setEffort: vi.fn(),
47 addImage: vi.fn(),
48 addImages: vi.fn(),
49 removeImage: vi.fn(),
50 clearPersistedAttachments: vi.fn(),
51 syncPersistedAttachments: vi.fn(),
52 clearComposerContent: vi.fn(),
53 clearDraftThread: vi.fn(),
54 setDraftThreadContext: vi.fn(),
55 },
56 terminalStore: {
57 terminalStateByThreadId: {} as Record<string, unknown>,
58 setTerminalOpen: vi.fn(),
59 setTerminalHeight: vi.fn(),
60 splitTerminal: vi.fn(),
61 newTerminal: vi.fn(),
62 setActiveTerminal: vi.fn(),
63 closeTerminal: vi.fn(),
64 },
65 terminalState: {
66 terminalOpen: false,
67 terminalHeight: 280,
68 terminalIds: ["default"],
69 runningTerminalIds: [],
70 activeTerminalId: "default",
71 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
72 activeTerminalGroupId: "group-default",
73 },
74 };
75});
76
77vi.mock("@tanstack/react-query", async () => {
78 const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
79 "@tanstack/react-query",
80 );
81 return {
82 ...actual,
83 useQueryClient: () => ({}),
84 useMutation: () => ({
85 mutateAsync: vi.fn(async () => ({})),
86 isPending: false,
87 }),
88 useQuery: () => ({
89 data: undefined,
90 isLoading: false,
91 error: null,
92 }),
93 };
94});
95
96vi.mock("../store", () => ({
97 useStore: (selector: (store: typeof mocks.storeState) => unknown) => selector(mocks.storeState),
98}));
99
100vi.mock("../composerDraftStore", () => ({
101 useComposerThreadDraft: () => mocks.composerDraft,
102 useComposerDraftStore: (selector: (store: typeof mocks.composerStore) => unknown) =>
103 selector(mocks.composerStore),
104}));
105
106vi.mock("../terminalStateStore", () => ({
107 useTerminalStateStore: (selector: (store: typeof mocks.terminalStore) => unknown) =>
108 selector(mocks.terminalStore),
109 selectThreadTerminalState: () => mocks.terminalState,
110}));
111
112vi.mock("../hooks/useTurnDiffSummaries", () => ({
113 useTurnDiffSummaries: () => ({
114 turnDiffSummaries: [],
115 inferredCheckpointTurnCountByTurnId: {},
116 }),
117}));
118
119vi.mock("../hooks/useTheme", () => ({
120 useTheme: () => ({ resolvedTheme: "light" as const }),
121}));
122
123vi.mock("../nativeApi", () => ({
124 readNativeApi: () => null,
125 ensureNativeApi: () => {
126 throw new Error("Native API unavailable in browser test");
127 },
128}));
129
130vi.mock("./BranchToolbar", () => ({
131 default: () => null,
132}));
133
134vi.mock("./GitActionsControl", () => ({
135 default: () => null,
136}));
137
138vi.mock("./ProjectScriptsControl", () => ({
139 default: () => null,
140}));
141
142vi.mock("./ThreadTerminalDrawer", () => ({
143 default: () => null,
144}));
145
146vi.mock("./ComposerPromptEditor", () => ({
147 ComposerPromptEditor: () => null,
148}));
149
150vi.mock("./ui/sidebar", () => ({
151 SidebarTrigger: () => null,
152}));
153
154const THREAD_ID = "thread-browser-test" as ThreadId;25const THREAD_ID = "thread-browser-test" as ThreadId;
26const PROJECT_ID = "project-1" as ProjectId;
155const NOW_ISO = "2026-03-04T12:00:00.000Z";27const NOW_ISO = "2026-03-04T12:00:00.000Z";
156const BASE_TIME_MS = Date.parse(NOW_ISO);28const BASE_TIME_MS = Date.parse(NOW_ISO);
157const TALL_IMAGE_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(29const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
158 "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>",
159)}`;
16030
161interface RenderMeasureOptions {31interface WsRequestEnvelope {
162 timelineWidthPx: number;32 id: string;
163 messages: ChatMessage[];33 body: {
164 targetMessageId: MessageId;34 _tag: string;
35 [key: string]: unknown;
36 };
165}37}
16638
167function createTestRouter(threadId: ThreadId) {39interface TestFixture {
168 const rootRoute = createRootRoute({40 snapshot: OrchestrationReadModel;
169 component: () => <Outlet />,41 serverConfig: ServerConfig;
170 });42 welcome: WsWelcomePayload;
171 const threadRoute = createRoute({
172 getParentRoute: () => rootRoute,
173 path: "/$threadId",
174 component: () => <ChatView threadId={threadId} />,
175 });
176 return createRouter({
177 routeTree: rootRoute.addChildren([threadRoute]),
178 history: createMemoryHistory({
179 initialEntries: [`/${threadId}`],
180 }),
181 context: {},
182 });
183}43}
18444
185function createThread(messages: ChatMessage[]): Thread {45let fixture: TestFixture;
186 return {46const wsLink = ws.link(/ws(s)?:\/\/.*/);
187 id: THREAD_ID,
188 codexThreadId: null,
189 projectId: "project-1" as Thread["projectId"],
190 title: "Browser test thread",
191 model: "gpt-5",
192 session: null,
193 messages,
194 error: null,
195 createdAt: NOW_ISO,
196 latestTurn: null,
197 lastVisitedAt: NOW_ISO,
198 branch: null,
199 worktreePath: null,
200 turnDiffSummaries: [],
201 activities: [],
202 };
203}
20447
205function isoAt(offsetSeconds: number): string {48function isoAt(offsetSeconds: number): string {
206 return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString();49 return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString();
207}50}
20851
209function createUserMessage({52function createBaseServerConfig(): ServerConfig {
210 id,53 return {
211 text,54 cwd: "/repo/project",
212 offsetSeconds,55 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
213 attachments,56 keybindings: [],
214}: {57 issues: [],
58 providers: [
59 {
60 provider: "codex",
61 status: "ready",
62 available: true,
63 authStatus: "authenticated",
64 checkedAt: NOW_ISO,
65 },
66 ],
67 availableEditors: [],
68 };
69}
70
71function createUserMessage(options: {
215 id: MessageId;72 id: MessageId;
216 text: string;73 text: string;
217 offsetSeconds: number;74 offsetSeconds: number;
218 attachments?: ChatMessage["attachments"];75 attachments?: Array<{
219}): ChatMessage {76 type: "image";
77 id: string;
78 name: string;
79 mimeType: string;
80 sizeBytes: number;
81 }>;
82}) {
220 return {83 return {
221 id,84 id: options.id,
222 role: "user",85 role: "user" as const,
223 text,86 text: options.text,
224 ...(attachments && attachments.length > 0 ? { attachments } : {}),87 ...(options.attachments ? { attachments: options.attachments } : {}),
225 createdAt: isoAt(offsetSeconds),88 turnId: null,
226 completedAt: isoAt(offsetSeconds + 1),
227 streaming: false,89 streaming: false,
90 createdAt: isoAt(options.offsetSeconds),
91 updatedAt: isoAt(options.offsetSeconds + 1),
228 };92 };
229}93}
23094
231function createAssistantMessage({95function createAssistantMessage(options: {
232 id,
233 text,
234 offsetSeconds,
235}: {
236 id: MessageId;96 id: MessageId;
237 text: string;97 text: string;
238 offsetSeconds: number;98 offsetSeconds: number;
239}): ChatMessage {99}) {
240 return {100 return {
241 id,101 id: options.id,
242 role: "assistant",102 role: "assistant" as const,
243 text,103 text: options.text,
244 createdAt: isoAt(offsetSeconds),104 turnId: null,
245 completedAt: isoAt(offsetSeconds + 1),
246 streaming: false,105 streaming: false,
106 createdAt: isoAt(options.offsetSeconds),
107 updatedAt: isoAt(options.offsetSeconds + 1),
247 };108 };
248}109}
249110
250function createImageAttachments(count: number): NonNullable<ChatMessage["attachments"]> {111function createSnapshotForTargetUser(options: {
251 return Array.from({ length: count }, (_, index) => ({
252 type: "image" as const,
253 id: `attachment-${index + 1}`,
254 name: `attachment-${index + 1}.svg`,
255 mimeType: "image/svg+xml",
256 sizeBytes: 128,
257 previewUrl: TALL_IMAGE_DATA_URI,
258 }));
259}
260
261function createConversationWithTargetUser(options: {
262 targetMessageId: MessageId;112 targetMessageId: MessageId;
263 targetText: string;113 targetText: string;
264 targetAttachments?: ChatMessage["attachments"];114 targetAttachmentCount?: number;
265}): ChatMessage[] {115}): OrchestrationReadModel {
266 const messages: ChatMessage[] = [];116 const messages: Array<OrchestrationReadModel["threads"][number]["messages"][number]> = [];
117
267 for (let index = 0; index < 22; index += 1) {118 for (let index = 0; index < 22; index += 1) {
268 const userId = (`msg-user-${index}` as MessageId);
269 const assistantId = (`msg-assistant-${index}` as MessageId);
270 const isTarget = index === 3;119 const isTarget = index === 3;
120 const userId = `msg-user-${index}` as MessageId;
121 const assistantId = `msg-assistant-${index}` as MessageId;
122 const attachments =
123 isTarget && (options.targetAttachmentCount ?? 0) > 0
124 ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({
125 type: "image" as const,
126 id: `attachment-${attachmentIndex + 1}`,
127 name: `attachment-${attachmentIndex + 1}.png`,
128 mimeType: "image/png",
129 sizeBytes: 128,
130 }))
131 : undefined;
132
271 messages.push(133 messages.push(
272 createUserMessage({134 createUserMessage({
273 id: isTarget ? options.targetMessageId : userId,135 id: isTarget ? options.targetMessageId : userId,
274 text: isTarget ? options.targetText : `filler user message ${index}`,136 text: isTarget ? options.targetText : `filler user message ${index}`,
275 offsetSeconds: messages.length * 3,137 offsetSeconds: messages.length * 3,
276 attachments: isTarget ? options.targetAttachments : undefined,138 ...(attachments ? { attachments } : {}),
277 }),139 }),
278 );140 );
279 messages.push(141 messages.push(
...@@ -284,9 +146,149 @@ function createConversationWithTargetUser(options: {...@@ -284,9 +146,149 @@ function createConversationWithTargetUser(options: {
284 }),146 }),
285 );147 );
286 }148 }
287 return messages;149
150 return {
151 snapshotSequence: 1,
152 projects: [
153 {
154 id: PROJECT_ID,
155 title: "Project",
156 workspaceRoot: "/repo/project",
157 defaultModel: "gpt-5",
158 scripts: [],
159 createdAt: NOW_ISO,
160 updatedAt: NOW_ISO,
161 deletedAt: null,
162 },
163 ],
164 threads: [
165 {
166 id: THREAD_ID,
167 projectId: PROJECT_ID,
168 title: "Browser test thread",
169 model: "gpt-5",
170 branch: "main",
171 worktreePath: null,
172 latestTurn: null,
173 createdAt: NOW_ISO,
174 updatedAt: NOW_ISO,
175 deletedAt: null,
176 messages,
177 activities: [],
178 checkpoints: [],
179 session: {
180 threadId: THREAD_ID,
181 status: "ready",
182 providerName: "codex",
183 providerSessionId: "session-1" as ProviderSessionId,
184 providerThreadId: null,
185 approvalPolicy: "on-failure",
186 sandboxMode: "workspace-write",
187 activeTurnId: null,
188 lastError: null,
189 updatedAt: NOW_ISO,
190 },
191 },
192 ],
193 updatedAt: NOW_ISO,
194 };
195}
196
197function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
198 return {
199 snapshot,
200 serverConfig: createBaseServerConfig(),
201 welcome: {
202 cwd: "/repo/project",
203 projectName: "Project",
204 bootstrapProjectId: PROJECT_ID,
205 bootstrapThreadId: THREAD_ID,
206 },
207 };
208}
209
210function resolveWsRpc(tag: string): unknown {
211 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
212 return fixture.snapshot;
213 }
214 if (tag === WS_METHODS.serverGetConfig) {
215 return fixture.serverConfig;
216 }
217 if (tag === WS_METHODS.gitListBranches) {
218 return {
219 isRepo: true,
220 branches: [
221 {
222 name: "main",
223 current: true,
224 isDefault: true,
225 worktreePath: null,
226 },
227 ],
228 };
229 }
230 if (tag === WS_METHODS.gitStatus) {
231 return {
232 branch: "main",
233 hasWorkingTreeChanges: false,
234 workingTree: {
235 files: [],
236 insertions: 0,
237 deletions: 0,
238 },
239 hasUpstream: true,
240 aheadCount: 0,
241 behindCount: 0,
242 pr: null,
243 };
244 }
245 if (tag === WS_METHODS.projectsSearchEntries) {
246 return {
247 entries: [],
248 truncated: false,
249 };
250 }
251 return {};
288}252}
289253
254const worker = setupWorker(
255 wsLink.addEventListener("connection", ({ client }) => {
256 client.send(
257 JSON.stringify({
258 type: "push",
259 channel: WS_CHANNELS.serverWelcome,
260 data: fixture.welcome,
261 }),
262 );
263 client.addEventListener("message", (event) => {
264 const rawData = event.data;
265 if (typeof rawData !== "string") return;
266 let request: WsRequestEnvelope;
267 try {
268 request = JSON.parse(rawData) as WsRequestEnvelope;
269 } catch {
270 return;
271 }
272 const method = request.body?._tag;
273 if (typeof method !== "string") return;
274 client.send(
275 JSON.stringify({
276 id: request.id,
277 result: resolveWsRpc(method),
278 }),
279 );
280 });
281 }),
282 http.get("*/attachments/:attachmentId", () =>
283 HttpResponse.text(ATTACHMENT_SVG, {
284 headers: {
285 "Content-Type": "image/svg+xml",
286 },
287 }),
288 ),
289 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
290);
291
290async function nextFrame(): Promise<void> {292async function nextFrame(): Promise<void> {
291 await new Promise<void>((resolve) => {293 await new Promise<void>((resolve) => {
292 window.requestAnimationFrame(() => resolve());294 window.requestAnimationFrame(() => resolve());
...@@ -299,8 +301,24 @@ async function waitForLayout(): Promise<void> {...@@ -299,8 +301,24 @@ async function waitForLayout(): Promise<void> {
299 await nextFrame();301 await nextFrame();
300}302}
301303
304async function waitForElement<T extends Element>(
305 query: () => T | null,
306 errorMessage: string,
307): Promise<T> {
308 const timeoutAt = performance.now() + 8_000;
309 while (performance.now() < timeoutAt) {
310 const element = query();
311 if (element) return element;
312 await nextFrame();
313 }
314 throw new Error(errorMessage);
315}
316
302async function waitForImagesToLoad(scope: ParentNode): Promise<void> {317async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
303 const images = Array.from(scope.querySelectorAll("img"));318 const images = Array.from(scope.querySelectorAll("img"));
319 if (images.length === 0) {
320 return;
321 }
304 await Promise.all(322 await Promise.all(
305 images.map(323 images.map(
306 (image) =>324 (image) =>
...@@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> {...@@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
317 await waitForLayout();335 await waitForLayout();
318}336}
319337
320async function renderAndMeasureUserRow({338async function renderAndMeasureUserRow(options: {
321 timelineWidthPx,339 timelineWidthPx: number;
322 messages,340 targetMessageId: MessageId;
323 targetMessageId,341 snapshot: OrchestrationReadModel;
324}: RenderMeasureOptions): Promise<{342}): Promise<{
325 measuredRowHeightPx: number;343 measuredRowHeightPx: number;
326 timelineWidthMeasuredPx: number;344 timelineWidthMeasuredPx: number;
327 renderedInVirtualizedRegion: boolean;345 renderedInVirtualizedRegion: boolean;
328}> {346}> {
347 fixture = buildFixture(options.snapshot);
348
329 const host = document.createElement("div");349 const host = document.createElement("div");
330 host.style.width = `${timelineWidthPx}px`;350 host.style.width = `${options.timelineWidthPx}px`;
331 host.style.height = "920px";351 host.style.height = "920px";
332 host.style.display = "flex";352 host.style.display = "flex";
333 host.style.overflow = "hidden";353 host.style.overflow = "hidden";
334 document.body.append(host);354 document.body.append(host);
335355
336 mocks.storeState.threads = [createThread(messages)];356 const router = getRouter(
337 const router = createTestRouter(THREAD_ID);357 createMemoryHistory({
358 initialEntries: [`/${THREAD_ID}`],
359 }),
360 );
338361
339 const root: Root = createRoot(host);362 const root: Root = createRoot(host);
340 root.render(<RouterProvider router={router} />);363 root.render(<RouterProvider router={router} />);
341 await waitForLayout();
342364
343 const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain");365 try {
344 if (!(scrollContainer instanceof HTMLDivElement)) {366 await waitForLayout();
345 root.unmount();
346 throw new Error("Unable to find ChatView message scroll container.");
347 }
348 scrollContainer.scrollTop = 0;
349 scrollContainer.dispatchEvent(new Event("scroll"));
350 await waitForLayout();
351367
352 const row = host.querySelector<HTMLElement>(368 const scrollContainer = await waitForElement(
353 `[data-message-id="${targetMessageId}"][data-message-role="user"]`,369 () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"),
354 );370 "Unable to find ChatView message scroll container.",
355 if (!(row instanceof HTMLElement)) {371 );
356 root.unmount();
357 throw new Error("Unable to locate targeted user message row.");
358 }
359 await waitForImagesToLoad(row);
360372
361 const timelineRoot = row.closest("div.max-w-3xl");373 let row: HTMLElement | null = null;
362 if (!(timelineRoot instanceof HTMLElement)) {374 const timeoutAt = performance.now() + 8_000;
375 while (performance.now() < timeoutAt) {
376 scrollContainer.scrollTop = 0;
377 scrollContainer.dispatchEvent(new Event("scroll"));
378 await waitForLayout();
379 row = host.querySelector<HTMLElement>(
380 `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`,
381 );
382 if (row) {
383 break;
384 }
385 }
386 if (!row) {
387 throw new Error("Unable to locate targeted user message row.");
388 }
389
390 await waitForImagesToLoad(row);
391 scrollContainer.scrollTop = 0;
392 scrollContainer.dispatchEvent(new Event("scroll"));
393 await nextFrame();
394
395 const timelineRoot =
396 row.closest<HTMLElement>('[data-timeline-root="true"]') ??
397 host.querySelector<HTMLElement>('[data-timeline-root="true"]');
398 if (!(timelineRoot instanceof HTMLElement)) {
399 throw new Error("Unable to locate timeline root container.");
400 }
401
402 const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width;
403 let measuredRowHeightPx = 0;
404 let renderedInVirtualizedRegion = false;
405 const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`;
406 const measureTimeoutAt = performance.now() + 4_000;
407 while (performance.now() < measureTimeoutAt) {
408 scrollContainer.scrollTop = 0;
409 scrollContainer.dispatchEvent(new Event("scroll"));
410 await nextFrame();
411 const measuredRow = host.querySelector<HTMLElement>(rowSelector);
412 if (!measuredRow) {
413 continue;
414 }
415 measuredRowHeightPx = measuredRow.getBoundingClientRect().height;
416 renderedInVirtualizedRegion = measuredRow.closest("[data-index]") instanceof HTMLElement;
417 if (measuredRowHeightPx > 0) {
418 break;
419 }
420 }
421 if (measuredRowHeightPx <= 0) {
422 throw new Error("Unable to measure targeted user row height.");
423 }
424
425 return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion };
426 } finally {
363 root.unmount();427 root.unmount();
364 throw new Error("Unable to locate timeline root container.");428 host.remove();
365 }429 }
430}
366431
367 const measuredRowHeightPx = row.getBoundingClientRect().height;432describe("ChatView timeline estimator parity (full app)", () => {
368 const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width;433 beforeAll(async () => {
369 const renderedInVirtualizedRegion = row.closest("[data-index]") instanceof HTMLElement;434 fixture = buildFixture(
370435 createSnapshotForTargetUser({
371 root.unmount();436 targetMessageId: "msg-user-bootstrap" as MessageId,
372 host.remove();437 targetText: "bootstrap",
438 }),
439 );
440 await worker.start({
441 onUnhandledRequest: "bypass",
442 quiet: true,
443 serviceWorker: {
444 url: "/mockServiceWorker.js",
445 },
446 });
447 });
373448
374 return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion };449 afterAll(async () => {
375}450 await worker.stop();
451 });
376452
377describe("ChatView timeline estimator parity", () => {
378 beforeEach(() => {453 beforeEach(() => {
454 localStorage.clear();
379 document.body.innerHTML = "";455 document.body.innerHTML = "";
380 mocks.storeState.projects = [];456 useStore.setState({
381 mocks.storeState.threads = [];457 projects: [],
382 mocks.storeState.runtimeMode = "full-access";458 threads: [],
383 mocks.composerDraft.prompt = "";459 threadsHydrated: false,
384 mocks.composerDraft.images = [];460 runtimeMode: "full-access",
385 mocks.composerDraft.nonPersistedImageIds = [];461 });
386 mocks.composerDraft.model = null;
387 mocks.composerDraft.effort = null;
388 });462 });
389463
390 afterEach(() => {464 afterEach(() => {
...@@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => {...@@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => {
398 await renderAndMeasureUserRow({472 await renderAndMeasureUserRow({
399 timelineWidthPx: 960,473 timelineWidthPx: 960,
400 targetMessageId,474 targetMessageId,
401 messages: createConversationWithTargetUser({475 snapshot: createSnapshotForTargetUser({
402 targetMessageId,476 targetMessageId,
403 targetText: userText,477 targetText: userText,
404 }),478 }),
...@@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => {...@@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => {
417 it("tracks additional rendered wrapping when ChatView width narrows", async () => {491 it("tracks additional rendered wrapping when ChatView width narrows", async () => {
418 const userText = "x".repeat(2_400);492 const userText = "x".repeat(2_400);
419 const targetMessageId = "msg-user-target-wrap" as MessageId;493 const targetMessageId = "msg-user-target-wrap" as MessageId;
420 const messages = createConversationWithTargetUser({494 const snapshot = createSnapshotForTargetUser({
421 targetMessageId,495 targetMessageId,
422 targetText: userText,496 targetText: userText,
423 });497 });
424 const desktop = await renderAndMeasureUserRow({498 const desktop = await renderAndMeasureUserRow({
425 timelineWidthPx: 960,499 timelineWidthPx: 960,
426 targetMessageId,500 targetMessageId,
427 messages,501 snapshot,
428 });502 });
429 const mobile = await renderAndMeasureUserRow({503 const mobile = await renderAndMeasureUserRow({
430 timelineWidthPx: 360,504 timelineWidthPx: 360,
431 targetMessageId,505 targetMessageId,
432 messages,506 snapshot,
433 });507 });
434508
435 const estimatedDesktopPx = estimateTimelineMessageHeight(509 const estimatedDesktopPx = estimateTimelineMessageHeight(
...@@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => {...@@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => {
452526
453 it("keeps user attachment estimate close to actual rendered ChatView row height", async () => {527 it("keeps user attachment estimate close to actual rendered ChatView row height", async () => {
454 const targetMessageId = "msg-user-target-attachments" as MessageId;528 const targetMessageId = "msg-user-target-attachments" as MessageId;
455 const attachments = createImageAttachments(3);
456 const userText = "message with image attachments";529 const userText = "message with image attachments";
457 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =530 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
458 await renderAndMeasureUserRow({531 await renderAndMeasureUserRow({
459 timelineWidthPx: 960,532 timelineWidthPx: 960,
460 targetMessageId,533 targetMessageId,
461 messages: createConversationWithTargetUser({534 snapshot: createSnapshotForTargetUser({
462 targetMessageId,535 targetMessageId,
463 targetText: userText,536 targetText: userText,
464 targetAttachments: attachments,537 targetAttachmentCount: 3,
465 }),538 }),
466 });539 });
467540
...@@ -471,7 +544,7 @@ describe("ChatView timeline estimator parity", () => {...@@ -471,7 +544,7 @@ describe("ChatView timeline estimator parity", () => {
471 {544 {
472 role: "user",545 role: "user",
473 text: userText,546 text: userText,
474 attachments: attachments.map((attachment) => ({ id: attachment.id })),547 attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }],
475 },548 },
476 { timelineWidthPx: timelineWidthMeasuredPx },549 { timelineWidthPx: timelineWidthMeasuredPx },
477 );550 );
apps/web/src/components/ChatView.tsx+5-1
...@@ -3650,7 +3650,11 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3650,7 +3650,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
3650 }3650 }
36513651
3652 return (3652 return (
3653 <div ref={timelineRootRef} className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden">3653 <div
3654 ref={timelineRootRef}
3655 data-timeline-root="true"
3656 className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"
3657 >
3654 {virtualizedRowCount > 0 && (3658 {virtualizedRowCount > 0 && (
3655 <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>3659 <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
3656 {virtualRows.map((virtualRow: VirtualItem) => {3660 {virtualRows.map((virtualRow: VirtualItem) => {
apps/web/src/main.tsx+3-26
...@@ -1,40 +1,17 @@...@@ -1,40 +1,17 @@
1import React from "react";1import React from "react";
2import ReactDOM from "react-dom/client";2import ReactDOM from "react-dom/client";
3import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4import { RouterProvider } from "@tanstack/react-router";3import { RouterProvider } from "@tanstack/react-router";
5import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router";4import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
6import { StoreProvider } from "./store";
75
8import "@xterm/xterm/css/xterm.css";6import "@xterm/xterm/css/xterm.css";
9import "./index.css";7import "./index.css";
108
11import { APP_DISPLAY_NAME } from "./branding";
12import { isElectron } from "./env";9import { isElectron } from "./env";
13import { routeTree } from "./routeTree.gen";10import { getRouter } from "./router";
1411
15const history = isElectron ? createHashHistory() : createBrowserHistory();12const history = isElectron ? createHashHistory() : createBrowserHistory();
1613
17const queryClient = new QueryClient();14const router = getRouter(history);
18document.title = APP_DISPLAY_NAME;
19
20const router = createRouter({
21 routeTree,
22 history,
23 context: {
24 queryClient,
25 },
26 Wrap: ({ children }) => (
27 <QueryClientProvider client={queryClient}>
28 <StoreProvider>{children}</StoreProvider>
29 </QueryClientProvider>
30 ),
31});
32
33declare module "@tanstack/react-router" {
34 interface Register {
35 router: typeof router;
36 }
37}
3815
39ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(16ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
40 <React.StrictMode>17 <React.StrictMode>
apps/web/src/router.ts created+34
...@@ -0,0 +1,34 @@
1import { createElement } from "react";
2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3import { createRouter } from "@tanstack/react-router";
4
5import { routeTree } from "./routeTree.gen";
6import { StoreProvider } from "./store";
7
8type RouterHistory = NonNullable<Parameters<typeof createRouter>[0]["history"]>;
9
10export function getRouter(history: RouterHistory) {
11 const queryClient = new QueryClient();
12
13 return createRouter({
14 routeTree,
15 history,
16 context: {
17 queryClient,
18 },
19 Wrap: ({ children }) =>
20 createElement(
21 QueryClientProvider,
22 { client: queryClient },
23 createElement(StoreProvider, null, children),
24 ),
25 });
26}
27
28export type AppRouter = ReturnType<typeof getRouter>;
29
30declare module "@tanstack/react-router" {
31 interface Register {
32 router: AppRouter;
33 }
34}
apps/web/src/routes/__root.tsx+18-9
...@@ -26,6 +26,9 @@ export const Route = createRootRouteWithContext<{...@@ -26,6 +26,9 @@ export const Route = createRootRouteWithContext<{
26}>()({26}>()({
27 component: RootRouteView,27 component: RootRouteView,
28 errorComponent: RootRouteErrorView,28 errorComponent: RootRouteErrorView,
29 head: () => ({
30 meta: [{ name: "title", content: APP_DISPLAY_NAME }],
31 }),
29});32});
3033
31function RootRouteView() {34function RootRouteView() {
...@@ -152,9 +155,7 @@ function EventRouter() {...@@ -152,9 +155,7 @@ function EventRouter() {
152 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);155 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
153 syncServerReadModel(snapshot);156 syncServerReadModel(snapshot);
154 const activeThreadIds = new Set(157 const activeThreadIds = new Set(
155 snapshot.threads158 snapshot.threads.filter((t) => t.deletedAt === null).map((t) => t.id),
156 .filter((t) => t.deletedAt === null)
157 .map((t) => t.id),
158 );159 );
159 removeOrphanedTerminalStates(activeThreadIds);160 removeOrphanedTerminalStates(activeThreadIds);
160 if (pending) {161 if (pending) {
...@@ -195,11 +196,13 @@ function EventRouter() {...@@ -195,11 +196,13 @@ function EventRouter() {
195 if (hasRunningSubprocess === null) {196 if (hasRunningSubprocess === null) {
196 return;197 return;
197 }198 }
198 useTerminalStateStore.getState().setTerminalActivity(199 useTerminalStateStore
199 ThreadId.makeUnsafe(event.threadId),200 .getState()
200 event.terminalId,201 .setTerminalActivity(
201 hasRunningSubprocess,202 ThreadId.makeUnsafe(event.threadId),
202 );203 event.terminalId,
204 hasRunningSubprocess,
205 );
203 });206 });
204 const unsubWelcome = onServerWelcome((payload) => {207 const unsubWelcome = onServerWelcome((payload) => {
205 void (async () => {208 void (async () => {
...@@ -276,7 +279,13 @@ function EventRouter() {...@@ -276,7 +279,13 @@ function EventRouter() {
276 unsubWelcome();279 unsubWelcome();
277 unsubServerConfigUpdated();280 unsubServerConfigUpdated();
278 };281 };
279 }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]);282 }, [
283 navigate,
284 queryClient,
285 removeOrphanedTerminalStates,
286 setProjectExpanded,
287 syncServerReadModel,
288 ]);
280289
281 return null;290 return null;
282}291}
bun.lock+1
...@@ -94,6 +94,7 @@...@@ -94,6 +94,7 @@
94 "@vitejs/plugin-react": "^5.1.4",94 "@vitejs/plugin-react": "^5.1.4",
95 "@vitest/browser-playwright": "^4.0.18",95 "@vitest/browser-playwright": "^4.0.18",
96 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",96 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
97 "msw": "^2.12.10",
97 "playwright": "^1.58.2",98 "playwright": "^1.58.2",
98 "tailwindcss": "^4.0.0",99 "tailwindcss": "^4.0.0",
99 "typescript": "catalog:",100 "typescript": "catalog:",
package.json+7-2
...@@ -56,5 +56,10 @@...@@ -56,5 +56,10 @@
56 "bun": "^1.3.9",56 "bun": "^1.3.9",
57 "node": "^24.13.1"57 "node": "^24.13.1"
58 },58 },
59 "packageManager": "bun@1.3.9"59 "packageManager": "bun@1.3.9",
60}60 "msw": {
61 "workerDirectory": [
62 "apps/web/public"
63 ]
64 }
65}
\ No newline at end of file