返回 DeepSeek-Reasonix
app-chrome-tabs.test.ts
根目录 / desktop / frontend / src / __tests__ / app-chrome-tabs.test.ts
1 // Run: tsx src/__tests__/app-chrome-tabs.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, tabMetaFallbackDelay } from "../lib/tabMetaRefresh";
7 import type { TabMeta } from "../lib/types";
8
9 const testDir = dirname(fileURLToPath(import.meta.url));
10 const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
11 const appChromeSource = readFileSync(resolve(testDir, "../components/AppChrome.tsx"), "utf8");
12 const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8");
13 const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8");
14 const topicShortcutsSource = readFileSync(resolve(testDir, "../lib/topicShortcuts.ts"), "utf8");
15 const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript.tsx"), "utf8");
16 const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
17 const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8");
18 const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8");
19 const workspacePanelSource = readFileSync(resolve(testDir, "../components/WorkspacePanel.tsx"), "utf8");
20 const rewindCommitSource = readFileSync(resolve(testDir, "../lib/rewindCommit.ts"), "utf8");
21 const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8");
22 const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
23
24 let passed = 0;
25 let failed = 0;
26
27 function ok(value: unknown, label: string) {
28 if (value) {
29 process.stdout.write(` PASS ${label}\n`);
30 passed += 1;
31 } else {
32 process.stdout.write(` FAIL ${label}\n`);
33 failed += 1;
34 }
35 }
36
37 function deferred<T>() {
38 let resolve!: (value: T) => void;
39 let reject!: (reason?: unknown) => void;
40 const promise = new Promise<T>((res, rej) => {
41 resolve = res;
42 reject = rej;
43 });
44 return { promise, resolve, reject };
45 }
46
47 function matchingBlocks(selector: string): string[] {
48 const blocks: string[] = [];
49 const rule = /([^{}]+)\{([^{}]*)\}/g;
50 let match: RegExpExecArray | null;
51 while ((match = rule.exec(stylesSource)) !== null) {
52 const selectors = match[1].split(",").map((part) => part.trim());
53 if (selectors.includes(selector)) blocks.push(match[2]);
54 }
55 return blocks;
56 }
57
58 function finalDeclaration(selector: string, property: string): string | undefined {
59 let value: string | undefined;
60 for (const block of matchingBlocks(selector)) {
61 const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
62 let match: RegExpExecArray | null;
63 while ((match = declaration.exec(block)) !== null) {
64 value = match[1].trim();
65 }
66 }
67 return value;
68 }
69
70 console.log("\napp chrome tabs");
71
72 const tabMeta = (overrides: Partial<TabMeta> = {}): TabMeta => ({
73 id: "tab-1",
74 scope: "project",
75 workspaceRoot: "/repo",
76 workspaceName: "repo",
77 topicId: "topic-1",
78 topicTitle: "Topic",
79 label: "model",
80 ready: true,
81 running: false,
82 cancellable: false,
83 mode: "normal",
84 active: true,
85 cwd: "/repo",
86 ...overrides,
87 });
88
89 ok(sameTabMetaLists([tabMeta()], [tabMeta()]), "identical tab metadata suppresses redundant state writes");
90 ok(!sameTabMetaLists([tabMeta()], [tabMeta({ running: true })]), "runtime tab changes still invalidate metadata state");
91 ok(tabMetaFallbackDelay("visible") === 15_000, "visible tab metadata fallback runs at low frequency");
92 ok(tabMetaFallbackDelay("hidden") === 60_000, "hidden tab metadata fallback backs off further");
93 ok(shouldRefreshTabMetaForEvent("turn_started"), "turn start refreshes tab runtime metadata immediately");
94 ok(shouldRefreshTabMetaForEvent("approval_request"), "approval prompts refresh tab runtime metadata immediately");
95 ok(!shouldRefreshTabMetaForEvent("text_delta"), "stream deltas do not trigger tab-list requests");
96
97 {
98 const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
99 const first = deferred<TabMeta[]>();
100 const second = deferred<TabMeta[]>();
101 let loads = 0;
102 const firstRefresh = coordinator.run(() => {
103 loads += 1;
104 return first.promise;
105 });
106 const secondRefresh = coordinator.run(() => {
107 loads += 1;
108 return second.promise;
109 });
110 const saturatedRefresh = coordinator.run(() => {
111 loads += 1;
112 return Promise.resolve([]);
113 });
114 await Promise.resolve();
115 ok(loads === 2, "tab metadata refresh caps outstanding backend calls");
116
117 const latestTabs = [tabMeta({ id: "tab-latest" })];
118 second.resolve(latestTabs);
119 const saturatedResult = await saturatedRefresh;
120 ok(saturatedResult.coalesced, "saturated tab metadata refresh joins the newest request");
121 ok(saturatedResult.value === latestTabs, "saturated tab metadata refresh returns authoritative tabs instead of an empty sentinel");
122 ok(saturatedResult.latest, "coalesced newest tab metadata remains eligible to update state");
123
124 first.resolve([tabMeta({ id: "tab-stale" })]);
125 const firstResult = await firstRefresh;
126 await secondRefresh;
127 ok(!firstResult.latest, "an older tab metadata response cannot replace a newer snapshot");
128 }
129
130 {
131 const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
132 const preMutationA = deferred<TabMeta[]>();
133 const preMutationB = deferred<TabMeta[]>();
134 const postMutation = deferred<TabMeta[]>();
135 let loads = 0;
136 const loadValues: TabMeta[][] = [
137 [tabMeta({ id: "pre-a" })],
138 [tabMeta({ id: "pre-b" })],
139 [tabMeta({ id: "post-mutation" })],
140 ];
141 const loadPromises = [preMutationA.promise, preMutationB.promise, postMutation.promise];
142
143 const firstRefresh = coordinator.run(() => {
144 const index = loads;
145 loads += 1;
146 return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
147 });
148 const secondRefresh = coordinator.run(() => {
149 const index = loads;
150 loads += 1;
151 return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
152 });
153 await Promise.resolve();
154 ok(loads === 2, "pre-mutation tab metadata fills both in-flight slots");
155
156 const mutationRefresh = coordinator.run(
157 () => {
158 const index = loads;
159 loads += 1;
160 return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
161 },
162 { invalidate: true },
163 );
164 await Promise.resolve();
165 ok(loads === 2, "post-mutation refresh does not start until an in-flight slot frees");
166
167 preMutationA.resolve(loadValues[0]);
168 const firstResult = await firstRefresh;
169 await Promise.resolve();
170 await Promise.resolve();
171 ok(loads === 3, "post-mutation refresh starts as a trailing load after a slot frees");
172 ok(!firstResult.latest, "pre-mutation snapshot is not authoritative after invalidate");
173
174 const postTabs = loadValues[2];
175 postMutation.resolve(postTabs);
176 const mutationResult = await mutationRefresh;
177 ok(!mutationResult.coalesced, "post-mutation refresh does not join a pre-mutation request");
178 ok(mutationResult.value === postTabs, "post-mutation refresh returns the mutation-after snapshot");
179 ok(mutationResult.latest, "post-mutation trailing refresh remains eligible to update state");
180
181 preMutationB.resolve(loadValues[1]);
182 const secondResult = await secondRefresh;
183 ok(!secondResult.latest, "pre-mutation coalesced request cannot overwrite post-mutation state");
184 }
185
186 {
187 const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(1);
188 const preMutation = deferred<TabMeta[]>();
189 const latestPostMutation = deferred<TabMeta[]>();
190 const started: string[] = [];
191
192 const preMutationRefresh = coordinator.run(() => {
193 started.push("pre");
194 return preMutation.promise;
195 });
196 await Promise.resolve();
197
198 const firstMutationRefresh = coordinator.run(
199 () => {
200 started.push("first-mutation");
201 return Promise.resolve([tabMeta({ id: "first-mutation" })]);
202 },
203 { invalidate: true },
204 );
205 const latestMutationRefresh = coordinator.run(
206 () => {
207 started.push("latest-mutation");
208 return latestPostMutation.promise;
209 },
210 { invalidate: true },
211 );
212
213 preMutation.resolve([tabMeta({ id: "pre" })]);
214 const preMutationResult = await preMutationRefresh;
215 await Promise.resolve();
216 await Promise.resolve();
217 ok(started.join(",") === "pre,latest-mutation", "queued invalidations retain only the latest trailing load");
218 ok(!preMutationResult.latest, "queued invalidations fence the pre-mutation load");
219
220 const latestTabs = [tabMeta({ id: "latest-mutation" })];
221 latestPostMutation.resolve(latestTabs);
222 const [firstMutationResult, latestMutationResult] = await Promise.all([firstMutationRefresh, latestMutationRefresh]);
223 ok(firstMutationResult.value === latestTabs, "an older queued mutation waits for the latest post-mutation snapshot");
224 ok(latestMutationResult.value === latestTabs, "the latest queued mutation receives its post-mutation snapshot");
225 ok(firstMutationResult.latest && latestMutationResult.latest, "the shared trailing snapshot remains authoritative");
226 }
227
228 ok(
229 !appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") &&
230 appSource.includes('document.addEventListener("visibilitychange", onVisibilityChange)') &&
231 appSource.includes("createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT)") &&
232 appSource.includes("void refreshTabMetas();\n schedule();"),
233 "tab metadata refresh is event-driven with a visibility-aware fallback",
234 );
235
236 ok(
237 appSource.includes("refreshTabMetas(undefined, { afterMutation: true })") &&
238 appSource.includes("{ afterMutation: true }") &&
239 appSource.includes("if (shouldRefreshTabMetaForEvent(e.kind)) {") &&
240 appSource.includes("void refreshTabMetas(undefined, { afterMutation: true });") &&
241 /await refreshTabMetas\(\s*\(\) => isNavigationIntentCurrent\(request\.navigationIntentSeq\),\s*\{\s*afterMutation:\s*true\s*\},?\s*\)/.test(appSource),
242 "tab lifecycle events and explicit mutations force a post-mutation trailing metadata refresh",
243 );
244
245 ok(
246 /import \{ TabBar \} from "\.\/TabBar";/.test(appChromeSource),
247 "AppChrome keeps the classic top session tab strip implementation",
248 );
249
250 for (const propName of ["onTabChange", "onTabClose", "onTabsClose", "onTabsReorder", "onNewTab"]) {
251 ok(
252 new RegExp(`\\b${propName}\\b`).test(appChromeSource),
253 `AppChrome exposes ${propName} for classic tabs`,
254 );
255 }
256
257 ok(
258 /app-chrome__tab-strip/.test(appChromeSource),
259 "AppChrome markup includes classic tab strip containers",
260 );
261
262 ok(
263 /const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
264 /\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
265 "AppChrome exposes the classic drag rail on macOS and Windows",
266 );
267
268 ok(
269 finalDeclaration(".app--darwin .app-chrome--tabs .tabbar", "--wails-draggable") === "drag" &&
270 finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .tabbar", "--wails-draggable") === "drag",
271 "classic tabbar whitespace drags the window on macOS and frameless Windows",
272 );
273
274 ok(
275 finalDeclaration(".app--darwin .app-chrome--tabs .tabbar *", "--wails-draggable") === "no-drag" &&
276 finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar *", "--wails-draggable") === "no-drag",
277 "classic tabbar controls and tab gaps remain interactive no-drag regions",
278 );
279
280 ok(
281 /const WORKSPACE_PANEL_DEFAULT_OPEN = true;/.test(layoutStoreSource) &&
282 /workspacePanelOpen:\s*loadWorkspacePanelOpen\(\)/.test(layoutStoreSource) &&
283 /export function saveWorkspacePanelOpen\(open: boolean\)/.test(layoutStoreSource) &&
284 /reasonix\.workspacePanel\.open/.test(layoutStoreSource),
285 "right dock open state is restored from localStorage with expanded first-launch default",
286 );
287
288 ok(
289 finalDeclaration(".app-chrome__tab-strip", "overflow") === "hidden",
290 "AppChrome tab strip clips tabs to the available chrome width",
291 );
292
293 ok(
294 finalDeclaration(".app-chrome__tab-strip", "min-width") === "0",
295 "AppChrome tab strip can shrink beside the right dock",
296 );
297
298 ok(
299 finalDeclaration(":root[data-theme-style] .app-chrome--tabs .tabbar__tabs", "max-width")?.includes("--chrome-panel-control-size"),
300 "themed AppChrome tab lists reserve a flowing new-tab button slot",
301 );
302
303 ok(
304 finalDeclaration(":root[data-theme-style] .app-chrome--tabs .tabbar__tabs", "flex") === "0 1 auto",
305 "themed AppChrome tab lists size to tab content before shrinking",
306 );
307
308 ok(
309 finalDeclaration(":root[data-theme-style] .app-chrome--tabs .tabbar__tabs", "width") === "max-content",
310 "themed AppChrome tab lists keep the new-tab button next to the last tab",
311 );
312
313 ok(
314 finalDeclaration(":root[data-theme-style] .app-chrome--tabs .tabbar > .tooltip-trigger:has(.tabbar__new)", "flex")?.includes("--chrome-panel-control-size"),
315 "themed AppChrome new-tab button keeps a stable slot beside the tabs",
316 );
317
318 ok(
319 finalDeclaration(":root[data-theme-style] .tabbar__tab--active", "box-shadow")?.includes(
320 "inset 0 -2px 0 var(--project-accent, var(--accent))",
321 ),
322 "active themed tab carries the project-accent underline",
323 );
324
325 ok(
326 finalDeclaration(":root[data-theme-style] .tabbar__tab--active:focus-visible", "box-shadow")?.includes(
327 "inset 0 -2px 0 var(--project-accent, var(--accent))",
328 ) &&
329 finalDeclaration(":root[data-theme-style] .tabbar__tab--active:focus-visible", "box-shadow")?.includes(
330 "0 0 0 3px var(--accent-soft)",
331 ),
332 "keyboard focus on the active tab keeps both the focus ring and the accent underline",
333 );
334
335 ok(
336 matchingBlocks(".app--darwin .app-chrome--tabs .tabbar__tab--active").every(
337 (block) => !block.includes("inset 0 2px"),
338 ),
339 "macOS active tab declares no dead top-edge accent (the themed bottom-edge layer owns it)",
340 );
341
342 ok(
343 finalDeclaration(":root[data-theme-style] .tabbar__tabs", "gap") === "6px" &&
344 finalDeclaration(":root[data-theme-style] .tabbar__tab", "border") === "1px solid var(--border)",
345 "themed tabs keep distinct full outlines with visible spacing",
346 );
347
348 ok(
349 finalDeclaration(":root[data-theme-style] .app-chrome--tabs .tabbar__tab + .tabbar__tab:not(.tabbar__tab--drop-before)::before", "width") === "1px" &&
350 finalDeclaration(":root[data-theme-style] .app-chrome--tabs .tabbar__tab + .tabbar__tab:not(.tabbar__tab--drop-before)::before", "background") === "var(--border-2)",
351 "adjacent AppChrome tabs render a stronger divider inside their gap",
352 );
353
354 ok(
355 finalDeclaration(":root[data-theme-style] .tabbar__tab--active", "border-color") === "var(--border-2)" &&
356 finalDeclaration(":root[data-theme-style] .tabbar__tab--active", "font-weight") === "600",
357 "active themed tabs combine a stronger border outline and heavier label weight",
358 );
359
360 ok(
361 /workbenchChrome \? \(\s*<span className="app-chrome__spacer" aria-hidden="true" \/>/s.test(appChromeSource),
362 "AppChrome workbench branch skips the tab strip",
363 );
364
365 ok(
366 /app-chrome__tools--fixed/.test(appChromeSource),
367 "AppChrome renders the command search as a fixed chrome tool",
368 );
369
370 ok(
371 /workbenchChromeHidden\s*=\s*sidebarWorkbench/.test(appSource),
372 "workbench chrome is hidden for every desktop platform",
373 );
374
375 ok(
376 /\{!appChromeHidden && \(/.test(appSource),
377 "workbench skips rendering the top AppChrome row",
378 );
379
380 ok(
381 /topicbar__chrome-btn/.test(appSource),
382 "workbench keeps chrome controls in the topic bar",
383 );
384
385 ok(
386 /const \[transcriptRevealSignal, setTranscriptRevealSignal\] = useState\(0\);/.test(appSource) &&
387 /revealActiveSignal=\{tabRevealSignal\}/.test(appSource) &&
388 /revealSignal=\{transcriptRevealSignal\}/.test(appSource),
389 "transcript bottom reveal is decoupled from tab-strip reveal",
390 );
391
392 const tabsReorderBlock = appSource.match(/const handleTabsReorder = useCallback\([\s\S]*?\n \}, \[refreshTabMetas, reorderTabs\]\);/)?.[0] ?? "";
393 ok(
394 /setTabRevealSignal/.test(tabsReorderBlock) && !/setTranscriptRevealSignal/.test(tabsReorderBlock),
395 "tab reordering refreshes the tab strip without snapping the transcript",
396 );
397
398 ok(
399 /aria-label=\{t\("transcript\.jumpToBottom"\)\}/.test(transcriptSource) &&
400 /title=\{t\("transcript\.jumpToBottom"\)\}/.test(transcriptSource),
401 "jump-to-bottom affordance uses localized transcript text",
402 );
403
404 ok(
405 /setActive\(items\.length > 0 \? 0 : -1\)/.test(commandPaletteSource),
406 "command palette highlights the first item when opened with an empty query",
407 );
408
409 ok(
410 /topicShortcutIndexFromEvent\(event, desktopPlatform\)/.test(appSource) &&
411 /useTopicShortcuts\(!sidebarCollapsed, desktopPlatform\)/.test(appSource),
412 "topic shortcuts use the resolved desktop platform",
413 );
414
415 ok(
416 /topicShortcutLabel\(shortcutIndex, shortcutPlatform\)/.test(projectTreeSource),
417 "topic shortcut badges render the platform-specific modifier",
418 );
419
420 ok(
421 /if \(!enabled\) hideBadges\(\);/.test(topicShortcutsSource) &&
422 /if \(heldRef\.current\) hideBadges\(\);/.test(topicShortcutsSource) &&
423 /window\.removeEventListener\("blur", onBlur\);\s*hideBadges\(\);/.test(topicShortcutsSource),
424 "topic shortcut badge state is cleared when disabled, interrupted, or cleaned up",
425 );
426
427 ok(
428 /const \[rewindStatesByTab, setRewindStatesByTab\] = useState<Record<string, RewindState>>\(\{\}\);/.test(appSource) &&
429 /setRewindStateForTab\(sourceTabId, null\);/.test(appSource) &&
430 /setRewindCommittingForTab\(sourceTabId, true\);/.test(appSource),
431 "committing optimistic rewind clears only the source tab before awaiting the backend",
432 );
433
434 ok(
435 /if \(scope === "code"\) \{[\s\S]*?rewindForTabDetailed\(sourceTabId, turn, scope\)[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
436 "code-only rewind retains the committed transaction id for real undo",
437 );
438
439 ok(
440 /onSessionRevertCommitted\?\.\(workspaceTabId, result\)/.test(workspacePanelSource) &&
441 /onSessionRevertCommitted=\{handleSessionRevertCommitted\}/.test(appSource) &&
442 /handleSessionRevertCommitted[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
443 "single-file session revert publishes its transaction id to the app undo state",
444 );
445
446 ok(
447 /const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
448 /if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
449 /onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
450 /submitDisabled=\{!controllerReady\}/.test(appSource),
451 "welcome prompts and composer submit share the controller readiness gate",
452 );
453
454 ok(
455 /pendingPlanRevisionsByTab\[activeTabId\]/.test(appSource) &&
456 /commitThenSendRef\.current\(activeTabId, text\)/.test(appSource) &&
457 !/const \[pendingPlanRevision, setPendingPlanRevision\]/.test(appSource),
458 "queued plan revisions stay scoped to their source tab",
459 );
460
461 ok(
462 /commitThenSendRef\.current\(sourceTabId, trimmed, submitText\.trim\(\), structured\)/.test(appSource) &&
463 /sendToTab\(sourceTabId, displayText, submitText, undefined, structured, initialGoal\)/.test(appSource) &&
464 /onSteer=\{handleSteer\}/.test(appSource) &&
465 /composerInsertRequestsByTab\[activeTabId\]/.test(appSource) &&
466 /consumedInsertIdByDraftRef\.current\[draftKey\]/.test(composerSource),
467 "composer sends and steers carry an explicit source tab through async preparation",
468 );
469
470 ok(
471 appSource.includes('key={`${activeTabId ?? ""}:${state.approval.id}`}') &&
472 appSource.includes('key={`${activeTabId ?? ""}:${state.ask.id}`}') &&
473 /planRevisionInsertRequest\.tabId === activeTabId/.test(appSource) &&
474 /planRevisionInsertRequest\.approvalId === state\.approval\?\.id/.test(appSource),
475 "approval and ask local state is scoped by tab plus prompt identity",
476 );
477
478 ok(
479 /app\.NewSessionForTab\(tabId\)/.test(controllerSource) &&
480 /app\.ClearSessionForTab\(tabId\)/.test(controllerSource) &&
481 /app\.CompactForTab\(tabId\)/.test(controllerSource) &&
482 /import\("\.\/rewindCommit"\)/.test(controllerSource) &&
483 /app\.PreviewRewindForTab\(sourceTabId, turn, scope\)/.test(rewindCommitSource) &&
484 /app\.CommitRewindForTab\(sourceTabId, remoteLegacy \? "" : \(plan\.planId \|\| ""\), turn, scope\)/.test(rewindCommitSource) &&
485 /app\.UndoRewindForTab\(sourceTabId, transactionId\)/.test(rewindCommitSource) &&
486 /app\.ForkForTab\(sourceTabId, turn\)/.test(controllerSource) &&
487 /app\.SummarizeFromForTab\(sourceTabId, turn\)/.test(controllerSource) &&
488 /NewSessionForTab\(tabID: string\)/.test(bridgeSource) &&
489 /CompactForTab\(tabID: string\)/.test(bridgeSource) &&
490 /PreviewRewindForTab\(tabID: string, turn: number, scope: string\)/.test(bridgeSource) &&
491 /CommitRewindForTab\(tabID: string, planID: string, turn: number, scope: string\)/.test(bridgeSource) &&
492 /UndoRewindForTab\(tabID: string, transactionID: string\)/.test(bridgeSource),
493 "session-changing controller actions use explicit tab-scoped Wails bindings",
494 );
495
496 ok(
497 /plan\.coverage === "partial"/.test(rewindCommitSource) &&
498 /window\.confirm\(t\("rewind\.confirmPartialCoverage"/.test(rewindCommitSource) &&
499 /plan\?\.conflicts\?\.length \? "overwrite_checkpoint" : ""/.test(workspacePanelSource),
500 "rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation",
501 );
502
503 ok(
504 /const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
505 /hydrating=\{transcriptHydrating\}/.test(appSource),
506 "Welcome is suppressed only until transcript history has loaded",
507 );
508
509 ok(
510 /const creationEmptyHero =/.test(appSource) &&
511 /!sidebarImDetailConnection/.test(appSource) &&
512 /!transcriptHydrating/.test(appSource) &&
513 /!hydratePlaceholderActive/.test(appSource) &&
514 /chat-pane\$\{creationEmptyHero \? " chat-pane--creation-empty" : ""\}/.test(appSource) &&
515 /heroMode=\{creationEmptyHero\}/.test(appSource),
516 "Creation empty hero waits for hydration and skips IM/Bot detail panels",
517 );
518
519 ok(
520 /if \(heroMode\) \{[\s\S]*?const maxHeight = 96;[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
521 !/if \(heroMode\) \{\s*setTextareaAutoHeight\(20\);/.test(composerSource),
522 "Creation hero composer auto-grows multi-line drafts instead of clipping at 20px",
523 );
524
525 ok(
526 /const \[workspaceControllerEpoch, setWorkspaceControllerEpoch\] = useState\(0\);/.test(appSource) &&
527 /const workspaceScopeKey = \[/.test(appSource) &&
528 /activeTab\?\.sessionPath/.test(appSource) &&
529 /state\.meta\?\.sessionPath/.test(appSource) &&
530 /state\.meta\?\.cwd/.test(appSource) &&
531 /state\.sessionGen/.test(appSource) &&
532 /workspaceControllerEpoch/.test(appSource) &&
533 Array.from(appSource.matchAll(/workspaceScopeKey=\{workspaceScopeKey\}/g)).length === 3,
534 "workspace file consumers receive a session and controller scoped identity",
535 );
536
537 ok(
538 /const unsubReady = onReady\(\(readyTabId\) => \{[\s\S]*?setWorkspaceControllerEpoch[\s\S]*?\n \}\);/.test(appSource) &&
539 /const unsubRebuilt = onRuntimeRebuilt\(\(rebuiltTabId\) => \{[\s\S]*?setWorkspaceControllerEpoch[\s\S]*?\n \}\);/.test(appSource),
540 "controller ready and rebuilt events invalidate active workspace file scopes",
541 );
542
543 const navigationBlock = appSource.match(/const runNavigationRequest = useCallback\([\s\S]*?\n \}, \[[^\]]*singleSurfaceLayout[^\]]*\]\);/)?.[0] ?? "";
544 ok(
545 /const navigationRunningRef = useRef\(false\);/.test(appSource) &&
546 /const navigationPendingRef = useRef<PendingDesktopNavigationRequest \| null>\(null\);/.test(appSource) &&
547 /const runNavigationRequest = useCallback\(async \(request: PendingDesktopNavigationRequest\)/.test(appSource) &&
548 /const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\);/.test(appSource) &&
549 /return activateTopic\(scope, workspaceRoot, topicId, sessionPath \|\| "", request\.navigationIntentSeq\)/.test(appSource) &&
550 /return openTopicSession\(scope, workspaceRoot, topicId, sessionPath, request\.navigationIntentSeq\)/.test(appSource) &&
551 /return openGlobalTab\(topicId, request\.navigationIntentSeq\)/.test(appSource) &&
552 /return openProjectTab\(workspaceRoot, topicId, request\.navigationIntentSeq\)/.test(appSource) &&
553 /enqueueNavigationRequest\([\s\S]*runningRef: navigationRunningRef, pendingRef: navigationPendingRef/.test(appSource) &&
554 !/openTopicQueueRef\.current\.catch\(\(\) => \{\}\)\.then/.test(appSource) &&
555 /const refreshLatestTabMetas = async \(\): Promise<TabMeta\[]> => \{[\s\S]*if \(latest\(\)\) setTabMetas\(tabs\);/.test(navigationBlock) &&
556 /if \(!latest\(\)\) return;[\s\S]*seedActiveTabMeta\(openedTab\);[\s\S]*void refreshLatestTabMetas\(\);/.test(navigationBlock),
557 "desktop navigation coalesces pending requests, ignores stale results, and seeds active tab metadata before background refresh",
558 );
559
560 ok(
561 /return enqueueNavigation\(\{ kind: "topic", scope, workspaceRoot, topicId, sessionPath \}\);/.test(appSource) &&
562 /enqueueNavigation\(\{ kind: "blank", scope, workspaceRoot: scope === "project" \? workspaceRoot : "" \}\)/.test(appSource) &&
563 /return enqueueNavigation\(\{ kind: "sidebar-im", connection \}\);/.test(appSource) &&
564 /return enqueueNavigation\(\{ kind: "resume-session", session \}\);/.test(appSource),
565 "topic, blank, IM, and history navigation all use the shared coalescing path",
566 );
567
568 ok(
569 !/await resumeSession\(session\.path, targetTab\.id\);/.test(navigationBlock),
570 "history navigation does not re-resume a session that OpenTopicSession already pinned",
571 );
572
573 ok(
574 /<HeartbeatPanel[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
575 "heartbeat topic navigation uses the guarded open-topic path",
576 );
577
578 for (const selector of [
579 ".app--darwin .app-chrome--tabs",
580 ":root[data-theme-style] .app--darwin .app-chrome--tabs",
581 ]) {
582 const rightSpace = finalDeclaration(selector, "padding-right") ?? finalDeclaration(selector, "padding") ?? "";
583 ok(
584 rightSpace.includes("--chrome-toggle-size") && !rightSpace.includes("--chrome-right-toggle-offset"),
585 `${selector} reserves fixed chrome tool width without shrinking for the right dock`,
586 );
587 }
588
589 for (const selector of [
590 ".app--windows .app-chrome--native-tabs",
591 ".app--linux .app-chrome--native-tabs",
592 ":root[data-theme-style] .app--windows .app-chrome--native-tabs",
593 ":root[data-theme-style] .app--linux .app-chrome--native-tabs",
594 ]) {
595 const rightSpace = finalDeclaration(selector, "padding-right") ?? finalDeclaration(selector, "padding") ?? "";
596 ok(
597 rightSpace.includes("--chrome-right-toggle-offset"),
598 `${selector} reserves right-dock width before rendering tabs`,
599 );
600 }
601
602 for (const selector of [
603 ".app--windows-frameless .app-chrome--native-tabs",
604 ":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
605 ]) {
606 const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
607 ok(
608 finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
609 paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
610 paddingRight.includes("--chrome-panel-control-size") &&
611 !paddingRight.includes("--chrome-right-toggle-offset"),
612 `${selector} keeps titlebar tools fixed beside the Windows controls`,
613 );
614 }
615
616 for (const selector of [
617 ".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
618 ":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
619 ]) {
620 ok(
621 finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
622 `${selector} stays fixed outside the Windows window controls`,
623 );
624 }
625
626 ok(
627 finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
628 finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
629 finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
630 "Windows classic chrome keeps a draggable rail while tabs remain clickable",
631 );
632
633 ok(
634 finalDeclaration(".sidebar", "--wails-draggable") === "drag" &&
635 finalDeclaration(".app--windows .sidebar", "--wails-draggable") === "no-drag" &&
636 finalDeclaration(".sidebar-resizer", "--wails-draggable") === "no-drag",
637 "Windows sidebar avoids native window drag without changing other platforms",
638 );
639
640 ok(
641 finalDeclaration(".app--windows.app--creation .topicbar", "position") === "relative" &&
642 finalDeclaration(".app--windows.app--creation .topicbar", "z-index") === "var(--z-app-chrome)" &&
643 finalDeclaration(".app--windows.app--creation .topicbar", "min-height") === "40px" &&
644 finalDeclaration(":root[data-theme-style] .app--windows.app--creation .topicbar", "min-height") === "40px" &&
645 finalDeclaration(".app--windows.app--creation .topicbar", "transform") === "none !important" &&
646 finalDeclaration(".app--windows.app--creation .topicbar__title-row", "transform") === "none" &&
647 finalDeclaration(".app--windows-frameless.app--creation", "--windows-window-controls-height") === "40px" &&
648 finalDeclaration(".app--creation .topicbar", "min-height") === "56px" &&
649 finalDeclaration(":root[data-theme-style] .app--creation .topicbar", "padding-top") === "14px" &&
650 finalDeclaration(".app--creation .topicbar__title-row", "transform") === "translateY(-3px)",
651 "Windows Creation stays 40px while macOS and Linux keep the shared Creation geometry",
652 );
653
654 for (const selector of [
655 ".layout--workbench-chrome-hidden",
656 ":root[data-theme-style] .layout--workbench-chrome-hidden",
657 ]) {
658 ok(
659 finalDeclaration(selector, "--app-chrome-height") === "0px" &&
660 finalDeclaration(selector, "grid-template-rows") === "minmax(0, 1fr) var(--statusbar-height)" &&
661 finalDeclaration(selector, "background") === "var(--bg)",
662 `${selector} removes the workbench chrome row`,
663 );
664 }
665
666 ok(
667 finalDeclaration(":root[data-theme-style] .app--darwin .layout--workbench-chrome-hidden", "--app-chrome-height") === "0px" &&
668 finalDeclaration(".app--darwin .layout--workbench-chrome-hidden .sidebar--workbench", "padding-top") === "46px" &&
669 finalDeclaration(".app--darwin .layout--workbench-chrome-hidden.layout--sidebar-collapsed .topicbar", "padding-left") === "96px",
670 "macOS workbench leaves safe space for inset window controls",
671 );
672
673 ok(
674 finalDeclaration(".app--darwin .layout--workbench-chrome-hidden.layout--workspace-maximized .workbench-dock__tools", "padding-left") === "96px",
675 "macOS maximized workbench dock leaves safe space for inset window controls",
676 );
677
678 ok(
679 /@media \(max-width: 820px\) \{[\s\S]*\.app--darwin \.layout--workbench-chrome-hidden \.topicbar\s*\{[\s\S]*padding-left:\s*96px;/.test(stylesSource) &&
680 /@media \(max-width: 820px\) \{[\s\S]*\.app--darwin \.layout--workbench-chrome-hidden\.layout--workspace-maximized \.workbench-dock__tools\s*\{[\s\S]*padding-left:\s*96px;/.test(stylesSource),
681 "macOS workbench keeps safe space when responsive CSS hides the sidebar",
682 );
683
684 ok(
685 finalDeclaration(".workbench-dock__tools", "--wails-draggable") === "drag" &&
686 finalDeclaration(".workbench-dock__tabs", "--wails-draggable") === "no-drag" &&
687 finalDeclaration(".workbench-dock__tab", "--wails-draggable") === "no-drag",
688 "maximized workbench dock keeps a draggable title region while tabs remain clickable",
689 );
690
691 ok(
692 finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
693 "active dock underline stays inside the visible dock edge",
694 );
695
696 for (const selector of [
697 ".app--classic .workbench-dock__tab + .workbench-dock__tab::before",
698 ".app--workbench .workbench-dock__tab + .workbench-dock__tab::before",
699 ]) {
700 ok(
701 finalDeclaration(selector, "width") === "1px" &&
702 finalDeclaration(selector, "height") === "16px" &&
703 finalDeclaration(selector, "background")?.includes("--border-soft"),
704 `${selector} renders a restrained divider between right-dock tabs`,
705 );
706 }
707
708 ok(
709 finalDeclaration(".app--creation .workbench-dock__tab + .workbench-dock__tab::before", "content") === undefined,
710 "Creation right-dock tabs keep their equal-column treatment without dividers",
711 );
712
713 for (const selector of [
714 ".app--windows-frameless.app--workbench .workbench-dock__tools",
715 ":root[data-theme-style] .app--windows-frameless.app--workbench .workbench-dock__tools",
716 ]) {
717 const padding = finalDeclaration(selector, "padding") ?? "";
718 ok(
719 finalDeclaration(selector, "height") === "calc(40px + var(--windows-window-controls-height))" &&
720 padding === "var(--windows-window-controls-height) 12px 0" &&
721 !padding.includes("--windows-window-controls-safe"),
722 `${selector} keeps dock tabs on a full-width row below Windows controls`,
723 );
724 }
725
726 for (const selector of [
727 ".app--windows-frameless.app--workbench .workbench-dock__tools::before",
728 ":root[data-theme-style] .app--windows-frameless.app--workbench .workbench-dock__tools::before",
729 ]) {
730 ok(
731 finalDeclaration(selector, "top") === "calc(var(--windows-window-controls-height) - 1px)" &&
732 finalDeclaration(selector, "height") === "1px",
733 `${selector} separates the Windows title row from the dock tabs`,
734 );
735 }
736
737 ok(
738 finalDeclaration(".app--windows-frameless:not(.app--workbench) .workbench-dock__tools", "padding-right") === undefined &&
739 finalDeclaration(":root[data-theme-style] .app--windows-frameless:not(.app--workbench) .workbench-dock__tools", "padding-right") === undefined,
740 "classic dock tabs do not reserve native window-control space on their separate chrome row",
741 );
742
743 ok(
744 /@container \(max-width: 420px\) \{[\s\S]*?\.app--classic \.workbench-dock__tab,[\s\S]*?\.app--workbench \.workbench-dock__tab,[\s\S]*?padding-left:\s*10px;[\s\S]*?padding-right:\s*10px;[\s\S]*?gap:\s*4px;/.test(stylesSource),
745 "classic and workbench share the same compact four-tab spacing at narrow dock widths",
746 );
747
748 for (const selector of [
749 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar",
750 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__chrome-btn",
751 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__icon-btn",
752 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__action-btn",
753 ]) {
754 ok(
755 finalDeclaration(selector, "box-shadow") === "none",
756 `${selector} stays flat after removing the workbench chrome row`,
757 );
758 }
759
760 ok(
761 finalDeclaration(":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar", "background") === "var(--bg-elev)",
762 "workbench topic bar uses elevated background for light-mode white",
763 );
764
765 for (const selector of [
766 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__identity",
767 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__title-row",
768 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__title-row h1",
769 ":root[data-theme-style] .layout--workbench-chrome-hidden .tooltip-trigger:has(.topicbar__icon-btn)",
770 ]) {
771 ok(
772 finalDeclaration(selector, "background") === "transparent" &&
773 finalDeclaration(selector, "box-shadow") === "none" &&
774 finalDeclaration(selector, "filter") === "none",
775 `${selector} cannot paint residual title-row shadows in workbench mode`,
776 );
777 }
778
779 for (const selector of [
780 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__icon-btn",
781 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__chrome-btn",
782 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__icon-btn:hover",
783 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__icon-btn:focus-visible",
784 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__chrome-btn:hover:not(.topicbar__chrome-btn--blocked)",
785 ":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__chrome-btn:focus-visible:not(.topicbar__chrome-btn--blocked)",
786 ]) {
787 ok(
788 finalDeclaration(selector, "background") === "transparent",
789 `${selector} does not paint a hover block in workbench mode`,
790 );
791 }
792
793 ok(
794 finalDeclaration(".skip-to-composer", "box-shadow") === "none" &&
795 finalDeclaration(".skip-to-composer:focus-visible", "box-shadow")?.includes("0 12px 28px"),
796 "offscreen skip link does not leak its focus shadow into the workbench title area",
797 );
798
799 // The Wails drag runtime drops any mousedown with detail !== 1, so a double
800 // click on a drag region never reaches the OS: both title-bar-hiding platforms
801 // have to zoom from here or not at all.
802 ok(
803 /chromeDoubleClickZooms\s*=\s*windowsFramelessChrome\s*\|\|\s*desktopPlatform === "darwin"/.test(appSource),
804 "title-bar double click zooms on macOS as well as frameless Windows",
805 );
806 ok(
807 /handleChromeTitlebarDoubleClick[\s\S]{0,700}?closest\("button, input, textarea, select, a, \[role='button'\], \[role='tab'\], \.windows-window-controls"\)/.test(appSource),
808 "title-bar double click still ignores interactive controls",
809 );
810 ok(
811 /function isMacOSWorkbenchSidebarTitlebar[\s\S]{0,500}?closest\("\.sidebar--workbench"\)[\s\S]{0,500}?MACOS_WORKBENCH_TITLEBAR_HEIGHT/.test(appSource) &&
812 /handleChromeTitlebarDoubleClick[\s\S]{0,400}?isMacOSWorkbenchSidebarTitlebar\(target, event\.clientY, desktopPlatform\)/.test(appSource) &&
813 !appSource.includes("window.runtime?.WindowToggleMaximise") &&
814 !bridgeSource.includes("WindowToggleMaximise?(): void;"),
815 "macOS workbench sidebar titlebar reuses the centralized zoom path",
816 );
817
818 console.log(`\n${passed} passed, ${failed} failed`);
819 if (failed > 0) process.exit(1);
820
820 lines TYPESCRIPT