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 @@
4545 "@vitejs/plugin-react": "^5.1.4",
4646 "@vitest/browser-playwright": "^4.0.18",
4747 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
48 "msw": "^2.12.10",
4849 "playwright": "^1.58.2",
4950 "tailwindcss": "^4.0.0",
5051 "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 @@
11import "../index.css";
22
3import { type MessageId, type ThreadId } from "@t3tools/contracts";
43import {
5 Outlet,
6 RouterProvider,
7 createMemoryHistory,
8 createRootRoute,
9 createRoute,
10 createRouter,
11} from "@tanstack/react-router";
4 ORCHESTRATION_WS_METHODS,
5 type MessageId,
6 type OrchestrationReadModel,
7 type ProjectId,
8 type ProviderSessionId,
9 type ServerConfig,
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";
1218import { 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";
16import ChatView from "./ChatView";
21import { getRouter } from "../router";
22import { useStore } from "../store";
1723import { 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
15425const THREAD_ID = "thread-browser-test" as ThreadId;
26const PROJECT_ID = "project-1" as ProjectId;
15527const NOW_ISO = "2026-03-04T12:00:00.000Z";
15628const BASE_TIME_MS = Date.parse(NOW_ISO);
157const TALL_IMAGE_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
158 "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>",
159)}`;
29const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
16030
161interface RenderMeasureOptions {
162 timelineWidthPx: number;
163 messages: ChatMessage[];
164 targetMessageId: MessageId;
31interface WsRequestEnvelope {
32 id: string;
33 body: {
34 _tag: string;
35 [key: string]: unknown;
36 };
16537}
16638
167function createTestRouter(threadId: ThreadId) {
168 const rootRoute = createRootRoute({
169 component: () => <Outlet />,
170 });
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 });
39interface TestFixture {
40 snapshot: OrchestrationReadModel;
41 serverConfig: ServerConfig;
42 welcome: WsWelcomePayload;
18343}
18444
185function createThread(messages: ChatMessage[]): Thread {
186 return {
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}
45let fixture: TestFixture;
46const wsLink = ws.link(/ws(s)?:\/\/.*/);
20447
20548function isoAt(offsetSeconds: number): string {
20649 return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString();
20750}
20851
209function createUserMessage({
210 id,
211 text,
212 offsetSeconds,
213 attachments,
214}: {
52function createBaseServerConfig(): ServerConfig {
53 return {
54 cwd: "/repo/project",
55 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
56 keybindings: [],
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: {
21572 id: MessageId;
21673 text: string;
21774 offsetSeconds: number;
218 attachments?: ChatMessage["attachments"];
219}): ChatMessage {
75 attachments?: Array<{
76 type: "image";
77 id: string;
78 name: string;
79 mimeType: string;
80 sizeBytes: number;
81 }>;
82}) {
22083 return {
221 id,
222 role: "user",
223 text,
224 ...(attachments && attachments.length > 0 ? { attachments } : {}),
225 createdAt: isoAt(offsetSeconds),
226 completedAt: isoAt(offsetSeconds + 1),
84 id: options.id,
85 role: "user" as const,
86 text: options.text,
87 ...(options.attachments ? { attachments: options.attachments } : {}),
88 turnId: null,
22789 streaming: false,
90 createdAt: isoAt(options.offsetSeconds),
91 updatedAt: isoAt(options.offsetSeconds + 1),
22892 };
22993}
23094
231function createAssistantMessage({
232 id,
233 text,
234 offsetSeconds,
235}: {
95function createAssistantMessage(options: {
23696 id: MessageId;
23797 text: string;
23898 offsetSeconds: number;
239}): ChatMessage {
99}) {
240100 return {
241 id,
242 role: "assistant",
243 text,
244 createdAt: isoAt(offsetSeconds),
245 completedAt: isoAt(offsetSeconds + 1),
101 id: options.id,
102 role: "assistant" as const,
103 text: options.text,
104 turnId: null,
246105 streaming: false,
106 createdAt: isoAt(options.offsetSeconds),
107 updatedAt: isoAt(options.offsetSeconds + 1),
247108 };
248109}
249110
250function createImageAttachments(count: number): NonNullable<ChatMessage["attachments"]> {
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: {
111function createSnapshotForTargetUser(options: {
262112 targetMessageId: MessageId;
263113 targetText: string;
264 targetAttachments?: ChatMessage["attachments"];
265}): ChatMessage[] {
266 const messages: ChatMessage[] = [];
114 targetAttachmentCount?: number;
115}): OrchestrationReadModel {
116 const messages: Array<OrchestrationReadModel["threads"][number]["messages"][number]> = [];
117
267118 for (let index = 0; index < 22; index += 1) {
268 const userId = (`msg-user-${index}` as MessageId);
269 const assistantId = (`msg-assistant-${index}` as MessageId);
270119 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
271133 messages.push(
272134 createUserMessage({
273135 id: isTarget ? options.targetMessageId : userId,
274136 text: isTarget ? options.targetText : `filler user message ${index}`,
275137 offsetSeconds: messages.length * 3,
276 attachments: isTarget ? options.targetAttachments : undefined,
138 ...(attachments ? { attachments } : {}),
277139 }),
278140 );
279141 messages.push(
......@@ -284,9 +146,149 @@ function createConversationWithTargetUser(options: {
284146 }),
285147 );
286148 }
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 {};
288252}
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
290292async function nextFrame(): Promise<void> {
291293 await new Promise<void>((resolve) => {
292294 window.requestAnimationFrame(() => resolve());
......@@ -299,8 +301,24 @@ async function waitForLayout(): Promise<void> {
299301 await nextFrame();
300302}
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
302317async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
303318 const images = Array.from(scope.querySelectorAll("img"));
319 if (images.length === 0) {
320 return;
321 }
304322 await Promise.all(
305323 images.map(
306324 (image) =>
......@@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
317335 await waitForLayout();
318336}
319337
320async function renderAndMeasureUserRow({
321 timelineWidthPx,
322 messages,
323 targetMessageId,
324}: RenderMeasureOptions): Promise<{
338async function renderAndMeasureUserRow(options: {
339 timelineWidthPx: number;
340 targetMessageId: MessageId;
341 snapshot: OrchestrationReadModel;
342}): Promise<{
325343 measuredRowHeightPx: number;
326344 timelineWidthMeasuredPx: number;
327345 renderedInVirtualizedRegion: boolean;
328346}> {
347 fixture = buildFixture(options.snapshot);
348
329349 const host = document.createElement("div");
330 host.style.width = `${timelineWidthPx}px`;
350 host.style.width = `${options.timelineWidthPx}px`;
331351 host.style.height = "920px";
332352 host.style.display = "flex";
333353 host.style.overflow = "hidden";
334354 document.body.append(host);
335355
336 mocks.storeState.threads = [createThread(messages)];
337 const router = createTestRouter(THREAD_ID);
356 const router = getRouter(
357 createMemoryHistory({
358 initialEntries: [`/${THREAD_ID}`],
359 }),
360 );
338361
339362 const root: Root = createRoot(host);
340363 root.render(<RouterProvider router={router} />);
341 await waitForLayout();
342364
343 const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain");
344 if (!(scrollContainer instanceof HTMLDivElement)) {
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();
365 try {
366 await waitForLayout();
351367
352 const row = host.querySelector<HTMLElement>(
353 `[data-message-id="${targetMessageId}"][data-message-role="user"]`,
354 );
355 if (!(row instanceof HTMLElement)) {
356 root.unmount();
357 throw new Error("Unable to locate targeted user message row.");
358 }
359 await waitForImagesToLoad(row);
368 const scrollContainer = await waitForElement(
369 () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"),
370 "Unable to find ChatView message scroll container.",
371 );
360372
361 const timelineRoot = row.closest("div.max-w-3xl");
362 if (!(timelineRoot instanceof HTMLElement)) {
373 let row: HTMLElement | null = null;
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 {
363427 root.unmount();
364 throw new Error("Unable to locate timeline root container.");
428 host.remove();
365429 }
430}
366431
367 const measuredRowHeightPx = row.getBoundingClientRect().height;
368 const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width;
369 const renderedInVirtualizedRegion = row.closest("[data-index]") instanceof HTMLElement;
370
371 root.unmount();
372 host.remove();
432describe("ChatView timeline estimator parity (full app)", () => {
433 beforeAll(async () => {
434 fixture = buildFixture(
435 createSnapshotForTargetUser({
436 targetMessageId: "msg-user-bootstrap" as MessageId,
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 };
375}
449 afterAll(async () => {
450 await worker.stop();
451 });
376452
377describe("ChatView timeline estimator parity", () => {
378453 beforeEach(() => {
454 localStorage.clear();
379455 document.body.innerHTML = "";
380 mocks.storeState.projects = [];
381 mocks.storeState.threads = [];
382 mocks.storeState.runtimeMode = "full-access";
383 mocks.composerDraft.prompt = "";
384 mocks.composerDraft.images = [];
385 mocks.composerDraft.nonPersistedImageIds = [];
386 mocks.composerDraft.model = null;
387 mocks.composerDraft.effort = null;
456 useStore.setState({
457 projects: [],
458 threads: [],
459 threadsHydrated: false,
460 runtimeMode: "full-access",
461 });
388462 });
389463
390464 afterEach(() => {
......@@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => {
398472 await renderAndMeasureUserRow({
399473 timelineWidthPx: 960,
400474 targetMessageId,
401 messages: createConversationWithTargetUser({
475 snapshot: createSnapshotForTargetUser({
402476 targetMessageId,
403477 targetText: userText,
404478 }),
......@@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => {
417491 it("tracks additional rendered wrapping when ChatView width narrows", async () => {
418492 const userText = "x".repeat(2_400);
419493 const targetMessageId = "msg-user-target-wrap" as MessageId;
420 const messages = createConversationWithTargetUser({
494 const snapshot = createSnapshotForTargetUser({
421495 targetMessageId,
422496 targetText: userText,
423497 });
424498 const desktop = await renderAndMeasureUserRow({
425499 timelineWidthPx: 960,
426500 targetMessageId,
427 messages,
501 snapshot,
428502 });
429503 const mobile = await renderAndMeasureUserRow({
430504 timelineWidthPx: 360,
431505 targetMessageId,
432 messages,
506 snapshot,
433507 });
434508
435509 const estimatedDesktopPx = estimateTimelineMessageHeight(
......@@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => {
452526
453527 it("keeps user attachment estimate close to actual rendered ChatView row height", async () => {
454528 const targetMessageId = "msg-user-target-attachments" as MessageId;
455 const attachments = createImageAttachments(3);
456529 const userText = "message with image attachments";
457530 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
458531 await renderAndMeasureUserRow({
459532 timelineWidthPx: 960,
460533 targetMessageId,
461 messages: createConversationWithTargetUser({
534 snapshot: createSnapshotForTargetUser({
462535 targetMessageId,
463536 targetText: userText,
464 targetAttachments: attachments,
537 targetAttachmentCount: 3,
465538 }),
466539 });
467540
......@@ -471,7 +544,7 @@ describe("ChatView timeline estimator parity", () => {
471544 {
472545 role: "user",
473546 text: userText,
474 attachments: attachments.map((attachment) => ({ id: attachment.id })),
547 attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }],
475548 },
476549 { timelineWidthPx: timelineWidthMeasuredPx },
477550 );
apps/web/src/components/ChatView.tsx+5-1
......@@ -3650,7 +3650,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
36503650 }
36513651
36523652 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 >
36543658 {virtualizedRowCount > 0 && (
36553659 <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
36563660 {virtualRows.map((virtualRow: VirtualItem) => {
apps/web/src/main.tsx+3-26
......@@ -1,40 +1,17 @@
11import React from "react";
22import ReactDOM from "react-dom/client";
3import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
43import { RouterProvider } from "@tanstack/react-router";
5import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router";
6import { StoreProvider } from "./store";
4import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
75
86import "@xterm/xterm/css/xterm.css";
97import "./index.css";
108
11import { APP_DISPLAY_NAME } from "./branding";
129import { isElectron } from "./env";
13import { routeTree } from "./routeTree.gen";
10import { getRouter } from "./router";
1411
1512const history = isElectron ? createHashHistory() : createBrowserHistory();
1613
17const queryClient = new QueryClient();
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}
14const router = getRouter(history);
3815
3916ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
4017 <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<{
2626}>()({
2727 component: RootRouteView,
2828 errorComponent: RootRouteErrorView,
29 head: () => ({
30 meta: [{ name: "title", content: APP_DISPLAY_NAME }],
31 }),
2932});
3033
3134function RootRouteView() {
......@@ -152,9 +155,7 @@ function EventRouter() {
152155 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
153156 syncServerReadModel(snapshot);
154157 const activeThreadIds = new Set(
155 snapshot.threads
156 .filter((t) => t.deletedAt === null)
157 .map((t) => t.id),
158 snapshot.threads.filter((t) => t.deletedAt === null).map((t) => t.id),
158159 );
159160 removeOrphanedTerminalStates(activeThreadIds);
160161 if (pending) {
......@@ -195,11 +196,13 @@ function EventRouter() {
195196 if (hasRunningSubprocess === null) {
196197 return;
197198 }
198 useTerminalStateStore.getState().setTerminalActivity(
199 ThreadId.makeUnsafe(event.threadId),
200 event.terminalId,
201 hasRunningSubprocess,
202 );
199 useTerminalStateStore
200 .getState()
201 .setTerminalActivity(
202 ThreadId.makeUnsafe(event.threadId),
203 event.terminalId,
204 hasRunningSubprocess,
205 );
203206 });
204207 const unsubWelcome = onServerWelcome((payload) => {
205208 void (async () => {
......@@ -276,7 +279,13 @@ function EventRouter() {
276279 unsubWelcome();
277280 unsubServerConfigUpdated();
278281 };
279 }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]);
282 }, [
283 navigate,
284 queryClient,
285 removeOrphanedTerminalStates,
286 setProjectExpanded,
287 syncServerReadModel,
288 ]);
280289
281290 return null;
282291}
bun.lock+1
......@@ -94,6 +94,7 @@
9494 "@vitejs/plugin-react": "^5.1.4",
9595 "@vitest/browser-playwright": "^4.0.18",
9696 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
97 "msw": "^2.12.10",
9798 "playwright": "^1.58.2",
9899 "tailwindcss": "^4.0.0",
99100 "typescript": "catalog:",
package.json+7-2
......@@ -56,5 +56,10 @@
5656 "bun": "^1.3.9",
5757 "node": "^24.13.1"
5858 },
59 "packageManager": "bun@1.3.9"
60}
59 "packageManager": "bun@1.3.9",
60 "msw": {
61 "workerDirectory": [
62 "apps/web/public"
63 ]
64 }
65}
\ No newline at end of file