Studio: fix the Windows-only path failures in the frontend test suite, and catch the shape from Linux by danielhanchen · Pull Request #8980 · unslothai/unsloth · GitHub
Skip to content

Studio: fix the Windows-only path failures in the frontend test suite, and catch the shape from Linux - #8980

Merged
danielhanchen merged 9 commits into
mainfrom
fix-windows-clipboard
Aug 17, 2026
Merged

Studio: fix the Windows-only path failures in the frontend test suite, and catch the shape from Linux#8980
danielhanchen merged 9 commits into
mainfrom
fix-windows-clipboard

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

Windows-only path failures in the frontend test suite. All of them are invisible here, because Frontend CI is ubuntu-only.

Measurement

Frontend CI runs on ubuntu-latest only, so nothing in the org has ever executed this suite on Windows. A staging replica ran it on windows-latest against current main:

tests pass fail
main 3594 3581 13
this branch 3599 3599 0

Ubuntu and macOS are 0 failed on both. The 13 are three files and two shapes.

The failures

tests/copy-to-clipboard.test.ts, 9 tests. It builds its dynamic import specifier with fileURLToPath, which yields a native path. On Linux and macOS that is /..., which Node accepts. On Windows it is D:\..., which it does not:

ERR_UNSUPPORTED_ESM_URL_SCHEME: Only URLs with a scheme in: file, data, node
are supported by the default ESM loader. On Windows, absolute paths must be
valid file:// URLs.

tests/microphone-permission-reset.test.ts, 3 tests. The same mistake, in the three tests that load the module under test. Added by #9006, which is itself a Windows fix: WebView2 stores "Don't allow" and has no site-settings UI, so Allow microphone clears the saved answer before asking again. The test for it could not run on Windows.

tests/desktop-stop-intent.test.ts, 1 test. It walks src/ as file: URLs but collects url.pathname, which is /D:/a/... on Windows. readFile reads that as drive-relative and opens D:\D:\a\...:

ENOENT: no such file or directory, open
  'D:\D:\a\...\studio\frontend\src\app\app.tsx'

The walk then finds no owner for the storage key at all, and the test asserting exactly one owner fails.

It is the same mistake in opposite directions: two convert a URL to a path where a URL was needed, one treats a URL as a path.

Both are test defects, not product defects. Nothing here asserts a browser or platform policy, and no source behaviour changes: the three modules under test are imported and read the same way on every platform once the specifier is legal.

Change

  • copy-to-clipboard.test.ts and microphone-permission-reset.test.ts: keep the file:// URL instead of converting it to a path. new URL(...).href is a legal specifier on every platform, and the ?bust=N cache-busting suffix these tests rely on only means anything on a URL in the first place.
  • desktop-stop-intent.test.ts: keep the walk's entries as URL objects. readFile accepts a file: URL directly on every platform. pathname is still what the assertion slices on, since it is / separated everywhere, and it is now read only at that point.
  • tests/windows-path-portability.test.ts, new: assert both shapes are absent from the whole suite, from any platform.

Why the new test

Fixing three files does not stop a fourth. Both shapes are decidable from the source, so the suite now scans itself for them and fails on Linux, where CI already runs, rather than on a Windows runner nobody has.

It is a rule about data flow, not a ban on the words. fileURLToPath reaching existsSync is correct and stays allowed; only fileURLToPath reaching import() is not. pathname reaching a string slice is correct; pathname reaching readFile is not, including via an array populated in one function and read in another, which is exactly how the desktop-stop-intent failure was written.

The rules were checked against a deliberately broken tree, one file at a time:

reverted to its main version which rule fires
copy-to-clipboard.test.ts no test imports a module by native path
microphone-permission-reset.test.ts no test imports a module by native path
desktop-stop-intent.test.ts no test reads a file through a URL pathname

The rule is a data-flow one, so it follows the value: through initialization, later assignment, destructuring, into and back out of a collection, and across a local helper's parameters. It stops at pathToFileURL, which is the exact inverse of fileURLToPath, so the standard round trip for a module location that starts life as a path is accepted rather than rejected. That barrier is deliberately not applied to the pathname rule, where a Windows pathname is /D:/... and converting it yields a URL for a file that does not exist.

The file also carries its own anti-vacuity checks: it asserts the walk saw more than 200 files, more than 50 dynamic imports and more than 50 fs calls, and currently measures 390, 150 and 341. It runs both detectors over fourteen known-bad snippets and one known-good one, and the sanitizer has an inverse test of its own asserting the unrepaired forms still fire. Stubbing out either detector fails two tests.

Testing

Locally: npm test 3599 passed 0 failed, npm run typecheck clean.

On real runners, since Frontend CI cannot see any of this: the staging table above. Re-run on windows-latest after each review round; the head of this branch is 3598 passed, 0 failed there.

Ubuntu and macOS unchanged throughout.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 16, 2026
@danielhanchen danielhanchen changed the title Studio: fix two module resolution failures in the frontend test suite Studio: fix three path failures in the frontend test suite Aug 16, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

Windows evidence, since Frontend CI is ubuntu-only and cannot see either Windows failure. Replicated the branch onto a staging repo and ran the frontend suite on real runners.

At this head, ce2871e04:

runner result
windows-latest 2843 passed, 0 failed
ubuntu-latest 2843 passed, 0 failed
macos-14 2843 passed, 0 failed

The control reverted only the copy-to-clipboard.test.ts specifier and kept the extension fix, so Ubuntu stayed green and Windows failed for the Windows reason alone:

runner result
windows-latest 2831 passed, 12 failed
ubuntu-latest 0 failed

The 12 were 9 from copy-to-clipboard.test.ts with ERR_UNSUPPORTED_ESM_URL_SCHEME, 1 from desktop-stop-intent.test.ts with ENOENT ... open 'D:\D:\a\...\src\app\app.tsx', and 2 that failed once and passed on the next run, unrelated to this branch. The desktop-stop-intent.test.ts failure is the one that only showed up once the clipboard fix stopped masking the rest of the run, and it is fixed here too.

The staging PRs are closed, never merged.

@danielhanchen danielhanchen changed the title Studio: fix three path failures in the frontend test suite Studio: fix two Windows-only path failures in the frontend test suite Aug 16, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

Still current at 22dd525ec. The head moved only because main was merged in; this PR's own diff is unchanged and is still the same two files, tests/copy-to-clipboard.test.ts and tests/desktop-stop-intent.test.ts, so the runner evidence above stands as measured.

danielhanchen added a commit that referenced this pull request Aug 17, 2026
…9026)

* Repair the prompt-queue contract test against the queueing refactor

Repo tests (CPU) is red on main. test_composer_only_queues_behind_the
_current_chat greps thread.tsx for code that #8952 moved or replaced, so
it fails on any branch regardless of what that branch changes. It is
currently red on #8935, #8964, #8980 and #8983 for this reason alone.

The behaviour it guards is intact, and in two of the three cases the
code that replaced it is stronger than what the test still asserted, so
the assertions are repointed rather than dropped:

- Queueing moved out of handleSubmit into an extracted queueComposerText,
  so the Cmd/Ctrl+Enter path could share it. Assert the delegation in
  handleSubmit and the queueing inside queueComposerText, which keeps
  this a contract on behaviour rather than on where the code sits.

- promptQueueStartPendingRef.current.has(reservationKey) became a
  .get(reservationKey) === identity comparison. A reservation can be
  replaced between the start and the callback, and acting on the
  successor would dispatch the wrong prompt; presence alone never
  caught that.

- temporary: useChatRuntimeStore.getState().incognito became
  temporary: incognitoAtQueueStart, captured when the queue starts
  instead of read live at dispatch. A chat toggled out of temporary
  mid-queue must not have its queued prompts persisted.

Each rewritten assertion was checked against a deliberately broken tree
rather than assumed to discriminate. Removing the delegation, swapping
the identity check back to presence, reading temporary live again, and
stopping queueComposerText from queueing each fail the file.

tests/studio 11 passed for this file, whole directory green.

* Read the identity check out of the dispatch guard, not the whole file

Reported on this PR and correct. The assertion searched thread.tsx for
promptQueueStartPendingRef.current.get(reservationKey) === anywhere, and the
abort and cleanup branches beside the dispatch carry the same comparison. The
dispatch guard could regress to .has(reservationKey) on its own, which is exactly
the bug this assertion exists to catch, and the test would still pass on its
neighbours.

It now slices the if condition that guards the startPromptQueue call and asserts
the identity comparison there, with .has excluded from that guard. All three
comparisons are still required by count, because the other two are load-bearing
too: abort without it reports the successor's start as this one's failure, and
cleanup without it deletes the successor's entry.

11 passed. Rewriting the dispatch guard to .has fails it; it passed before.

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026
microphone-permission-reset.test.ts builds its dynamic import specifier with
fileURLToPath, the same mistake copy-to-clipboard.test.ts made. On Windows that
is a "D:\\..." path, which node's ESM loader rejects, and the three tests that
load the module fail.

The suite now also asserts both shapes are absent, from any platform, so
Frontend CI on ubuntu can see a Windows-only path defect being reintroduced.
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026
@danielhanchen danielhanchen changed the title Studio: fix two Windows-only path failures in the frontend test suite Studio: fix the Windows-only path failures in the frontend test suite, and catch the shape from Linux Aug 17, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0fae7b1b51

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +159 to +162
const tainted = new Set<string>();
const isTainted = (expr: ts.Node): boolean =>
subtreeHas(expr, seed) ||
identifiersIn(expr).some((name) => tainted.has(name));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track taint by binding rather than identifier text

When one file uses const path = location.pathname in one scope and later uses an unrelated const path = new URL(...) with readFile(path) in another, this file-wide Set<string> marks both bindings as tainted and rejects the portable URL read. Because common names such as path, file, and files are likely to be reused across tests, track declaration bindings or lexical scopes rather than identifier text to avoid blocking valid changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the collision is realistic here: path, file and files are all reused across this suite. Fixed in 5232e71 by resolving identifiers lexically. There is now a per-scope declaration table (variable declarations, parameters, function declarations, import bindings), a name is resolved by walking out through enclosing scopes, and the taint set holds declaration nodes rather than text. An unresolved name is never tainted, which is the safe direction.

The file's own detector cases cover it: they include a const path = url.pathname used only for display in one function and a portable const path = new URL(...) passed to readFile in another. Swapping the resolver back to the name-keyed lookup fails that case with "the pathname rule fired on a correct file".

Comment on lines +253 to +255
const target = n.arguments[0];
if (target && reaches(target, urlPathnames, isPathnameRead)) {
result.pathnameToFs.push(at(n));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scan every filesystem path argument

For multi-path APIs already included in FS_PATH_APIS, such as copyFile and cp, only inspecting arguments[0] misses an equally non-portable destination: copyFile(sourceUrl, destinationUrl.pathname) still fails on Windows while this guard remains green. Inspect all path-bearing argument positions for these APIs so the new portability check enforces its stated guarantee.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in 5232e71. FS_PATH_APIS is now a map from entry point to how many of its leading arguments are a path, so copyFile, cp, rename, link and symlink are scanned in both positions and the rest in one.

Covered by a new detector case, copyFile(sourceUrl, destination.pathname), where only the destination is wrong. Reverting the scan to arguments[0] fails it.

Review: a file-wide set of names let 'const path = url.pathname' in one scope
reject an unrelated 'const path = new URL(...)' read in another, and scanning
only arguments[0] missed copyFile(source, destination.pathname).

Resolve identifiers through their enclosing scopes instead, and map each fs
entry point to how many of its leading arguments are a path. Both are covered
by the file's own detector cases: reverting either fails them.
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5232e7187e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +246 to +250
const assigned = (n: ts.Node): ts.Node | null =>
ts.isVariableDeclaration(n) &&
ts.isIdentifier(n.name) &&
n.initializer !== undefined &&
isTainted(n.initializer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track taint through assignment expressions

When a test builds a value in multiple steps, such as let path = ""; path = url.pathname; await readFile(path), this analysis stays green because assigned only examines initializers on VariableDeclaration nodes and ignores subsequent assignment expressions. Track writes back to the resolved binding as well, or this common refactoring can reintroduce the Windows-only failure without triggering the guard.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it is the likelier refactor of the two. Fixed in cab5194: taint now propagates through assignment expressions as well as initializers, resolved to the same binding.

Detector case added: let where = ""; where = new URL(...).pathname; await readFile(where, "utf8"). Dropping the assignment step fails it.

Comment on lines +216 to +220
function calleeName(call: ts.CallExpression): string | null {
if (ts.isIdentifier(call.expression)) return call.expression.text;
if (ts.isPropertyAccessExpression(call.expression))
return call.expression.name.text;
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve aliased filesystem imports

When an fs function is imported under an alias, for example import { readFile as read } from "node:fs/promises", calleeName returns read, so read(new URL("./x", import.meta.url).pathname) is not recognized as an fs call and the portability test passes. Resolve call sites to their import bindings rather than matching only the local callee spelling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Fixed in cab5194: the callee is resolved through the declaration table, and when it lands on an import specifier the name matched is the one on the module's side of the as. fs.readFile through a namespace import was already handled by the property access branch.

Detector case added: import { readFile as read } followed by read(url.pathname). Removing the import-specifier branch fails it.

// arguments are one. Every one of them breaks the same way when handed a Windows
// `file:` pathname, and a destination breaks exactly as a source does, so
// copyFile(sourceUrl, destination.pathname) has to be caught as well.
const FS_PATH_APIS = new Map([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover the remaining path-taking filesystem APIs

When a test uses an omitted fs API such as createReadStream(new URL("./fixture", import.meta.url).pathname), the same Windows failure occurs but this map does not classify the call, so the new guard remains green. Include the other path-taking entry points—particularly createReadStream and createWriteStream, as well as APIs such as readlink, truncate, and watch—so the assertion actually covers filesystem reads and writes rather than only this subset.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in cab5194. The map now covers createReadStream, createWriteStream, readlink, truncate, watch, watchFile, unwatchFile, rmdir, mkdtemp, openAsBlob, glob, chmod, chown and utimes alongside the read/write core, each with its argument count so the two-path ones stay scanned in both positions.

Detector case added for createReadStream(url.pathname). Removing that one entry fails it.

Review: a value built in steps (let path = ""; path = url.pathname) was
invisible, an fs entry point imported under an alias was not recognised as one,
and the entry point list stopped at the read/write core.

Track assignment as well as initialization, resolve a callee through its import
binding so the module's own name is what is matched, and extend the list to the
stream, link, glob and metadata entry points. Each has its own detector case;
reverting any one of the three fails them.
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cab5194ee0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +156 to +160
const isFileURLToPathCall = (n: ts.Node): boolean =>
ts.isCallExpression(n) &&
((ts.isIdentifier(n.expression) && n.expression.text === "fileURLToPath") ||
(ts.isPropertyAccessExpression(n.expression) &&
n.expression.name.text === "fileURLToPath"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve aliases before seeding native-path taint

When fileURLToPath is imported under an alias, such as import { fileURLToPath as toPath } from "node:url", a call to toPath(...) does not satisfy this spelling-based predicate. Consequently, const modulePath = toPath(...); import(modulePath) passes the new portability test even though it still produces the Windows-only ERR_UNSUPPORTED_ESM_URL_SCHEME; resolve the callee through its import binding as is already done for aliased filesystem calls.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and inconsistent of me to have alias-resolved the fs side and not this one. Fixed in a17c165: the seed now resolves the callee to its import binding and requires the module to be node:url, the same shape as the fs rule.

Detector case added: import { fileURLToPath as toPath } followed by import(toPath(...)). Reverting the seed to the spelling check fails it.

Comment on lines +248 to +249
if (ts.isPropertyAccessExpression(call.expression)) {
return call.expression.name.text; // fs.readFile(...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit path API matches to node:fs bindings

When a test calls an unrelated method whose name happens to appear in FS_PATH_APIS, this branch classifies it as a filesystem call without checking the receiver. For example, router.open(new URL("https://example.test/x").pathname) is reported as a Windows filesystem violation even though it performs no filesystem access, so future tests using common method names such as open, watch, or link can be blocked incorrectly; verify that the member is reached through a node:fs or node:fs/promises binding before applying the rule.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this is the direction that matters most: a rule that fires on correct code is worse than no rule. Fixed in a17c165. A call is classified as filesystem only when the callee resolves to a binding imported from node:fs, node:fs/promises, fs or fs/promises, for the named, default and namespace forms alike. An unresolved or unrelated receiver is never classified.

Two detector cases: fs.readFileSync(url.pathname) through import * as fs must fire, and router.open(url.pathname) plus router.link(url.pathname) on a plain object must not. Dropping the module check fails the second; dropping namespace-import resolution fails the first.

The scan still sees 341 filesystem calls and 150 dynamic imports across 390 files after the restriction, which the file's own anti-vacuity assertions hold it to.

Review: an aliased fileURLToPath did not seed the native-path taint, and an
unrelated method sharing a name with an fs entry point (router.open, dom.link)
was classified as one.

A call is now resolved to its import binding and to the module that binding came
from, so node:url and node:fs decide both rules and a local spelling decides
neither. Namespace imports are resolved too. Three more detector cases cover it:
aliased fileURLToPath, fs.readFileSync through a namespace import, and a
router.open on a URL pathname that must stay quiet.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a17c16548b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +369 to +370
const binding =
assigned(n) ?? reassigned(n) ?? collected(n) ?? iterated(n);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate taint through local helper parameters

When a pathname crosses a local helper boundary, such as function load(path) { return readFile(path); } load(new URL("./x", import.meta.url).pathname), none of these four propagation cases taints the parameter, so the filesystem call is reported as clean even though it still fails on Windows. Map tainted call arguments to the resolved function parameters, including the analogous helper pattern around dynamic import().

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in c7d3d12: taint now flows from a call argument to the parameter it lands on, for a locally declared function or an arrow bound to a const. The fixpoint loop already existed, so it composes with the other steps; the propagation functions return every binding they find rather than one, since a single call can taint two parameters.

It is an over-approximation on purpose, and the comment says so: a helper called with a tainted argument at one site and a clean one at another taints every use inside it. That is the right direction here, because the tainted call site is itself the defect.

Two cases added, one per rule: function load(target) { return readFile(target, "utf8"); } called with a pathname, and const load = (specifier) => import(specifier) called with a fileURLToPath result. Removing the step fails both.

Comment on lines +117 to +118
const record = (name: ts.Node, declaration: ts.Node): void => {
if (!ts.isIdentifier(name)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record bindings introduced by destructuring

When a test uses the idiomatic const { pathname } = new URL("./x", import.meta.url); await readFile(pathname), the variable declaration reaches record, but its ObjectBindingPattern is discarded here and assigned likewise accepts only identifier names. The later identifier therefore cannot resolve to a tainted binding, allowing the same Windows-only /D:/... read to pass this guard.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on both halves, and they needed separate fixes. In c7d3d12 the declaration table now walks binding patterns and records each element as its own declaration, so const { pathname, href } = url taints pathname and leaves href clean rather than tainting or missing the pair. And an object binding element whose property is pathname now seeds the taint directly, since destructuring reads the property without producing a member access for the old seed to match.

Binding elements also inherit a tainted right-hand side, so const [first] = taintedList propagates.

Three cases: const { pathname } = url, the renamed const { pathname: where } = url, and const { href, origin } = url feeding import(), which must stay quiet. Reverting either half fails them.

Comment on lines +312 to +317
const isTainted = (expr: ts.Node): boolean =>
subtreeHas(expr, seed) ||
identifiersIn(expr).some((use) => {
const declaration = resolve(use, table);
return declaration !== null && tainted.has(declaration);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop native-path taint after pathToFileURL

When code correctly sanitizes a native path before importing it—for example, const native = fileURLToPath(url); const specifier = pathToFileURL(native).href; await import(specifier)—this expression still taints specifier merely because it reads native. The guard then rejects a valid cross-platform file URL, blocking the standard conversion needed when the module location originates as a filesystem path; model pathToFileURL(...).href as a sanitizer rather than propagating taint through it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this is the direction that actually gets a rule deleted. Fixed in c7d3d12. The traversal now takes a barrier that stops the descent at a node and everything under it, and pathToFileURL from node:url is one for the native-path rule, so the round trip is accepted however the value is built.

One correction to the suggestion though. It is a sanitizer for that rule only, and I have deliberately not applied it to the pathname rule: on Windows a URL pathname is /D:/..., which is not a native path, so pathToFileURL(url.pathname) yields a URL for a file that does not exist. Treating it as a barrier there would silence a real defect.

A sanitizer is the one change that can quietly turn a rule off, since silencing it and repairing it look identical from outside, so it has its own test asserting all three halves: the repaired pathToFileURL(native).href stops firing; a native path still reaching import() in the same file, with pathToFileURL imported and used elsewhere in it, still fires; and readFile(pathToFileURL(pathname)) still fires. Removing the barrier fails the first, over-applying it to the pathname rule fails the third.

Re-checked afterwards that the fix has not blunted anything: reverting each of copy-to-clipboard.test.ts, desktop-stop-intent.test.ts and microphone-permission-reset.test.ts to its main version still fires the right rule. The scan still parses 390 files, 150 dynamic imports and 341 filesystem calls, unchanged by the narrowing.

@danielhanchen

Copy link
Copy Markdown
Member Author

Windows before and after, paired, on the same job

Frontend CI runs on ubuntu only, so neither of these failures is visible in this repo's own checks. Both arms below ran the frontend unit suite on windows-latest through staging, with the control arm being unmodified main.

arm commit tests pass fail skipped
main, unmodified b36eeaf93 3594 3581 13 0
main + this PR + #9099 8980 head 3598 3598 0 0

The after arm was run twice, on two independent staging repositories, and both report 3598 of 3598 with nothing skipped. The test count rises from 3594 to 3598 because the two PRs add four tests.

The 13 that fail on main

Tauri build copies natively and never touches navigator.clipboard
Tauri build does not resolve the native writer before the first await
a browser tab calls no command
a failed native write still falls through to the web writers
a runtime without the permission API does not break the request
an install without the clipboard plugin falls through to the web writers
copyToClipboard reports failure when every writer fails
empty and non-string input returns false and touches no writer
execCommand fallback runs when navigator.clipboard.writeText rejects
the desktop app clears the saved answer before asking again
the marker key belongs to nothing else in the app
web build reaches execCommand in the same tick when clipboard is absent
web build writes through navigator.clipboard before yielding

Clipboard and Tauri writer selection dominate, with one marker-key case and two permission cases. None of them is in the streaming or rendering path, and the same set fails on unrelated branches, so they are pre-existing on main rather than introduced by any current work.

What this leaves

The failures themselves are one half. The other half is that nothing in CI would have reported them, which is what #9099 addresses by running the frontend unit tests on Windows. Landing this PR without that one fixes the tests and leaves the blind spot, so the pair belongs together.

Numbers here are measurement. The claim that the same 13 fail on unrelated branches is measurement as well, taken from staging runs on two other branches. Nothing in this comment rests on a job that cannot fail: the control arm is a real red, which is what makes the after arm mean anything.

@danielhanchen

Copy link
Copy Markdown
Member Author

Three corrections to the grouping in the comment above. The counts and the two arms are right; the prose splits them wrongly.

It is three permission cases, not two. The prose says "one marker-key case and two permission cases", which only accounts for 12 of the 13. The list itself is right: a browser tab calls no command, a runtime without the permission API does not break the request and the desktop app clears the saved answer before asking again are all three from the same file. The split is 9 clipboard, 1 marker key, 3 permission.

They are microphone permission, not notification permission. All three come from tests/microphone-permission-reset.test.ts. Nothing in this suite touches Notification.permission, and no test here asserts a browser permission policy at all.

"Clipboard and Tauri writer selection dominate" reads as three unrelated problems. There are two, and neither is about the behaviour named. Twelve of the 13 are one mechanism:

ERR_UNSUPPORTED_ESM_URL_SCHEME: Only URLs with a scheme in: file, data, node
are supported by the default ESM loader. On Windows, absolute paths must be
valid file:// URLs. Received protocol 'd:'

A native path from fileURLToPath used as a dynamic import() specifier. copy-to-clipboard.test.ts builds its specifier that way at module scope, so the whole file dies and all 9 of its tests go with it; microphone-permission-reset.test.ts builds it inside its load() helper, so only the 3 tests that call load() fail and its other 6 pass. Same defect, different blast radius, which is the only reason the counts differ. Nothing is wrong with clipboard fallback ordering, native writer selection or the permission reset on Windows: those code paths are never reached, because the module is never loaded.

The 13th, the marker key belongs to nothing else in the app, is the other mechanism: a file: URL's pathname (/D:/a/...) passed to readFile, which reads it as drive-relative and opens D:\D:\a\....

So the accurate summary is two shapes of path and URL confusion across three files, not three feature areas.

One detail on the test count. 3594 to 3598 is four tests, and all four are from this PR, the windows-path-portability.test.ts file. #9099 is net zero: it rewrites a healthy read resets the stall window in place rather than adding a case. Measured locally, main plus #9099 alone is 3594.

@danielhanchen

Copy link
Copy Markdown
Member Author

Two corrections to the comment above

The 13 are two mechanisms, not three groups, and the third set is microphone permission rather than notification permission. Nothing in this suite references Notification.permission, and nothing asserts a browser permission policy. My split of "one marker-key case and two permission cases" was also miscounted: it is three microphone cases, not two.

The accurate breakdown:

mechanism tests error
a native path used as a dynamic import() specifier 12 ERR_UNSUPPORTED_ESM_URL_SCHEME: On Windows, absolute paths must be valid file:// URLs. Received protocol 'd:'
a file: URL's pathname passed to readFile 1 ENOENT: open 'D:\D:\a\...\app.tsx'

The 12 split as 9 in copy-to-clipboard.test.ts, where the specifier is built at module scope so the whole file dies, and 3 in microphone-permission-reset.test.ts, where it is built inside a load() helper so only the three tests that call it fail while the file's other six pass.

That distinction decides the test-versus-source question. The error is raised by node:internal/modules/esm/load before the module is evaluated, so neither copy-to-clipboard.ts nor microphone-permission.ts is ever reached. The behaviour is untested on Windows, not broken on it. fileURLToPath yields /home/... on POSIX, which Node's ESM loader tolerates, and D:\... on Windows, which it rejects.

There is also a 14th failure, intermittent, which was not in the original count. background-load-notice.test.ts, "a healthy read resets the stall window", failed in one of two Windows runs of the same commit. It is not a path problem: the test drove the loop at pollMs: 1, stallMs: 30, and Windows' default timer resolution of about 15.6 ms means two polls consume the whole window before the healthy read arrives. In production the same loop runs at pollMs: 2000, stallMs: 3600000, five orders of magnitude clear of that, so the product is fine and the test was not.

Worth recording that the old version of that test measured nothing on either platform: with lastHealthy = Date.now() deleted from settleWhenLoadEnds, so the stall window is never reset at all, the original assertion still passed on Linux 5 times out of 5. It has been replaced with one that fails 5 of 5 against that same mutation.

…ToFileURL

Review, three items on the portability rules.

A pathname crossing a local helper boundary was invisible: the argument is
tainted, the parameter it lands on was not. Taint now flows from a call argument
to the parameter of a locally declared function or arrow, which also covers the
same shape around dynamic import().

Destructuring introduced bindings the declaration table discarded, so
'const { pathname } = url' followed by readFile(pathname) passed. Binding
elements are now recorded individually, and an object binding element naming
pathname seeds the taint the way a member access does, so only that element is
tainted and its siblings stay clean.

The opposite risk, a false positive that would get the rule weakened: taint now
stops at pathToFileURL, which is the exact inverse of fileURLToPath, so the
standard round trip for a module location that starts life as a path is accepted.
Deliberately not a barrier for the pathname rule, where a Windows pathname is
'/D:/...' and converting it produces a URL for a file that does not exist.

Each has its own case, and the sanitizer has an inverse one asserting the
unrepaired forms still fire.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen
danielhanchen merged commit 677d93a into main Aug 17, 2026
36 of 37 checks passed
@danielhanchen
danielhanchen deleted the fix-windows-clipboard branch August 17, 2026 14:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7d3d1261f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +252 to +253
const isPathnameRead = (n: ts.Node): boolean =>
(ts.isPropertyAccessExpression(n) && n.name.text === "pathname") ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict pathname taint to URL-derived values

This predicate treats every property named pathname as a file-URL pathname without inspecting its receiver. Consequently, valid code such as storing a native path in { pathname: fileURLToPath(url) } and passing entry.pathname to readFile is rejected even though it is portable; track whether the receiver is derived from a URL rather than seeding taint solely from the property name.

Useful? React with 👍 / 👎.

Comment on lines +470 to +473
const passed = (n: ts.Node): ts.Node[] => {
if (!ts.isCallExpression(n) || !ts.isIdentifier(n.expression)) return [];
const parameters = parametersOf(resolve(n.expression, table));
if (!parameters) return [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate taint out of local helper returns

The helper propagation only maps tainted call arguments into parameters and never carries a tainted return expression back to the call result. Thus common wrappers such as function modulePath() { return fileURLToPath(url); } await import(modulePath()) or a helper returning url.pathname to readFile pass this guard despite retaining the Windows-only failure; resolve local calls' return expressions and taint the receiving expression or binding.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 677d93ae31

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +496 to +498
if (!tainted.has(binding)) {
tainted.add(binding);
changed = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear taint after an in-place sanitized overwrite

When a native-path variable is repaired in place, such as let specifier = fileURLToPath(url); specifier = pathToFileURL(specifier).href; await import(specifier), the first write permanently adds its declaration to this monotonic set and the clean second write can never remove it. The current sanitizer handling therefore still rejects this valid cross-platform conversion unless callers introduce a new variable; account for sanitized overwrites rather than retaining taint from every historical value.

Useful? React with 👍 / 👎.

Comment on lines +213 to +217
if (ts.isPropertyAccessExpression(call.expression)) {
const receiver = call.expression.expression;
const declaration = ts.isIdentifier(receiver)
? resolve(receiver, table)
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve filesystem calls through the promises facade

When a test uses the standard import fs from "node:fs"; fs.promises.readFile(url.pathname) form, the receiver here is the nested fs.promises property access rather than an identifier, so declaration is null and fsPathArguments skips the call. The bad /D:/... pathname still fails on Windows, so resolve the root imported binding through the promises facade as well.

Useful? React with 👍 / 👎.

Comment on lines +323 to +327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include statfs in the filesystem path API map

Even after the expanded API list, the current map still omits Node's statfs and statfsSync path-taking entry points. A test using statfs(new URL("./fixture", import.meta.url).pathname) is consequently ignored and can retain the same Windows-only pathname failure; add both synchronous and promise/callback spellings to the map.

Useful? React with 👍 / 👎.

danielhanchen added a commit that referenced this pull request Aug 18, 2026
…sts (#8979)

* Studio: stop a settings module reading a chat store key before it exists

* Fix two module resolution failures in the frontend test suite

* Fix the third Windows-only path failure in the frontend test suite

* Drop the #8980 content this branch no longer needs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Trim the comments this PR added

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
danielhanchen added a commit that referenced this pull request Aug 18, 2026
… the build gates (#8983)

* Fix two module resolution failures in the frontend test suite

* Studio CI: make a failing browser smoke say why, and stop it skipping the build gates

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix the third Windows-only path failure in the frontend test suite

* Drop the #8980 content this branch no longer needs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Snapshot the vite tail before printing it, and put the startup bundle gate ahead of the smokes

The failure dump iterated the live deque the drain thread is still appending to,
while writing each line to stdout, so a vite server that was still talking during
the dump raised deque mutated during iteration and dropped the tail in exactly the
noisy failure the dump was added for. list() of a deque is atomic; take it first.

Startup bundle budget still ran after the browser smokes, and every step carries an
implicit success(), so a red smoke skipped it. It only needs dist/, so it moves up
with the other build gates.

* Tighten comments in the smoke diagnostics changes

* Upload the failing smoke's own report, not every report but that one

The failure upload globbed logs/playwright-*, which four of the five browser
smokes write. playwright_settings_tabs.py writes logs/settings_tabs_report.json
and, for the blocked-chunk arm, logs/settings_tabs_blocked_report.json. So when
either settings smoke failed the artifact contained the reports of the smokes
that had passed and not the one that had just failed, which is the opposite of
what this upload is for.

Add both names to the upload path, and guard it: the new test reads every
logs/ path the wired-up smokes actually write out of their own source and
fails if one is not matched by an upload pattern. Shown red on the bare glob
first, naming logs/settings_tabs_report.json.

* Upload the non-blocking smoke's report on the runs where it is the point

The stream-pacing smoke is continue-on-error, which rewrites its CONCLUSION
to success while leaving its OUTCOME as failure. The artifact upload was
gated on a bare failure(), so on the runs where that smoke was the only thing
that failed -- exactly the runs where its report is the whole point -- the
upload was skipped and logs/playwright-stream-pacing went nowhere.

Give the step an id and OR its raw outcome into the upload condition.

Guarded: the new test walks every continue-on-error browser smoke and fails
if it has no id, or if its outcome is not named in the upload condition.
Shown red two independent ways first, reverting the condition to bare
failure() and separately deleting the step id, each naming the step.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
danielhanchen added a commit that referenced this pull request Aug 18, 2026
* Fix two module resolution failures in the frontend test suite

* Fix the third Windows-only path failure in the frontend test suite

* Measure how the chat thread's interaction cost grows with message count

Studio's chat UI is reported as sluggish on Windows 11 and worsening as the
thread fills, while token generation is unaffected. That shape says the cost is
per-message renderer work, so the thing to establish first is the curve.

New smoke page mounts the real Thread against a synthetic local runtime, seeded
to N messages, each carrying prose plus one code fence plus one KaTeX block so
Streamdown, Shiki and KaTeX all pay their per-message price. No backend, no
auth, no router.

New harness runs four scripted actions at N in {10, 50, 200, 500} under 6x CDP
CPU throttling: one keystroke into the composer, one scroll gesture, one message
action menu opened and closed, and one message delete. Each is bracketed by
Performance.getMetrics, which separates the two families of cost: work that
grows because layout is uncontained lands in LayoutDuration, work that grows
because a listener or an export is O(messages) lands in TaskDuration alone.

It measures, it does not gate. There are no timing budgets: it prints the table
and exits 0 unless the harness itself broke. Budgets belong in a later change,
set from numbers taken on real hardware. What it does fail on is measuring
nothing, since that is the failure mode that reads as good news: a seed that did
not render, a menu that never opened, a delete that deleted nothing, a scroll
that did not move, or four columns that do not rise with N.

Measured on this tree, N=10 to N=500: menu open+close 1309ms to 33199ms,
delete 438ms to 8491ms, keystroke median 86ms to 268ms.

Every metric recorded reaches the printed table, and the harness contract test
now enforces that mechanically by parsing the recorded keys out of the source
and requiring each one in the table.

CDP CPU throttling and longtask are Chromium-only, so Firefox and WebKit runs of
this file are correctness checks and not performance ones.

* Stop the thread-weight harness charging its own cost to the app

A review of the first commit found four ways the measurement could look clean
while reporting something other than the app, and two of them were forging part
of the curve. All were reproduced before being fixed.

The local runtime does have a remote id. It synthesises `__LOCALID_...`, which is
truthy, so the per-message fork-count GET fires after all: seeding 20 messages
issued 10 requests, against a comment claiming nothing reached the network. They
were being answered by a Playwright route handler, so each one paused the
renderer for a round trip to another process, once per assistant message. The
page now answers them itself, before anything mounts, and the harness fails if a
single request escapes during a measured action.

Closing the menu was timed from after the Escape dispatch. Radix dismisses
synchronously inside it, so the layer teardown, focus restore and re-render --
the O(messages) fan-out this issue is about -- were excluded from the number
meant to capture them.

Every timing carries a ~33ms floor, since a double rAF cannot resolve faster than
two vsync intervals, and CPU throttling does not move it. An action that never
happened therefore reported ~33ms, which reads as a plausible measurement rather
than as a failure. The floor is now measured per N, printed, subtracted before
every growth ratio, and a keystroke at or under it fails the run.

The keystroke check read the DOM value back, which is what the harness itself
wrote. It now compares against the runtime's own composer state, so a keystroke
that reaches the textarea but not React is caught.

Also removed from the timed regions: a per-frame document-wide querySelector in
both poll loops, replaced by a MutationObserver flag and an isConnected check; a
counts() call per frame in the seed gate; an animated scrollIntoView still in
flight when the menu window opened; and a console warning per action-bar render,
by giving the page the router its useNavigate calls expect. Long tasks are now
read after a yield, since the observer delivers on a later task and the tail
entry was being dropped.

Corrected curve, N=10 to N=500, floors removed where they apply: menu open+close
1021ms to 33591ms, delete 297ms to 8563ms, keystroke 48ms to 283ms, scroll worst
frame 5ms to 126ms. Layout stays flat and tiny throughout; the growth is in
style recalc and task time.

* Stop mounting the assistant action bar for every message

At rest the full assistant action bar was mounted under every assistant
message. Each one carries around eight tooltips, and every tooltip holds a
useSyncExternalStore subscription to the shared modal-layer store, which
re-walks its ancestors reading style.pointerEvents whenever Radix puts the
body on the modal layer. A 500-message thread therefore mounted 250 bars and
1503 tooltip triggers, and every menu open fanned out across all of them.

autohide unmounts rather than hides (ActionBarRoot returns null on the hidden
status), so passing it removes the nodes and the subscriptions together. The
user bar has always done this.

Not unconditionally "always", though: this bar carries the only Stop reading
control, which is why it already passes hideWhenRunning={!speaking} and why
DeleteMessageButton guards the same case. With "always", moving the pointer
off a message being read aloud would take that control away. At most one
message speaks at a time, so exempting it costs nothing.

Measured with tests/studio/playwright_thread_weight.py at 6x CPU throttle,
before -> after, at 500 messages:

    action bars           250 -> 0
    tooltip triggers     1503 -> 3
    DOM nodes           56332 -> 41082
    delete ms          8595.8 -> 3642.7
    scroll worst frame  159.5 -> 87.3
    menu open+close ms 33624.5 -> 25279.3
    keystroke median ms  316.2 -> 241.0

Note what did not move: menu style recalc, 23725.8 -> 22567.7 ms. A 27% cut
in DOM buys 5% there, so the bar is not what makes that number grow. It is the
document-wide invalidation from Radix writing pointer-events onto the body,
and it is still the dominant cost at large N.

The index.css comment is corrected in passing: it justified forcing
content-visibility: visible on every code block with "thread length is
bounded", which is the assumption this issue disproves. The rule is kept for
the flicker it was really fixing.

* Studio chat: one fork-count subscription per thread, not one per message

The fork badge registered its own CHAT_HISTORY_UPDATED_EVENT listener and issued
its own GET, and it is mounted once per message. A delete on a 200-message thread
therefore fired 200 requests before anything could repaint, and streaming raises
that event once per chunk.

Badges now share one debounced subscription per thread and one request that
returns every fork count of that thread, so the cost is flat in thread length.

* Studio chat: derive research-message ownership once per thread revision

useOwnsResearchMessage exported the whole thread from inside a per-message render
body, so one render pass over N messages exported N times and inspected N*N items.
Streaming re-renders the thread once per chunk, so that pass is hot.

The answer is a property of the thread revision, so derive it once for the message
list every message in the pass already shares. Measured on a synthetic thread: 200
exports and 0.80ms per pass becomes 1 export and 0.014ms; at 1000 messages 16.4ms
becomes 0.02ms.

* Studio chat: stop deep-cloning the thread on every delete and every save

exportedItemToRecord ran JSON.parse(JSON.stringify(...)) over every message's
content and attachments on its way to a PUT that serializes the same records
again, and syncExportedRepositoryToBackend ensured the thread row that
syncStoredChatMessages already ensures, so every save paid for GET /threads/{id}
twice.

The parts are replaced rather than mutated, so a copy of the list is snapshot
enough and the bytes on the wire are identical (asserted in the new test).
Measured on a 200-message thread of ~4KB messages: the record step drops from
1.17ms to 0.015ms, and a delete makes one thread-row read instead of two.

* Studio chat: open the message action menu non-modally

A modal Radix menu writes pointer-events:none on <body>. That is an inherited
property, so every open and close invalidates style for the whole document, and
on a long thread the recalc is the bulk of the cost. Non-modal never writes it.

Also teaches the harness the difference between a cost that was removed and a
page that never mounted one, so the after-tree does not read as broken.

* Pin the message action menu to the non-modal layer

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop the #8980 content this branch no longer needs

* Print the hovered trigger count and keep the modal layer load-bearing in the verdict

The contract test caught both: a metric recorded but not printed, and the verdict
no longer reading body_pointer_events_while_open after the non-modal fix removed the
old check. The layer is now compared ACROSS N instead, since either mode is
legitimate but mixing them means the columns measure different mechanisms.

* Stop the rapid-submit settle wait measuring the action bar instead of the reply

This branch autohides the assistant action bar, and that turned the last
wait of the rapid-submit step into a 7-of-7 failure on Windows CI. The wait
is not what the step proves, and the clause that broke was not measuring
what it claimed.

innerText of a [data-role=assistant] root spans the whole subtree, and the
action bar sits inside it, so 'every reply has non-empty innerText' was
satisfied by button labels regardless of what the model returned.
Instrumented at that point on the CI runners, two runs on this branch's
merge base read:

  content=[0, 0]   innerText=[73, 73]   clause held, BOTH replies empty
  content=[0, 19]  innerText=[73, 89]   clause held, first reply empty

gemma-3-270m-it answers 'Reply with exactly: rapid-first' with an empty
completion in 3 of 8 sampled runs, on the merge base as much as here, and
the clause held every time. So the empty reply is the model, is pre-existing,
and was simply masked. With the bar autohidden the subtree is content only,
and the same empty completion now fails.

Dropped rather than repointed at the content element: an empty completion is
the model's behaviour, so a content assertion would be flakier than what it
replaces. What is left is exactly the settle this wait is for, two bubbles,
nothing streaming, nothing queued. The behaviour the step exists to prove,
that a 100 ms follow-up queues behind a held first turn, is state.queueSeen
above and is untouched.

Test-only. No Studio code changes, so the menu open+close and nodes-at-rest
wins are unaffected. Verified on Windows CI at this branch's head: 4 of 4
Chat UI Tests jobs green with this change, against 7 of 7 red without it.

* Validate the delete measurement at every size, not just the last

* Keep the thread's fork counts across the autohidden badges

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Exempt the thread-weight harness from the CI-coverage check

* Keep the newest reply's action bar in the tab order

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Load the crypto polyfill on the thread-weight smoke page

The page this branch adds was the only one in studio/frontend without
<script src="/crypto-boot.js"></script> in its head, so
crypto-uuid-boot.test.ts fails with "smoke-thread-weight.html must load
/crypto-boot.js".

The rule is not cosmetic: the polyfill has to run before the module entry
or crypto.randomUUID is missing on the older WebViews Desktop embeds, and
a smoke page without it measures a page that differs from production in
the one respect the harness is meant to hold constant.

Reproduced on this branch and verified: the named assertion fails before
the change and the file's four tests pass after it.

* Debounce the fork count refresh instead of throttling it

onHistoryUpdated returned while a timer existed, which is a leading edge
throttle rather than a debounce. Streaming raises the history event once per
chunk, so the timer expired mid stream and the next chunk armed another one,
costing a whole thread fork count fetch every 300ms for as long as the reply
ran. Fork counts cannot change during generation, so all of those were waste.

Clear and reschedule on every event, as the sidebar refresh already does.

The existing burst test fired all 20 events inside one window, where a
throttle and a debounce behave identically, which is why this survived. The
new case spreads the events across the window like a real stream: 20 chunks
cost 10 refetches before this change and none after, with one refresh in the
quiet window that follows.

* Reveal the action bar on focus, not only on hover

Unmounting the bar on every message but the newest took its controls out of
the tab order, and there was no non-pointer way to bring them back, so Copy,
Edit, Refresh, Delete, Read aloud and More were unreachable by keyboard or
screen reader on every older reply. Deferred rendering is fine while the user
can still ask for what is deferred, and tabbing is asking.

Measured on the thread weight smoke page at 20 messages, older reply: the
accessibility tree exposed no bar controls before this change and still none
after focus entered the message; it now gains Copy, Refresh, Delete, Edit
response, Read aloud and More. Three tabs from an older reply used to walk
its two code fence buttons and step to the next message without ever
entering a bar; the same walk now lands on the bar's Copy.

The library has no focus path, so this drives its own isHovering flag from
focus within the message root. Two writers share that flag, so a pointer
leaving while focus is inside re-asserts it from a listener registered in an
effect, which runs after the primitive's own mouseleave in the same dispatch
and so never renders the intermediate false that would unmount the element
holding focus. The clear is a one frame watchdog reading activeElement
rather than a relatedTarget test, because relatedTarget is null both for
browser chrome and for a portal, and no focusout fires at all when the
focused element is removed, which is how the menu closes.

At rest this stays at one mounted bar of ten, and at one again after a focus
round trip, so the weight this branch is here to remove is unchanged: 1950
DOM nodes and 9 tooltip triggers, the same as before.

* Give a plain prose reply a way into the tab order

The focus reveal only fires once focus is inside the message, and a reply
whose body is plain prose contains nothing focusable after autohide unmounts
its action bar. The earlier measurement of two focusable controls per message
was an artefact of a fixture where every reply carried a code fence, and
Streamdown ships one Copy button per fence. Seeded with a prose only reply it
is zero, and Tab walks straight past the message into the next reply, so
Copy, Edit, Refresh, Delete, Read aloud and More stay unreachable.

tabIndex on the message root rather than a visually hidden button: it adds no
DOM node, which matters for a branch that exists to cut per message weight,
and it draws nothing at rest. The app's own focus-visible rule gives it the
same 1px keyboard indicator every other focusable container already has, and
focus-visible means a mouse click still draws nothing.

Measured at rest, unchanged: one action bar of ten, 1950 DOM nodes, 9 tooltip
triggers. Outline is none with neither focus nor hover, and none after a
click. The cost is one extra tab stop per assistant message, which is the
price of the controls being reachable at all.

The fixture gains an opt in plain prose variant so the existing weight
measurements keep the exact thread they had.

* Bound how long a fork change can wait behind an unrelated stream

CHAT_HISTORY_UPDATED_EVENT fires once per streaming chunk, and the fork-count refresh was a pure
trailing-edge debounce, so a reply running in a background thread reset the timer on every chunk.
Deleting a fork from the sidebar while looking at its parent changes the displayed count, and the
refresh was postponed until the unrelated stream went quiet, which on a long or queued run is
minutes. That is starvation, not slowness.

The event is a bare Event with no detail and six other consumers, so telling fork changes apart
from chunks means changing a contract well outside this store. FORK_COUNT_REFRESH_MAX_WAIT_MS
bounds the wait inside it instead: a second timer, started by the first event of a burst and
deliberately not restarted by the ones after it, races the debounce, and whichever fires first
cancels the other. A second timer rather than a Date.now() deadline so it runs off the same clock
as the debounce and is testable without a fake Date.

2000ms because the bound costs one whole-thread fetch per window while a stream runs. At the
300ms debounce that is the per-chunk traffic this store exists to remove; at 2000 it is under a
sixth of it, and only while something is streaming.

The existing continuous-stream test asserted ZERO mid-stream fetches, which is the behaviour the
review flagged, so it now measures the price of the bound instead of claiming there is none: it
pins the count against both what the ceiling allows and what a leading-edge throttle would have
cost, so a regression in either direction is a failure.

Four assertions were made to fail on their own broken tree before being kept: the ceiling
removed, the ceiling restarted per chunk so it never expires, the losing timer left uncancelled
when the other fires, and the ceiling left running past unsubscribe. That last one was vacuous at
first, since the entries map is empty after unsubscribe and a leaked timer refreshes nothing; it
now subscribes a second thread inside the ceiling window, which is both observable and the case
that actually costs a user a request.

* Scope the popup lookup to the action bar

The watchdog treated any expanded descendant as this message's open menu. Reasoning cards and
tool-fallback cards are Radix CollapsibleTriggers and render aria-expanded=true for as long as
the reader leaves them open, which is the resting state of a message whose tool output has been
expanded. decide() therefore found a popup every frame, rescheduled itself every frame, held
focusWithinRef and the synthetic hover set, and left the bar mounted indefinitely, at the cost of
a DOM query per frame per such message. Scoped to .aui-assistant-action-bar-root, which is where
the trigger the hook has to hand focus back to actually lives.

Proving this took three attempts and the first two were wrong, which is worth recording because
the failure was in the test rather than in the fix.

isHovering has two writers. This hook is one; assistant-ui's own MessagePrimitive.Root mouseleave
handler is the other, and it writes false directly. So a phase that reveals the bar by HOVERING
and then moves the pointer away sees the bar unmount on both trees, because the library unmounted
it. On the broken tree the watchdog genuinely spins forever, and the bar still goes away. An
assertion on the mounted bar count cannot attribute that outcome to this branch, and C2 passed on
the fixed and broken trees alike.

The phase now keeps the pointer off the message entirely and reveals the bar by focus, which the
tabIndex on the message root makes possible. With no mouseleave to fire, focus is the only writer
and the bar's fate is decided by the watchdog alone. C2 is green on the fixed tree and red under
--break widepopup, 1 bar still mounted and held indefinitely. Two guards sit in front of it: one
asserts the pointer really is off the message, the other that the bar really was mounted and
focused, so the phase fails loudly rather than passing vacuously if either precondition breaks.

Also fixes a pre-existing crash the sweep was hiding. Under --break eagerclear the bar is gone by
the time A8 runs, and a bare more.focus() on an undefined element threw a Playwright TypeError
that killed the process before phases P, B and C ever ran, so the break reported fewer reds than
it earns and ended in a traceback rather than a red result. It is more?.focus() now and the
trigger's absence is folded into A8's condition, so eagerclear completes and A8 goes red on its
own merits.

Full sweep: head 14/23, notabindex 19/23, restring 21/23, focusring 22/23, leakflag 21/23,
noreassert 22/23, eagerclear 21/23, widepopup 22/23 with C2 the only red. thread.tsx checksummed
before and after all eight runs, identical every time.

* Reveal the action bar from the backward traversal too

The tabIndex on the message root only worked going FORWARD. A container is reached before its own
descendants, so Shift+Tab arriving from the message below landed on the last tabbable thing in the
message, and with the bar unmounted that is the root, which sits BEFORE the bar in DOM order.
Focusing it mounted the controls and the next Shift+Tab then stepped straight past them to the
previous message. Copy, Edit, Delete and More were reachable going forward and unreachable going
backward, which is worse than being unreachable outright, because the forward pass makes it look
solved.

A sentinel span after the bar is what makes the backward pass land inside the message: focus stops
there, the bar mounts, and the next Shift+Tab goes into the last control rather than out. It is
deliberately NOT a focus redirect to that control, which would trap the forward pass in a loop
between the last button and the sentinel. It carries no onFocus of its own because React's onFocus
is focusin and already bubbles to the root, and no role, because it performs no action; the
aria-label is what stops it being an unannounced stop.

It DOES cost one DOM node per assistant message, and this branch is about per-message weight, so
that is asserted rather than absorbed: the at-rest guard now requires exactly 1950 baseline nodes
plus one sentinel per reply and nothing else, measured 1960 for 10 replies, with the sentinel count
checked separately so the extra nodes are attributed rather than tolerated.

Three assertions, each proven red. nosentinel takes D2 and D3 red while D1 stays green, which is
precisely the reported asymmetry: focus still enters the reply, it just skips the bar. D1 says
something weaker, so it needs noentry, which removes the sentinel and the tabIndex together and
leaves nothing in the message reachable at all; D1, D2 and D3 all go red there.

Full sweep, 26 assertions: fixed 26/26, nosentinel 23/26, noentry 18/26, notabindex 24/26, head
15/26, widepopup 25/26 with C2 alone, eagerclear 21/26, noreassert 25/26, leakflag 24/26, restring
24/26, focusring 25/26. thread.tsx checksummed before and after every run, identical throughout.

* Draw a focus indicator on the backward reveal sentinel

The shared soft-outline rule is :where(div, main, section, aside, ul, ol):focus-visible, which
never matched a span, so the sentinel was a real tab stop that drew nothing: Shift+Tab into a
message made focus visibly disappear for one stop before the next press reached the action bar.
That is a focus-visible failure, not a cosmetic one.

The element stays 0x0 and the ring is drawn by outline-offset. Outlines take no part in layout,
so the indicator appears without shifting the message, which giving the span dimensions on focus
would have done. Still nothing at rest, and :focus-visible means a mouse click draws nothing
either.

Measured: with keyboard focus the ring spans 14px against the UA default's 2px on a zero-sized
span, which is the difference between an indicator and no indicator. D4 asserts the SPAN of the
drawn ring rather than merely that an outline style exists, because the broken tree still reports
outline-style auto and would satisfy a presence check while showing nothing.

Proven red by --break blindsentinel, which removes the indicator rule and leaves the tab stop
itself intact, so only D4 fails.

* Stop tracking the generated Studio test database

.studio-test-root/studio.db is written at test time by
tests/studio/install/test_selection_logic.py, which points storage_roots.studio_root
at that path. It is a mutable SQLite runtime database, not a fixture: nothing
reads it, any test run or Studio start rewrites it and dirties the checkout, a
later accidental commit could capture real local chat or settings data, and it
puts 221 KB into every clone while exercising nothing.

It was not in the tree deliberately. It arrived in the merge commit here because
that commit was staged with `git add -A` after running the suite, and no
gitignore rule covered the path. Main does not track it.

Untracked and ignored, and the file is left on disk since creating it is normal.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant