| 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, "../AppRuntime.tsx"), "utf8"), workspaceFocusSource = readFileSync(resolve(testDir, "../lib/workspaceRefreshStore.ts"), "utf8"); |
| 11 | const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8"); |
| 12 | const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8"); |
| 13 | const topicShortcutsSource = readFileSync(resolve(testDir, "../lib/topicShortcuts.ts"), "utf8"); |
| 14 | const topicShortcutOwnerSource = readFileSync(resolve(testDir, "../app-runtime/useTopicNavigationShortcuts.ts"), "utf8"); |
| 15 | const runtimeHandlersSource = readFileSync(resolve(testDir, "../app-runtime/useRuntimeEventHandlers.ts"), "utf8"); |
| 16 | const sessionNavigationSource = readFileSync(resolve(testDir, "../app-runtime/useSessionNavigationCommands.ts"), "utf8"); |
| 17 | const chromeCommandsSource = readFileSync(resolve(testDir, "../app-runtime/useAppChromeCommands.ts"), "utf8"); |
| 18 | const dockToggleSource = readFileSync(resolve(testDir, "../app-shell/DockToggleButton.tsx"), "utf8"); |
| 19 | const chatPaneSource = readFileSync(resolve(testDir, "../app-shell/ChatPaneRegion.tsx"), "utf8"); |
| 20 | const transcriptSurfaceSource = readFileSync(resolve(testDir, "../app-runtime/useTranscriptSurfaceProjection.ts"), "utf8"); |
| 21 | const desktopNavigationOwnerSource = readFileSync(resolve(testDir, "../app-runtime/desktopNavigationOwner.ts"), "utf8"); |
| 22 | const appViewSource = readFileSync(resolve(testDir, "../app-shell/AppRuntimeView.tsx"), "utf8"); |
| 23 | const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript.tsx"), "utf8"); |
| 24 | const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8"); |
| 25 | const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8"), forkWorktreeSource = readFileSync(resolve(testDir, "../lib/forkWorktree.ts"), "utf8"); |
| 26 | const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8"); |
| 27 | const workspacePanelSource = readFileSync(resolve(testDir, "../components/WorkspacePanel.tsx"), "utf8"); |
| 28 | const rewindCommitSource = readFileSync(resolve(testDir, "../lib/rewindCommit.ts"), "utf8"); |
| 29 | const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8"); |
| 30 | const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); |
| 31 | |
| 32 | let passed = 0; |
| 33 | let failed = 0; |
| 34 | |
| 35 | function ok(value: unknown, label: string) { |
| 36 | if (value) { |
| 37 | process.stdout.write(` PASS ${label}\n`); |
| 38 | passed += 1; |
| 39 | } else { |
| 40 | process.stdout.write(` FAIL ${label}\n`); |
| 41 | failed += 1; |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | function deferred<T>() { |
| 46 | let resolve!: (value: T) => void; |
| 47 | let reject!: (reason?: unknown) => void; |
| 48 | const promise = new Promise<T>((res, rej) => { |
| 49 | resolve = res; |
| 50 | reject = rej; |
| 51 | }); |
| 52 | return { promise, resolve, reject }; |
| 53 | } |
| 54 | |
| 55 | function matchingBlocks(selector: string): string[] { |
| 56 | const blocks: string[] = []; |
| 57 | const rule = /([^{}]+)\{([^{}]*)\}/g; |
| 58 | let match: RegExpExecArray | null; |
| 59 | while ((match = rule.exec(stylesSource)) !== null) { |
| 60 | const selectors = match[1].split(",").map((part) => part.trim()); |
| 61 | if (selectors.includes(selector)) blocks.push(match[2]); |
| 62 | } |
| 63 | return blocks; |
| 64 | } |
| 65 | |
| 66 | function finalDeclaration(selector: string, property: string): string | undefined { |
| 67 | let value: string | undefined; |
| 68 | for (const block of matchingBlocks(selector)) { |
| 69 | const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g"); |
| 70 | let match: RegExpExecArray | null; |
| 71 | while ((match = declaration.exec(block)) !== null) { |
| 72 | value = match[1].trim(); |
| 73 | } |
| 74 | } |
| 75 | return value; |
| 76 | } |
| 77 | |
| 78 | console.log("\napp chrome tabs"); |
| 79 | |
| 80 | const tabMeta = (overrides: Partial<TabMeta> = {}): TabMeta => ({ |
| 81 | id: "tab-1", |
| 82 | scope: "project", |
| 83 | workspaceRoot: "/repo", |
| 84 | workspaceName: "repo", |
| 85 | topicId: "topic-1", |
| 86 | topicTitle: "Topic", |
| 87 | label: "model", |
| 88 | ready: true, |
| 89 | running: false, |
| 90 | cancellable: false, |
| 91 | mode: "normal", |
| 92 | active: true, |
| 93 | cwd: "/repo", |
| 94 | ...overrides, |
| 95 | }); |
| 96 | |
| 97 | ok(sameTabMetaLists([tabMeta()], [tabMeta()]), "identical tab metadata suppresses redundant state writes"); |
| 98 | ok(!sameTabMetaLists([tabMeta()], [tabMeta({ running: true })]), "runtime tab changes still invalidate metadata state"); |
| 99 | ok(tabMetaFallbackDelay("visible") === 15_000, "visible tab metadata fallback runs at low frequency"); |
| 100 | ok(tabMetaFallbackDelay("hidden") === 60_000, "hidden tab metadata fallback backs off further"); |
| 101 | ok(shouldRefreshTabMetaForEvent("turn_started"), "turn start refreshes tab runtime metadata immediately"); |
| 102 | ok(shouldRefreshTabMetaForEvent("approval_request"), "approval prompts refresh tab runtime metadata immediately"); |
| 103 | ok(!shouldRefreshTabMetaForEvent("text_delta"), "stream deltas do not trigger tab-list requests"); |
| 104 | |
| 105 | { |
| 106 | const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2); |
| 107 | const first = deferred<TabMeta[]>(); |
| 108 | const second = deferred<TabMeta[]>(); |
| 109 | let loads = 0; |
| 110 | const firstRefresh = coordinator.run(() => { |
| 111 | loads += 1; |
| 112 | return first.promise; |
| 113 | }); |
| 114 | const secondRefresh = coordinator.run(() => { |
| 115 | loads += 1; |
| 116 | return second.promise; |
| 117 | }); |
| 118 | const saturatedRefresh = coordinator.run(() => { |
| 119 | loads += 1; |
| 120 | return Promise.resolve([]); |
| 121 | }); |
| 122 | await Promise.resolve(); |
| 123 | ok(loads === 2, "tab metadata refresh caps outstanding backend calls"); |
| 124 | |
| 125 | const latestTabs = [tabMeta({ id: "tab-latest" })]; |
| 126 | second.resolve(latestTabs); |
| 127 | const saturatedResult = await saturatedRefresh; |
| 128 | ok(saturatedResult.coalesced, "saturated tab metadata refresh joins the newest request"); |
| 129 | ok(saturatedResult.value === latestTabs, "saturated tab metadata refresh returns authoritative tabs instead of an empty sentinel"); |
| 130 | ok(saturatedResult.latest, "coalesced newest tab metadata remains eligible to update state"); |
| 131 | |
| 132 | first.resolve([tabMeta({ id: "tab-stale" })]); |
| 133 | const firstResult = await firstRefresh; |
| 134 | await secondRefresh; |
| 135 | ok(!firstResult.latest, "an older tab metadata response cannot replace a newer snapshot"); |
| 136 | } |
| 137 | |
| 138 | { |
| 139 | const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2); |
| 140 | const preMutationA = deferred<TabMeta[]>(); |
| 141 | const preMutationB = deferred<TabMeta[]>(); |
| 142 | const postMutation = deferred<TabMeta[]>(); |
| 143 | let loads = 0; |
| 144 | const loadValues: TabMeta[][] = [ |
| 145 | [tabMeta({ id: "pre-a" })], |
| 146 | [tabMeta({ id: "pre-b" })], |
| 147 | [tabMeta({ id: "post-mutation" })], |
| 148 | ]; |
| 149 | const loadPromises = [preMutationA.promise, preMutationB.promise, postMutation.promise]; |
| 150 | |
| 151 | const firstRefresh = coordinator.run(() => { |
| 152 | const index = loads; |
| 153 | loads += 1; |
| 154 | return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []); |
| 155 | }); |
| 156 | const secondRefresh = coordinator.run(() => { |
| 157 | const index = loads; |
| 158 | loads += 1; |
| 159 | return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []); |
| 160 | }); |
| 161 | await Promise.resolve(); |
| 162 | ok(loads === 2, "pre-mutation tab metadata fills both in-flight slots"); |
| 163 | |
| 164 | const mutationRefresh = coordinator.run( |
| 165 | () => { |
| 166 | const index = loads; |
| 167 | loads += 1; |
| 168 | return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []); |
| 169 | }, |
| 170 | { invalidate: true }, |
| 171 | ); |
| 172 | await Promise.resolve(); |
| 173 | ok(loads === 2, "post-mutation refresh does not start until an in-flight slot frees"); |
| 174 | |
| 175 | preMutationA.resolve(loadValues[0]); |
| 176 | const firstResult = await firstRefresh; |
| 177 | await Promise.resolve(); |
| 178 | await Promise.resolve(); |
| 179 | ok(loads === 3, "post-mutation refresh starts as a trailing load after a slot frees"); |
| 180 | ok(!firstResult.latest, "pre-mutation snapshot is not authoritative after invalidate"); |
| 181 | |
| 182 | const postTabs = loadValues[2]; |
| 183 | postMutation.resolve(postTabs); |
| 184 | const mutationResult = await mutationRefresh; |
| 185 | ok(!mutationResult.coalesced, "post-mutation refresh does not join a pre-mutation request"); |
| 186 | ok(mutationResult.value === postTabs, "post-mutation refresh returns the mutation-after snapshot"); |
| 187 | ok(mutationResult.latest, "post-mutation trailing refresh remains eligible to update state"); |
| 188 | |
| 189 | preMutationB.resolve(loadValues[1]); |
| 190 | const secondResult = await secondRefresh; |
| 191 | ok(!secondResult.latest, "pre-mutation coalesced request cannot overwrite post-mutation state"); |
| 192 | } |
| 193 | |
| 194 | { |
| 195 | const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(1); |
| 196 | const preMutation = deferred<TabMeta[]>(); |
| 197 | const latestPostMutation = deferred<TabMeta[]>(); |
| 198 | const started: string[] = []; |
| 199 | |
| 200 | const preMutationRefresh = coordinator.run(() => { |
| 201 | started.push("pre"); |
| 202 | return preMutation.promise; |
| 203 | }); |
| 204 | await Promise.resolve(); |
| 205 | |
| 206 | const firstMutationRefresh = coordinator.run( |
| 207 | () => { |
| 208 | started.push("first-mutation"); |
| 209 | return Promise.resolve([tabMeta({ id: "first-mutation" })]); |
| 210 | }, |
| 211 | { invalidate: true }, |
| 212 | ); |
| 213 | const latestMutationRefresh = coordinator.run( |
| 214 | () => { |
| 215 | started.push("latest-mutation"); |
| 216 | return latestPostMutation.promise; |
| 217 | }, |
| 218 | { invalidate: true }, |
| 219 | ); |
| 220 | |
| 221 | preMutation.resolve([tabMeta({ id: "pre" })]); |
| 222 | const preMutationResult = await preMutationRefresh; |
| 223 | await Promise.resolve(); |
| 224 | await Promise.resolve(); |
| 225 | ok(started.join(",") === "pre,latest-mutation", "queued invalidations retain only the latest trailing load"); |
| 226 | ok(!preMutationResult.latest, "queued invalidations fence the pre-mutation load"); |
| 227 | |
| 228 | const latestTabs = [tabMeta({ id: "latest-mutation" })]; |
| 229 | latestPostMutation.resolve(latestTabs); |
| 230 | const [firstMutationResult, latestMutationResult] = await Promise.all([firstMutationRefresh, latestMutationRefresh]); |
| 231 | ok(firstMutationResult.value === latestTabs, "an older queued mutation waits for the latest post-mutation snapshot"); |
| 232 | ok(latestMutationResult.value === latestTabs, "the latest queued mutation receives its post-mutation snapshot"); |
| 233 | ok(firstMutationResult.latest && latestMutationResult.latest, "the shared trailing snapshot remains authoritative"); |
| 234 | } |
| 235 | |
| 236 | ok( |
| 237 | !appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") && runtimeHandlersSource.includes('import("../lib/workspaceRefreshStore")') && |
| 238 | workspaceFocusSource.includes('document.addEventListener("visibilitychange", onVisibilityChange)') && |
| 239 | runtimeHandlersSource.includes("createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT)") && |
| 240 | /void refreshTabMetas\(\);\s+schedule\(\);/.test(workspaceFocusSource), |
| 241 | "tab metadata refresh is event-driven with a visibility-aware fallback", |
| 242 | ); |
| 243 | |
| 244 | |
| 245 | ok( |
| 246 | /const WORKSPACE_PANEL_DEFAULT_OPEN = true;/.test(layoutStoreSource) && |
| 247 | /workspacePanelOpen:\s*loadWorkspacePanelOpen\(""\)/.test(layoutStoreSource) && |
| 248 | /export function saveWorkspacePanelOpen\(open: boolean, workspaceRoot = ""\)/.test(layoutStoreSource) && |
| 249 | /reasonix\.workspacePanel\.open/.test(layoutStoreSource), |
| 250 | "right dock open state is restored from per-project localStorage with expanded first-launch default", |
| 251 | ); |
| 252 | |
| 253 | ok( |
| 254 | /const workbenchChromeHidden = true/.test(appViewSource), |
| 255 | "workbench chrome is hidden for every desktop platform", |
| 256 | ); |
| 257 | |
| 258 | ok( |
| 259 | /topicbar__chrome-btn/.test(dockToggleSource), |
| 260 | "workbench keeps chrome controls in the topic bar", |
| 261 | ); |
| 262 | |
| 263 | // The app tab strip that consumed the tab reveal signal is gone; the transcript |
| 264 | // keeps its own cell and the shared reveal still has to bump both independently. |
| 265 | ok(!appSource.includes("transcriptRevealSignal"), "retired transcript reveal state is removed"); |
| 266 | |
| 267 | |
| 268 | ok(transcriptSource.includes('t("chat.toLatest")'), "jump-to-bottom affordance uses localized transcript text"); |
| 269 | |
| 270 | ok( |
| 271 | /setActive\(items\.length > 0 \? 0 : -1\)/.test(commandPaletteSource), |
| 272 | "command palette highlights the first item when opened with an empty query", |
| 273 | ); |
| 274 | |
| 275 | ok( |
| 276 | /topicShortcutIndexFromEvent\(event, input\.platform\)/.test(topicShortcutOwnerSource) && |
| 277 | /useTopicShortcuts\(input\.enabled, input\.platform\)/.test(topicShortcutOwnerSource), |
| 278 | "topic shortcuts use the resolved desktop platform", |
| 279 | ); |
| 280 | |
| 281 | ok( |
| 282 | /topicShortcutLabel\(shortcutIndex, shortcutPlatform\)/.test(projectTreeSource), |
| 283 | "topic shortcut badges render the platform-specific modifier", |
| 284 | ); |
| 285 | |
| 286 | ok( |
| 287 | /if \(!enabled\) hideBadges\(\);/.test(topicShortcutsSource) && |
| 288 | /if \(heldRef\.current\) hideBadges\(\);/.test(topicShortcutsSource) && |
| 289 | /window\.removeEventListener\("blur", onBlur\);\s*hideBadges\(\);/.test(topicShortcutsSource), |
| 290 | "topic shortcut badge state is cleared when disabled, interrupted, or cleaned up", |
| 291 | ); |
| 292 | |
| 293 | // session-submission-lifecycle.test.tsx verifies source-only undo invalidation |
| 294 | // before send, and zero invalidation for stale/read-only/disposed submissions. |
| 295 | |
| 296 | // session-undo-lifecycle.test.tsx drives the production useSessionUndo owner: |
| 297 | // code-only rewind retains the committed transaction id, full rewinds fill the |
| 298 | // composer only after success, failures leave the banner untouched, and the |
| 299 | // edit prompt honors the undo banner gate. |
| 300 | |
| 301 | |
| 302 | |
| 303 | // pending-plan-revision-lifecycle.test.tsx drives running/idle, tab changes, |
| 304 | // replacement sessions, identical queued text, old finally and disposal. |
| 305 | |
| 306 | |
| 307 | |
| 308 | ok( |
| 309 | /app\.NewSessionForTab\(tabId\)/.test(controllerSource) && |
| 310 | /app\.ClearSessionForTab\(tabId\)/.test(controllerSource) && |
| 311 | /app\.CompactForTab\(tabId\)/.test(controllerSource) && |
| 312 | /import\("\.\/rewindCommit"\)/.test(controllerSource) && |
| 313 | /app\.PreviewRewindForTab\(sourceTabId, turn, scope\)/.test(rewindCommitSource) && |
| 314 | /app\.CommitRewindForTab\(sourceTabId, remoteLegacy \? "" : \(plan\.planId \|\| ""\), turn, scope\)/.test(rewindCommitSource) && |
| 315 | /app\.UndoRewindForTab\(sourceTabId, transactionId\)/.test(rewindCommitSource) && |
| 316 | /bindings\.ForkForTab\(sourceTabId, turn\)[\s\S]*bindings\.ForkWorktreeForTab\(sourceTabId, turn\)/.test(forkWorktreeSource) && |
| 317 | /app\.SummarizeFromForTab\(sourceTabId, turn\)/.test(controllerSource) && |
| 318 | /NewSessionForTab\(tabID: string\)/.test(bridgeSource) && |
| 319 | /CompactForTab\(tabID: string\)/.test(bridgeSource) && |
| 320 | /PreviewRewindForTab\(tabID: string, turn: number, scope: string\)/.test(bridgeSource) && |
| 321 | /CommitRewindForTab\(tabID: string, planID: string, turn: number, scope: string\)/.test(bridgeSource) && |
| 322 | /UndoRewindForTab\(tabID: string, transactionID: string\)/.test(bridgeSource), |
| 323 | "session-changing controller actions use explicit tab-scoped bridge bindings", |
| 324 | ); |
| 325 | |
| 326 | ok( |
| 327 | /plan\.coverage === "partial"/.test(rewindCommitSource) && |
| 328 | /window\.confirm\(t\("rewind\.confirmPartialCoverage"/.test(rewindCommitSource) && |
| 329 | /plan\?\.conflicts\?\.length \? "overwrite_checkpoint" : ""/.test(workspacePanelSource), |
| 330 | "rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation", |
| 331 | ); |
| 332 | |
| 333 | ok(/const transcriptHydrating = input\.hydrating && !input\.hydrateHistoryLoaded;/.test(transcriptSurfaceSource) && |
| 334 | /hydrating=\{transcript\.transcriptHydrating \|\| \(transitioning && !transcript\.navigationDataReady\)\}/.test(chatPaneSource) && |
| 335 | /surfaceCommitToken=\{transcript\.surfaceCommitToken\}/.test(chatPaneSource) && /onSurfacePaintReady=\{commands\.onSurfacePaintReady\}/.test(chatPaneSource), |
| 336 | "Welcome stays suppressed through target data commit and navigation settles only after paint readiness", |
| 337 | ); |
| 338 | |
| 339 | |
| 340 | ok( |
| 341 | /if \(heroMode\) \{[\s\S]*?const maxHeight = composerHeroInputMaxHeight\(\);[\s\S]*?setTextareaAutoHeight/.test(composerSource) && |
| 342 | !/if \(heroMode\) \{\s*setTextareaAutoHeight\(20\);/.test(composerSource), |
| 343 | "Creation hero composer auto-grows multi-line drafts instead of clipping at 20px", |
| 344 | ); |
| 345 | |
| 346 | |
| 347 | |
| 348 | ok( |
| 349 | /return navigation\.enqueueNavigationWithIntent\(\{ kind: "topic", scope, workspaceRoot, topicId, sessionPath \}, navigationIntentSeq\);/.test(sessionNavigationSource) && |
| 350 | /return navigation\.enqueueNavigationWithIntent\(\{ kind: "sidebar-im", connection \}, navigationIntentSeq\);/.test(sessionNavigationSource) && |
| 351 | /navigation\.enqueueNavigationWithIntent\(\{ kind: "resume-session", session \}, navigationIntentSeq\)/.test(sessionNavigationSource), |
| 352 | "topic, IM, and history navigation use the shared intent-fenced formal-session path", |
| 353 | ); |
| 354 | |
| 355 | ok( |
| 356 | /const targetRoot = scope === "project" \? workspaceRoot : ""/.test(sessionNavigationSource) && |
| 357 | /return input\.draft\.open\(scope, targetRoot\);/.test(sessionNavigationSource) && |
| 358 | !/enqueueNavigation\(\{ kind: "blank", scope, workspaceRoot: targetRoot \}\)/.test(sessionNavigationSource) && |
| 359 | /if \(activeTab\?\.remote\)[\s\S]*navigation\.openRemoteProject\(activeTab\.remote, \{ newSession: true \}\)/.test(sessionNavigationSource), |
| 360 | "local blank sessions open durable drafts while remote new-session keeps its runtime path", |
| 361 | ); |
| 362 | |
| 363 | ok( |
| 364 | /projectTree:\s*\{[\s\S]*?activeTab:\s*draftActive \? undefined : activeTab/.test(appViewSource), |
| 365 | "a draft surface clears the previous formal-session highlight in the project tree", |
| 366 | ); |
| 367 | |
| 368 | |
| 369 | // The owner resumes history through topic activation alone; a second |
| 370 | // resumeSession call would re-pin a session the activation already pinned. |
| 371 | const historyResumeBlock = desktopNavigationOwnerSource.match(/const \{ session \} = request;[\s\S]*?ports\.closeHistory\(\);/)?.[0] ?? ""; |
| 372 | ok( |
| 373 | historyResumeBlock.includes("ports.closeHistory()") && !historyResumeBlock.includes("resumeSession"), |
| 374 | "history navigation does not re-resume a session that topic activation already pinned", |
| 375 | ); |
| 376 | |
| 377 | |
| 378 | ok( |
| 379 | finalDeclaration(".sidebar", "--reasonix-draggable") === "drag" && |
| 380 | finalDeclaration(".app--windows .sidebar", "--reasonix-draggable") === "no-drag" && |
| 381 | finalDeclaration(".sidebar-resizer", "--reasonix-draggable") === "no-drag", |
| 382 | "Windows sidebar avoids native window drag without changing other platforms", |
| 383 | ); |
| 384 | |
| 385 | ok( |
| 386 | finalDeclaration(".topicbar", "--reasonix-draggable") === "drag" && |
| 387 | finalDeclaration(".topicbar button", "--reasonix-draggable") === "no-drag" && |
| 388 | finalDeclaration(".topicbar__actions", "--reasonix-draggable") === "no-drag", |
| 389 | "the shell bar is the window drag surface and opts its controls out", |
| 390 | ); |
| 391 | |
| 392 | ok( |
| 393 | finalDeclaration(".msg", "--reasonix-draggable") === undefined && |
| 394 | finalDeclaration(".msg", "-webkit-app-region") === undefined && |
| 395 | finalDeclaration(".reasoning__head", "-webkit-app-region") === undefined && |
| 396 | finalDeclaration(".tool__head", "-webkit-app-region") === undefined && |
| 397 | finalDeclaration(".process-card__head", "-webkit-app-region") === undefined && |
| 398 | finalDeclaration(".compaction", "-webkit-app-region") === undefined, |
| 399 | "transcript content does not participate in native app-region subtraction", |
| 400 | ); |
| 401 | |
| 402 | ok( |
| 403 | finalDeclaration(".chat-pane", "overflow") === "hidden" && |
| 404 | finalDeclaration(".chat-pane", "min-height") === "0", |
| 405 | "the chat pane clips overflow so zoomed transcript boxes cannot paint into the shell bar", |
| 406 | ); |
| 407 | |
| 408 | ok( |
| 409 | finalDeclaration(".app--windows.app--creation .topicbar", "position") === "relative" && |
| 410 | finalDeclaration(".app--windows.app--creation .topicbar", "z-index") === "var(--z-app-chrome)" && |
| 411 | finalDeclaration(".app--windows.app--creation .topicbar", "min-height") === "40px" && |
| 412 | finalDeclaration(":root[data-theme-style] .app--windows.app--creation .topicbar", "min-height") === "40px" && |
| 413 | finalDeclaration(".app--windows.app--creation .topicbar", "transform") === "none !important" && |
| 414 | finalDeclaration(".app--windows.app--creation .topicbar__title-row", "transform") === "none" && |
| 415 | finalDeclaration(".app--windows-frameless.app--creation", "--windows-window-controls-height") === "40px" && |
| 416 | finalDeclaration(".app--creation .topicbar", "min-height") === "56px" && |
| 417 | finalDeclaration(":root[data-theme-style] .app--creation .topicbar", "padding-top") === "14px" && |
| 418 | finalDeclaration(".app--creation .topicbar__title-row", "transform") === "translateY(-3px)", |
| 419 | "Windows Creation stays 40px while macOS and Linux keep the shared Creation geometry", |
| 420 | ); |
| 421 | |
| 422 | // Every style now renders the bar as the layout's own first row, so there is no |
| 423 | // chrome row left to remove and no layout class describing its absence. |
| 424 | ok( |
| 425 | /\.topicbar \{\s*position: relative;\s*z-index: var\(--z-inline-sticky\);\s*grid-row: 1;\s*grid-column: 1 \/ -1;/.test(stylesSource) && |
| 426 | /grid-template-rows: auto minmax\(0, 1fr\) var\(--statusbar-height\)/.test(stylesSource), |
| 427 | "the shell bar spans every column as the layout's first row", |
| 428 | ); |
| 429 | |
| 430 | // The bar covers the sidebar's column too, so the macOS inset moves from a |
| 431 | // sidebar-collapsed special case onto the bar itself. The sidebar is the row |
| 432 | // below the bar now, so the traffic lights can no longer reach it. |
| 433 | ok( |
| 434 | finalDeclaration(".app--darwin .topicbar", "padding-left") === "var(--chrome-left-safe-offset)" && |
| 435 | finalDeclaration(".app--darwin .sidebar--workbench", "padding") === "14px 12px 10px", |
| 436 | "macOS leaves safe space for inset window controls on the shell bar, not the sidebar", |
| 437 | ); |
| 438 | |
| 439 | // The shell bar owns the window's drag region now. While the dock's control |
| 440 | // strip was also draggable, any control that did not opt out individually — |
| 441 | // the leading overview chevron did not — never received a click. |
| 442 | ok( |
| 443 | finalDeclaration(".workbench-dock__tools", "--reasonix-draggable") === "no-drag" && |
| 444 | finalDeclaration(".workbench-dock__tabs", "--reasonix-draggable") === "no-drag" && |
| 445 | finalDeclaration(".workbench-dock__tab", "--reasonix-draggable") === "no-drag" && |
| 446 | finalDeclaration(".workbench-dock__tab-overview", "--reasonix-draggable") !== "drag", |
| 447 | "the dock's control strip is not a window drag region, so every control stays clickable", |
| 448 | ); |
| 449 | |
| 450 | // The dock wraps TabContainer in .workbench-dock__panel. It must stretch that |
| 451 | // container: unstyled, the wrapper is a content-sized block, so the container |
| 452 | // collapses to its content and its own overflow clip then cuts off the tab |
| 453 | // overview popover, which is positioned inside it. |
| 454 | ok( |
| 455 | finalDeclaration(".workbench-dock__panel", "flex") === "1 1 auto" && |
| 456 | finalDeclaration(".workbench-dock__panel", "display") === "flex" && |
| 457 | finalDeclaration(".tab-container", "overflow") === "hidden", |
| 458 | "the dock panel stretches its tab container so in-dock popovers are not clipped", |
| 459 | ); |
| 460 | |
| 461 | ok( |
| 462 | finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "display") === "none", |
| 463 | "active dock tab underline is removed in favor of the rounded-rect selected state", |
| 464 | ); |
| 465 | |
| 466 | ok( |
| 467 | finalDeclaration(".app--workbench .workbench-dock__tab + .workbench-dock__tab::before", "width") === "1px" && |
| 468 | finalDeclaration(".app--workbench .workbench-dock__tab + .workbench-dock__tab::before", "height") === "16px" && |
| 469 | finalDeclaration(".app--workbench .workbench-dock__tab + .workbench-dock__tab::before", "background")?.includes("--border-soft"), |
| 470 | ".app--workbench .workbench-dock__tab + .workbench-dock__tab::before renders a restrained divider between right-dock tabs", |
| 471 | ); |
| 472 | |
| 473 | ok( |
| 474 | finalDeclaration(".app--creation .workbench-dock__tab + .workbench-dock__tab::before", "content") === undefined, |
| 475 | "Creation right-dock tabs keep their equal-column treatment without dividers", |
| 476 | ); |
| 477 | |
| 478 | // The bar, not the dock's tools row, is the window's native title surface now: |
| 479 | // it carries the caption inset at every dock state, and the tools row is a plain |
| 480 | // tab strip that reserves nothing for the window controls. |
| 481 | ok( |
| 482 | finalDeclaration(".app--windows-frameless .topicbar", "padding-right") === "var(--windows-window-controls-safe)" && |
| 483 | finalDeclaration(".app--windows-frameless.app--workbench .workbench-dock__tools", "height") === undefined, |
| 484 | "the Windows caption inset sits on the shell bar, not on the dock's tools row", |
| 485 | ); |
| 486 | |
| 487 | ok( |
| 488 | /@container \(max-width: 420px\) \{[\s\S]*?\.app--workbench \.workbench-dock__tab,[\s\S]*?:root\[data-theme-style\] \.app--workbench \.workbench-dock__tab \{[\s\S]*?padding-left:\s*10px;[\s\S]*?padding-right:\s*10px;[\s\S]*?gap:\s*4px;/.test(stylesSource), |
| 489 | "workbench keeps compact four-tab spacing at narrow dock widths", |
| 490 | ); |
| 491 | |
| 492 | for (const selector of [ |
| 493 | ":root[data-theme-style] .app--workbench .topicbar", |
| 494 | ":root[data-theme-style] .app--workbench .topicbar__chrome-btn", |
| 495 | ":root[data-theme-style] .app--workbench .topicbar__icon-btn", |
| 496 | ":root[data-theme-style] .app--workbench .topicbar__action-btn", |
| 497 | ]) { |
| 498 | ok( |
| 499 | finalDeclaration(selector, "box-shadow") === "none", |
| 500 | `${selector} stays flat after removing the workbench chrome row`, |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | ok( |
| 505 | finalDeclaration(":root[data-theme-style] .app--workbench .topicbar", "background") === "var(--bg-elev)", |
| 506 | "workbench topic bar uses elevated background for light-mode white", |
| 507 | ); |
| 508 | |
| 509 | for (const selector of [ |
| 510 | ":root[data-theme-style] .app--workbench .topicbar__identity", |
| 511 | ":root[data-theme-style] .app--workbench .topicbar__title-row", |
| 512 | ":root[data-theme-style] .app--workbench .topicbar__title-row h1", |
| 513 | ":root[data-theme-style] .app--workbench .tooltip-trigger:has(.topicbar__icon-btn)", |
| 514 | ]) { |
| 515 | ok( |
| 516 | finalDeclaration(selector, "background") === "transparent" && |
| 517 | finalDeclaration(selector, "box-shadow") === "none" && |
| 518 | finalDeclaration(selector, "filter") === "none", |
| 519 | `${selector} cannot paint residual title-row shadows in workbench mode`, |
| 520 | ); |
| 521 | } |
| 522 | |
| 523 | for (const selector of [ |
| 524 | ":root[data-theme-style] .app--workbench .topicbar__icon-btn", |
| 525 | ":root[data-theme-style] .app--workbench .topicbar__chrome-btn", |
| 526 | ":root[data-theme-style] .app--workbench .topicbar__icon-btn:hover", |
| 527 | ":root[data-theme-style] .app--workbench .topicbar__icon-btn:focus-visible", |
| 528 | ":root[data-theme-style] .app--workbench .topicbar__chrome-btn:hover:not(.topicbar__chrome-btn--blocked)", |
| 529 | ":root[data-theme-style] .app--workbench .topicbar__chrome-btn:focus-visible:not(.topicbar__chrome-btn--blocked)", |
| 530 | ]) { |
| 531 | ok( |
| 532 | finalDeclaration(selector, "background") === "transparent", |
| 533 | `${selector} does not paint a hover block in workbench mode`, |
| 534 | ); |
| 535 | } |
| 536 | |
| 537 | ok( |
| 538 | finalDeclaration(".skip-to-composer", "box-shadow") === "none" && |
| 539 | finalDeclaration(".skip-to-composer:focus-visible", "box-shadow")?.includes("0 12px 28px"), |
| 540 | "offscreen skip link does not leak its focus shadow into the workbench title area", |
| 541 | ); |
| 542 | |
| 543 | // The OS drag runtime drops any mousedown with detail !== 1, so a double |
| 544 | // click on a drag region never reaches the OS: both title-bar-hiding platforms |
| 545 | // have to zoom from here or not at all. |
| 546 | ok( |
| 547 | /chromeDoubleClickZooms\s*=\s*input\.windowsFrameless\s*\|\|\s*input\.platform === "darwin"/.test(chromeCommandsSource), |
| 548 | "title-bar double click zooms on macOS as well as frameless Windows", |
| 549 | ); |
| 550 | ok( |
| 551 | /handleChromeTitlebarDoubleClick[\s\S]{0,700}?closest\("button, input, textarea, select, a, \[role='button'\], \[role='tab'\], \.windows-window-controls"\)/.test(chromeCommandsSource), |
| 552 | "title-bar double click still ignores interactive controls", |
| 553 | ); |
| 554 | |
| 555 | console.log(`\n${passed} passed, ${failed} failed`); |
| 556 | if (failed > 0) process.exit(1); |
| 557 |