Compare commits

...
138 Commits
Author SHA1 Message Date
YHH cbcc2ebb1e Merge pull request #14810 from esengine/release/studio-2.56.0
docs(release-notes): studio 2.56.0
2026-10-29 17:01:19 -07:00
esengine 674c2b45e7 docs(release-notes): studio 2.56.0 2026-10-29 16:25:53 -07:00
YHH ec6929c157 Merge pull request #14808 from esengine/fx/parity-scheduled
docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap
2026-10-29 14:09:01 -07:00
esengine dcd4135049 docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap 2026-10-29 13:56:57 -07:00
YHH 08ea935ea9 Merge pull request #14784 from lancy69/feat/14319-recent-sessions
feat(studio): add globally paged recent session discovery
2026-10-29 12:58:29 -07:00
YHH 2a3a3738db Merge pull request #14801 from lancy69/fix/12303-yielded-claim-evidence
fix(agent): preserve unknown scope across yielded workspace claims
2026-10-29 12:28:51 -07:00
YHH d1eb9c968e Merge pull request #14800 from lancy69/fix/12080-workspace-startup-read
fix(studio): share pane startup workspace metadata
2026-10-29 12:27:39 -07:00
YHH a2597fb17c Merge pull request #14805 from xxoingr/fix/worktree-commands-one-at-a-time
fix(worktree): run one repository's worktree commands one at a time
2026-10-29 12:26:40 -07:00
YHH 41ea14ccc9 Merge pull request #14806 from esengine/fx/import-orphan-index
fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed
2026-10-29 11:58:11 -07:00
esengine b3bea0d076 fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed 2026-10-29 11:29:54 -07:00
YHH efff0a825f Merge pull request #14803 from xxoingr/fix/earlier-version-delete-shows
fix(studio): an earlier version in the sidebar shows its delete button
2026-10-29 11:25:38 -07:00
xxoingr 44712c3aad fix(worktree): run one repository's worktree commands one at a time
`git worktree add` creates the new entry's directory under
<common>/worktrees before it writes the entry's files, and every worktree
command resolves each entry's HEAD, reading its commondir and dying on one
it finds empty: "failed to read .../commondir: Success". Two isolated
tasks started in parallel each create a candidate from the same
repository, and on a loaded Linux runner the second add read the first's
entry in that window (TestPreparationConcurrentCandidatesDoNotShareResultState,
run 40689344657).

Every `git worktree` command in the package now goes through runWorktree,
which holds one gate per repository, keyed by its common Git directory,
and gives up waiting when its context ends.
2026-10-30 02:09:35 +08:00
YHH d188af4207 Merge pull request #14783 from lancy69/feat/14699-scheduled-appearance
feat(studio): add a daily dark-mode schedule
2026-10-29 10:59:14 -07:00
xxoingr 8a89f0874b fix(studio): an earlier version in the sidebar shows its delete button
An earlier version or recovery copy has no row menu; its × is the only
way a pointer deletes it. `.rail .sessrow > .wsdel { display: none }`
came in with the Studio rail when session rows moved deletion into their
menu, and since then the copy row is the only one that renders .wsdel,
so the rule hid exactly that button. The hover, focus-within and
hover:none rules beside it were already there to show it.

perf/sessionrows.mjs now puts the button on its earlier-version row and
holds it shown and inside the row on hover and on keyboard focus.
2026-10-30 01:47:47 +08:00
Lancy 350379ffff feat(studio): add globally paged recent session discovery 2026-10-30 01:27:28 +08:00
Lancy 65ecbce661 feat(studio): add a daily dark-mode schedule 2026-10-30 01:19:11 +08:00
Lancy 2656f33f28 fix(agent): preserve unknown scope across yielded workspace claims 2026-10-30 01:13:00 +08:00
Lancy e31c2347b4 fix(studio): share pane startup workspace metadata 2026-10-30 01:05:03 +08:00
YHH be65b117e0 Merge pull request #14493 from imcreazy/ci/android-release
ci(android): build the APK on a tag and attach it to the release
2026-10-29 08:52:55 -07:00
imcreazy 19f05e8509 Attach the APK only when it is signed, and wait long enough for the release
An unsigned package cannot be installed at all, so publishing one put an
unusable file on every release page. The wait was also shorter than the
release actually takes.
2026-10-29 23:11:20 +08:00
YHH 4fde96dccc Merge pull request #14781 from lancy69/fix/14759-dim-composer-placeholder
feat(studio): add a default-on composer hint visibility setting
2026-10-29 07:39:33 -07:00
YHH 9c38dd1129 Merge pull request #14771 from 1012905/fx/session-remove-many-kernel
feat(serve): 通过 POST /tree/sessions/remove-many 批量删除会话
2026-10-29 07:20:19 -07:00
Lancy 355e047c27 feat(studio): add a default-on composer hint visibility setting 2026-10-29 22:07:16 +08:00
1012905 0abc13d3c0 chore(release-notes): name the pull request in the batch-remove fragment
The fragment lost its issue reference when the lines were cut to one
sentence each, and the release-note lint asks every change to name the
issue or pull request it belongs to. Both lines now end with the
reference, and both stay under the 200-column limit.
2026-10-29 21:42:55 +08:00
YHH 4055d9e404 Merge pull request #14791 from 1012905/fx/settings-search-storage-race
test(desktop): the settings search is measured without the storage tally
2026-10-29 06:40:23 -07:00
1012905 ddd0db3ee7 feat(serve): batch session deletion via POST /tree/sessions/remove-many
Kernel half of session batch delete. The handler validates the whole batch up front (bad path -> 400, empty -> 400), refuses a running session (409), a leased transcript (409), and a path outside the adopted workspace (403), then erases all guarded sessions.

No desktop UI in this change; the management view lands in a follow-up PR.

Go tests cover the handler, including two new edge cases:
- a path inside the session dir but belonging to another project (refused wholesale, 403, the in-workspace file left untouched);
- a running session in the middle of the list (refused with 409, nothing erased before the refusal).

Cuts the release-note fragment to one sentence per line and drops the
self-reference, so each line stays within the 200-column limit.
2026-10-29 21:02:04 +08:00
1012905 ba95fc74f1 test(desktop): the settings search is measured without the storage tally
The settings pane schedules a tally of the disk usage a beat after it opens,
and that walk reads the port. The search test measures the port as it stood
before the search, and reads it again afterwards to prove the search read
nothing. When the two moments fell more than a beat apart — which the full
suite's load makes likely — the tally landed in between and the test failed
on a read the search never asked for.

Seeding the tally before the pane opens means no walk is scheduled at all, so
the search is measured against the port as it stood, with nothing else reading
it on the way past.

The failing assertion is unchanged: the search still has to read nothing.
2026-10-29 20:40:20 +08:00
YHH 1da925d0f9 Merge pull request #14790 from esengine/fx/shortcut-cards-order
fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page
2026-10-29 05:14:47 -07:00
esengine eaf5329101 fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page 2026-10-29 04:59:32 -07:00
YHH dc659c7509 Merge pull request #14788 from esengine/fx/full-installer-link
fix(studio): the full-installer link after a failed update opens the system browser
2026-10-29 04:48:06 -07:00
esengine 310567fd08 fix(studio): the full-installer link after a failed update opens the system browser 2026-10-29 04:31:49 -07:00
YHH 77059988f8 Merge pull request #14780 from lancy69/docs/14603-cross-project-dispatch
docs: explain cross-project task dispatch
2026-10-29 04:23:39 -07:00
YHH 4b87d87883 Merge pull request #14774 from kevin9327/fix/worktree-stderr-rune-boundary
fix(worktree): cut git stderr in error messages on a rune boundary
2026-10-29 03:54:40 -07:00
Lancy b8997b34eb docs: explain cross-project task dispatch 2026-10-29 18:38:40 +08:00
YHH 3c6ad78273 Merge pull request #14773 from kevin9327/fix/recovery-task-summary-runes
fix(control): bound the fallback recovery task summary by runes
2026-10-29 03:23:41 -07:00
kevin 2cbd60e326 fix(worktree): cut git stderr in error messages on a rune boundary
stderrSuffix capped git's stderr at 500 bytes with a plain slice, so a localized
(multi-byte) git message was split mid-character and the error ended in invalid
UTF-8. Back the cut up to a rune start.
2026-10-29 19:00:58 +09:00
YHH 7b41ec8a08 Merge pull request #14769 from chuiwenwei/test/every-aggregate-field-declares-its-provenance
test(stats): every aggregate field declares its provenance
2026-10-29 02:40:34 -07:00
kevin 9be610d41d fix(control): bound the fallback recovery task summary by runes 2026-10-29 18:31:57 +09:00
YHH d16063929c Merge pull request #14726 from SivanCola/codex/worktree-apply-ancestor-boundary-14025
fix(worktree): guard apply ancestors and confine deletions / 防止父目录链接重定向隔离结果删除
2026-10-29 01:53:20 -07:00
YHH 0ab4329305 Merge pull request #14748 from lancy69/feat/4262-line-code-references
feat(control): support precise @path:start-end file references
2026-10-29 01:51:26 -07:00
YHH 692a46d8d3 Merge pull request #14768 from jf200s/fix/fork-inherit-compaction
fix(control): a fork inherits its source's context projection
2026-10-29 01:50:05 -07:00
YHH 8066440bb7 Merge pull request #14749 from lancy69/fix/14738-stale-mcp-removal
fix(control): remove stale MCP entries without losing source authority
2026-10-29 01:48:35 -07:00
YHH aa09229ab2 Merge pull request #14750 from lancy69/feat/11915-running-message-default
feat(studio): choose steer or follow-up as the running-message default
2026-10-29 01:47:11 -07:00
Lancy 87d59a1816 feat(control): resolve precise line-range file references 2026-10-29 15:03:12 +08:00
Lancy f98edeec7b fix(control): reconcile MCP entries after their source disappears 2026-10-29 14:55:25 +08:00
Lancy cab93a8b02 feat(studio): choose default intent for messages sent mid-run 2026-10-29 14:46:40 +08:00
F 2b517203be docs(release-notes): name the fragment after #14768
Refs #14768
2026-10-29 14:39:31 +08:00
Will 08a1288603 test(stats): every aggregate field declares its provenance 2026-10-29 14:38:30 +08:00
F e5eb7cbee2 docs(release-notes): point the fragment at the rebased commit
Refs 02bf796dc
2026-10-29 14:35:54 +08:00
F d5db1cc446 docs(release-notes): a fork keeps its source's compaction
Refs 4566c19b8
2026-10-29 14:34:49 +08:00
F 02bf796dcc fix(control): a fork inherits its source's context projection
A conversation branched from a compacted session reopened on the full
history. createBranchPrefix copied the transcript, the branch metadata and
the checkpoints but never the projection sidecar, and resumeInto binds with
loadSidecar=true, so the fork found no sidecar and visibleByFullScan handed
the model the whole transcript. Recovery forks and 1.x continuations already
go through InheritCompactionState; the user-facing branch path never called it.

BranchAt now inherits inside the branch's own save window, next to the
checkpoint copy, so a branch is never published with a fold it cannot read.
Controller.Branch, which swaps panes in place, binds with loadSidecar=true so
the same-pane path loads what it just inherited.

The inherit helper keeps its fail-soft contract: no source sidecar, a corrupt
or unsupported one, a projection whose covered prefix reaches past the fork
point, and a failed write all leave the fork on the full history. Recall
budget, blocked fields and a non-applied receipt belong to the source
session's runtime generation, so the copy clears them; the projection,
generation, lineage key and an applied receipt still travel.
2026-10-29 14:34:49 +08:00
YHH 396b2c6b6d Merge pull request #14764 from esengine/fx/browser-open-steer
fix(tools): tell the model browser_open is visible and web_fetch reads static pages
2026-10-28 23:10:45 -07:00
YHH 79a2232e55 Merge pull request #14763 from esengine/fx/remote-link-linger
fix(serve): hold a request's remote link past its answer
2026-10-28 22:59:56 -07:00
YHH af1628b8ea Merge pull request #14747 from lancy69/feat/14466-collapsed-plan-progress
feat(studio): optional progress track for collapsed plans
2026-10-28 22:51:48 -07:00
YHH 73da13ee39 Merge pull request #14746 from lancy69/feat/14724-unread-completion-marker
feat(studio): show green unread markers instead of session dates
2026-10-28 22:47:27 -07:00
YHH 955474f6fc Merge pull request #14677 from SivanCola/codex/carried-proof-file-state-14025
fix(agent): invalidate carried proof after file-mode changes / 文件模式变化时拒绝旧证明
2026-10-28 22:42:41 -07:00
YHH 1ba8ed87cb Merge pull request #14757 from chuiwenwei/feat/bench-batches-emit-an-auditable-manifest
feat(e2ebench): a batch emits an auditable manifest
2026-10-28 22:42:03 -07:00
esengine 181e697147 fix(tools): tell the model browser_open is visible and web_fetch reads static pages 2026-10-28 22:36:18 -07:00
esengine bccf76ab7e fix(serve): hold a request's remote link past its answer 2026-10-28 22:17:27 -07:00
YHH 8a1fda41a7 Merge pull request #14730 from SivanCola/codex/reported-total-token-budget-14025
fix(agent): count reported totals in token budgets / 将已报告总量计入 token 预算
2026-10-28 21:57:59 -07:00
YHH da91464adb Merge pull request #14684 from SivanCola/codex/ancestor-copy-admission-14025
fix(delegation): reserve ancestor copy admission / 预留祖先副本续跑准入
2026-10-28 21:57:19 -07:00
YHH 1619856458 Merge pull request #14761 from esengine/docs/release-notes-2.55.0
docs(release-notes): studio 2.55.0
2026-10-28 21:49:09 -07:00
esengine 51eb1bb24f docs(release-notes): studio 2.55.0 2026-10-28 21:37:37 -07:00
YHH 812e405a8d Merge pull request #14735 from esengine/fx/sandbox-git-bash
fix(sandbox): a Windows session passes over a Git Bash that cannot start under the token
2026-10-28 21:35:53 -07:00
YHH 17fe3c7110 Merge pull request #14734 from kevin9327/fix/gitignore-dotdot-names
fix(gitignore): treat a root name starting with .. as inside the repo
2026-10-28 21:35:15 -07:00
YHH c58b1242c8 Merge pull request #14729 from chuiwenwei/fix/register-the-unguarded-wire-mirrors
fix(repolint): register the wire mirrors that had no guard
2026-10-28 21:34:37 -07:00
YHH 90f2ad2c3b Merge pull request #14727 from chuiwenwei/fix/an-unprovable-lease-is-its-own-state
fix(sessionstore): an unprovable lease is its own answer
2026-10-28 21:33:58 -07:00
YHH 9c973fae65 Merge pull request #14716 from chuiwenwei/fix/restore-keeps-an-image-only-turn
fix(tui): a restored image-only turn is still a turn
2026-10-28 21:33:20 -07:00
Will ce2c3acd44 docs(release-notes): record #14757 2026-10-29 09:39:05 +08:00
Lancy 6202465e3e feat(studio): optionally show progress on collapsed plans 2026-10-28 16:48:34 +00:00
Lancy bb2a71f959 feat(studio): replace unread session dates with green markers 2026-10-28 16:47:14 +00:00
YHH 7dec45ad05 Merge pull request #14711 from lancy69/fix/14539-isolated-test-config-locks
fix(testenv): isolate config locks from the real user home
2026-10-28 05:37:27 -07:00
kevin 9a89dc2d34 fix(gitignore): treat a root name starting with .. as inside the repo 2026-10-28 21:19:32 +09:00
esengine 6ac89de76e fix(sandbox): a Windows session passes over a Git Bash that cannot start under the token 2026-10-28 05:19:02 -07:00
YHH 54c5d01574 Merge pull request #14733 from esengine/fx/primary-action-ink
fix(studio): primary actions in settings keep their paired text colour
2026-10-28 05:16:54 -07:00
YHH 8412374533 Merge pull request #14709 from lancy69/fix/14607-rename-dialog-typography
fix(studio): use semantic typography in session rename dialog
2026-10-28 05:04:12 -07:00
YHH 35e79a8d46 Merge pull request #14708 from lancy69/fix/14600-sticky-project-header
fix(studio): keep project controls outside the scrolling tree
2026-10-28 05:03:24 -07:00
YHH b554637a26 Merge pull request #14707 from lancy69/fix/14601-settings-button-height
fix(studio): align settings header button heights
2026-10-28 05:02:38 -07:00
YHH 3e269c1362 Merge pull request #14732 from esengine/fx/graphics-on-window-page
feat(studio): graphics rendering lives on the window and performance page
2026-10-28 05:01:52 -07:00
YHH 02bf511ba2 Merge pull request #14731 from esengine/fx/menu-over-plan
fix(studio): composer menus open above the plan bar
2026-10-28 05:01:06 -07:00
Will 74ad7e5f39 feat(e2ebench): a batch emits an auditable manifest
A batch printed its aggregates and kept nothing they could be recomputed
from, so a published figure could not be tied to the runs behind it after
the temp directory was gone. -batch DIR now writes results.jsonl (one row
per attempt) beside batch.json (manifest v1), and -mode audit re-derives
the report's headline figures from those rows with the harness's own
aggregation, printing the claim beside the recomputation and naming every
figure that moved.

The manifest records what the figures are over (command axes, a task-id
digest, filters, and a denominator defined in tasks), which run produced
them (commit or an explicit unknown, model/provider names, seed when
known, timestamps, harness and runtime), and why they can still be
checked (results_sha256, the claimed figures, and a ledger of planned /
attempted / completed / scored / failed / unmeasured / excluded that the
audit checks for consistency). Absent figures are omitted and read as
unknown, never as zero.

This is an aggregation audit, not a replay: it proves the published
figures follow from the retained rows. It does not re-execute the model,
tools or environment, and a real replay (commit, corpus fingerprint,
model/provider configuration, seed, flags, raw results, runtime version)
is not implemented.
2026-10-28 19:52:12 +08:00
esengine 25a97bb683 fix(studio): primary actions in settings keep their paired text colour 2026-10-28 04:46:02 -07:00
esengine 972e910722 feat(studio): graphics rendering lives on the window and performance page 2026-10-28 04:31:11 -07:00
esengine a0f197a535 fix(studio): composer menus open above the plan bar 2026-10-28 04:22:51 -07:00
YHH f665a00794 Merge pull request #14700 from esengine/fx/settings-no-page-header
feat(desktop): drop the title block above each settings page
2026-10-28 04:04:57 -07:00
YHH acb47daee9 Merge pull request #14686 from esengine/fx/activity-title-size
fix(studio): the Execution row uses the same text size as the Thinking row
2026-10-28 04:03:58 -07:00
SivanCola fbf0b99ccd fix(agent): count reported totals in local and root token budgets
Problem: Accepted total-only usage remained visible to reporting but did not
cross token budgets, allowing additional tool rounds after the known spend.

Root cause: Both local and shared-root enforcement added only input/output
components, so missing or partial splits hid a known reported total.

Fix: Use one accounting helper for the larger of the nonnegative total and
component sum, and saturate both counters. Keep usage, pricing, shadow component
telemetry, context ownership and existing soft-gate/finalization behavior.

Verification: Real OpenAI HTTP/SSE adapter and Boot execute real file tools.
Final19named regressions pass; exact preceding owners fail10 events with9controls.
Root240packages once:24330named passes/215existing skips. Complete Agent race
2162pass/10existing skips; focused Boot race7pass. Vet, pinned lint, check, format,
workflow and release gates pass; four Win/Linuxamd64 checks compile only.
2026-10-28 18:36:33 +08:00
Will 9b666634e6 docs(release-notes): record #14727 2026-10-28 17:11:54 +08:00
Will 40ab228ab6 docs(release-notes): record #14716 2026-10-28 17:11:54 +08:00
SivanCola 19c7b76de1 fix(worktree): reject redirected apply ancestors and confine deletion
Problem: An isolated candidate deletion could follow a source directory link
and remove a file outside the repository while reporting success.

Root cause: Leaf comparison followed ancestor links and publication deleted
through an ordinary pathname, without an ancestor precondition or held root.

Fix: Check ancestors before path-by-path leaf hashing and before publishing
deletions, and delete through an open repository root. Preserve whole-workspace
file-to-directory conversion and the Store's existing same-ID conflict retry.
Extend the existing Windows isolation inventory with junction regressions.

Verification: Final root inventory covers 240 packages once with 24324 named
passes and 215 existing skips. Both required package commands exit zero.
Complete worktree/isolation race has 242 passes and no skips. The final focused
13 names pass; the exact preceding owner fails 9 events with 4 controls.
Vet, pinned lint, check, format, workflow and release-note gates pass. Windows
and Linux amd64 compile checks pass; native hosted verification is pending.
2026-10-28 17:00:26 +08:00
YHH 70b90453a5 Merge pull request #14656 from SivanCola/codex/subagent-status-admission-14025
fix(delegation): refuse unknown saved run states / 拒绝未知子任务恢复状态
2026-10-27 23:34:23 -07:00
YHH 7205271b07 Merge pull request #14661 from SivanCola/codex/goal-usage-delegation-scope-14025
fix: retain delegated Goal usage ownership / 保留委派任务的 Goal 用量归属
2026-10-27 23:33:26 -07:00
YHH a6ffc30339 Merge pull request #14667 from lancy69/feat/14649-adjustable-viewport
feat(studio): add adjustable conversation viewport width
2026-10-27 23:32:55 -07:00
YHH f3e3ac9f2e Merge pull request #14654 from esengine/fx/openwith-explains-inside-project
feat(openwith): say why an installed program is not offered
2026-10-27 23:32:35 -07:00
SivanCola f133704176 fix(ci): make proof fixture independent of host sandbox availability
Problem: Linux coverage could not establish the carried-proof fixture;
CodeQL also flagged the shared inventory parser for exponential backtracking.
Root cause: the fixture inherited an unavailable host bash sandbox, and
repeated name-suffix groups could repartition the same slash sequence.
Fix: select sandbox off only in the isolated test user configuration;
parse the entire optional suffix once and add a bounded long-input test.
Verification: required builtin/Boot exit0, focused coverage four named
passes, 14-name shared contract, two Node tests, vet, pinned lint and check.
Original regex fails the bounded regression with one passing control.
No production Go, assertion, workflow, timeout, skip or baseline change.
Hosted Linux coverage, CodeQL and own-head Windows evidence remain due.
2026-10-28 14:28:12 +08:00
SivanCola 382016dfe1 fix(ci): avoid backtracking in concurrency inventory names
Problem: CodeQL reports exponential backtracking in the shared Windows
inventory test-name parser.
Root cause: repeated suffix groups can partition the same slash sequence.
Fix: accept the entire optional non-whitespace suffix once, preserving the
previous accepted language. Cover nested names and a long malformed suffix.
Verification: two Node tests pass; the original regex fails the bounded
regression while the existing contract test passes. Go and inventory bytes
are unchanged; prior exact-head Go qualification remains separate.
2026-10-28 14:18:44 +08:00
Will 4fefec819c fix(sessionstore): an unprovable lease is its own answer
SessionLeaseHeldByOtherRuntime folded every lease-lock probe failure into
"held", so a lock it could not open became the same answer as a live holder.
Redaction then skipped a session nobody held and reported an active lease,
leaving the secret scrub silently incomplete; session and task listings
claimed active/running on the same non-evidence.

Add ErrSessionLeaseUnprovable beside ErrSessionLeaseHeld and expose
SessionLeaseOwnership as the three-state probe. Only ErrSessionLeaseHeld
proves a holder; any other lock failure is unprovable with its cause kept.
The bool wrappers keep their old "may be busy" answers, so callers that only
skip work are unchanged.

Redaction refuses an unprovable session, reports it as a skipped file with
the reason (and exits non-zero), and never calls it an active lease. Machine
session state and running task/subagent status become unknown when ownership
cannot be proven; held and free keep their current answers.
2026-10-28 13:56:02 +08:00
Will c1079d99ba fix(repolint): register the wire mirrors that had no guard
The registry declares 324 pairs now: the 202 it already had and 122 page
copies that agree field for field with the struct their handler writes. The
table moved to a second file, wireparity_table.go, rather than taking
wireparity.go past the 800-line ceiling; the checker still reads one list.

Left out on purpose, because the writer is not a named struct and a guard
over the lookalike struct would not see the response drift:
account.DeviceGrant (/account/login assembles a map), market.Page
(/market/packages assembles a map) and control's McpDraft (/mcp/parse
assembles a map). host.ts's Refusal and ViewRect are the Electron preload's
bridge, not kernel wire; workspace.ts's WorkspaceEntry has no writer.

Tests: the added half resolves, is exact today and is unique per reader; a
dropped field on createBackupRequest -> BackupCreateRequest names the field.
2026-10-28 13:55:00 +08:00
Will 6c1f68df48 fix(tui): a restored image-only turn is still a turn
The terminal's hand-written HistoryMessage dropped images, via and modelRef.
A turn that was only an image has empty text, so Restore saw nothing to draw
and dropped it from the transcript, and every reload lost the device and the
model of turns it did keep. Carry the fields that decide that a turn exists
and what it was, restore the image-only row with an honest line, and register
the pair in repolint so a dropped field fails make check.
2026-10-28 13:43:20 +08:00
Lancy 9e68ee8f25 fix(testenv): isolate config locks from the real user home 2026-10-28 11:43:21 +08:00
Lancy 74f0bf4dc9 fix(studio): use semantic typography in session rename dialog 2026-10-28 11:34:35 +08:00
Lancy 9ce320d1c2 fix(studio): keep project controls outside the scrolling tree 2026-10-28 11:34:06 +08:00
Lancy 8ccc09925f fix(studio): align settings header button heights 2026-10-28 11:34:06 +08:00
YHH da10d503d5 Merge branch 'studio' into codex/subagent-status-admission-14025 2026-10-27 20:32:52 -07:00
YHH 96cdd810da Merge branch 'studio' into codex/goal-usage-delegation-scope-14025 2026-10-27 20:32:17 -07:00
YHH d2f94a6bc3 Merge branch 'studio' into feat/14649-adjustable-viewport 2026-10-27 20:31:43 -07:00
YHH ccd0f0ca9f Merge branch 'studio' into fx/openwith-explains-inside-project 2026-10-27 20:31:09 -07:00
YHH a001b16563 Merge pull request #14685 from esengine/fx/xnet-0.62
fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6860
2026-10-27 20:30:52 -07:00
YHH cc08d5552b Merge pull request #14619 from SivanCola/fix/studio-worktree-labels
fix(studio): clarify worktree labels and directory choices / 简化工作树标识并保留目录模式选择
2026-10-27 20:09:28 -07:00
YHH bf42f9ad83 Merge pull request #14494 from SivanCola/fix/isolation-prepared-candidates-14025
feat(isolation): prepare Store-owned candidates / 由结果 Store 统一准备候选
2026-10-27 20:08:52 -07:00
YHH e511357b6d Merge pull request #14446 from SivanCola/codex/opaque-write-admission-14025
feat(mutation): coordinate opaque writes across roots / 未知范围写入跨根互斥
2026-10-27 20:08:14 -07:00
YHH 79b96679e0 Merge pull request #14459 from SivanCola/codex/process-root-wait-14025
feat(proc): expose root wait completion / 区分根等待完成与取消返回
2026-10-27 20:07:37 -07:00
YHH 6d29079a09 Merge pull request #14549 from chuiwenwei/fix/doctor-catalogs-stops-saying-ok
fix(doctor): a catalog that recorded a failure is not ok
2026-10-27 20:07:00 -07:00
Sivan 787886669f docs(ci): clarify carried proof limits and share Windows inventory checks 2026-10-28 11:04:54 +08:00
SivanCola 8378f0834c fix(delegation): identify busy ancestor continuations and share Windows checks 2026-10-28 11:03:31 +08:00
esengine 600bd55461 feat(desktop): drop the title block above each settings page
The navigation already shows the section and page, so the first card now sits
at the top of the page. The setting count and the section's value stay in the
navigation row.
2026-10-27 19:54:48 -07:00
esengine dc92a55618 fix(studio): the Execution row uses the same text size as the Thinking row 2026-10-27 19:43:13 -07:00
esengine 80ebd88bf7 fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6860 2026-10-27 19:33:39 -07:00
YHH 8ae73f9332 Merge pull request #14659 from esengine/fx/installer-closes-running-studio
feat(installer): close a running Studio thoroughly and say how when it cannot
2026-10-27 19:31:57 -07:00
SivanCola 7352385cc6 fix(delegation): reserve ancestor copy admission before publication
Problem: Competing continuations could create two copies of one ancestor in
the same parent session, leaving later continuation ambiguous.

Root cause: The source ref lock ended before a newly allocated copy published
metadata, and each preparation acquired a different destination ref lock.

Fix: Reserve the canonical nearest-source and target-parent pair before
searching for copies. Transfer the reservation to the returned run's existing
release lifetime, and release it on failed preparation. Preserve independent
parents, sources, current-reference locks and persisted formats.

Verification: Real TaskTool publication interleavings and Store regressions;
exact old-owner overlay fails 9 named events with 5 controls. Root 240-package
coverage, delegation/sessionstore race, vet, pinned lint, make check,
actionlint and Windows/Linux compile-only checks pass. Windows named-step
inventory validation rejects missing package completion and skipped cases.

Refs #14025
2026-10-28 10:29:45 +08:00
Lancy 6ade8b3238 feat(studio): add adjustable conversation viewport width 2026-10-28 09:16:34 +08:00
YHH cbdc515ce7 Merge branch 'studio' into fx/installer-closes-running-studio 2026-10-27 16:51:12 -07:00
YHH fb3e8535e7 Merge branch 'studio' into fx/openwith-explains-inside-project 2026-10-27 16:50:39 -07:00
SivanCola 1117fb2eca fix(agent): bind carried proof to regular file state
Invalidate persisted Delivery proof when file mode changes without a byte edit.
Use the existing content-version reader and verify surrounding identity, mode,
size and mtime observations. Refuse links, directories and special files as
regular-file proof inputs.

Version fingerprint values so legacy byte-only proof conservatively requires
fresh evidence in both reader directions, while goals and transcripts remain
readable. Cover real Boot persistence/rebuild, compatibility and Unix kinds,
and add a required native Windows inventory with bilingual documentation.

Refs #14025
Refs #14133
2026-10-28 05:05:30 +08:00
SivanCola 430aea04a1 ci: run status admission inventory before broad Windows smoke
Preserve every workflow step, condition, timeout and assertion. Move the
status-owned pair away from concurrently added budget inventories so both
sets compose, and collect the named evidence before broad smoke can fail.

Refs #14656
2026-10-28 03:57:35 +08:00
SivanCola 7972a60da7 fix(event): preserve quote and scope capabilities together
Carry the accepted UsageQuoter event owner alongside Goal usage scope capture.
Retain both optional capabilities in wrappers and their shared inventory while
keeping scope-specific fixture additions independent of the quote additions.

Verify that nested bound usage reaches the occurrence-time quote owner once,
keeps the earlier Goal attribution, and preserves the emitted JSON. Document
the two owners and retain existing Windows required-test coverage.

Refs #14661
Refs #14671
2026-10-28 03:53:07 +08:00
esengine 61bbcd5a16 feat(installer): close a running Studio thoroughly and say how when it cannot 2026-10-27 10:58:38 -07:00
esengine 4713b74f1d feat(openwith): say why an installed program is not offered 2026-10-27 10:07:45 -07:00
SivanCola 57678cc06a fix: retain Goal usage ownership across delegated work
Problem: background children from an older chat or Goal could charge late
usage to a newly active Goal while still retaining their original root budget.

Root cause: the usage tee selected its current recorder at event delivery,
so recorder epoch checks were applied to the wrong owner.

Fix: capture an opaque process-local accounting scope at the shared tool-call
boundary. Forward the optional capability through sink wrappers, preserve it
for nested delegation, and select the captured Goal recorder at consumption.
Keep session tally, quoting, title exclusion, provider requests and wire data
unchanged. Document between-turn accounting and persistence limitations.

Verification: real Boot delayed-background and nested fixtures, same-Goal
controls, Goal lifecycle and JSON tests, exact preceding-owner negatives,
root package inventory, owner/Boot race tests, static gates, cross-compilation
and a pinned native Windows inventory with fail-closed verifier mutants.
Hosted native execution and independent review remain separate gates.

Refs #14025.
2026-10-27 18:24:18 +08:00
SivanCola 32c323a553 fix(delegation): reject unsupported persisted run states
Problem: Saved child records with missing or unknown status could reach
continuation and transcript-copy preparation.

Root cause: The shared metadata validator rejected three known bad states
but implicitly admitted every other string, including future enum values.

Fix: Permit only the existing completed/cancelled states before unchanged
identity checks. Keep metadata decoding and writers unchanged so refusal
preserves unknown fields and transcript bytes for diagnosis.

Verification: Real on-disk store/reopen matrix covers nine state shapes and
five preparation routes, artifact preservation and reference-lock release.
Exact preceding owner fails 25 named events with 31 passing controls.
Qualification results and compatibility limits are recorded in the PR body.
2026-10-27 17:43:47 +08:00
SivanCola e031cd0cf6 fix(studio): clarify worktree labels and directory choices
Problem: Directory labels were inconsistent across the header and composer,
and the default green badge looked like a success status.

Root cause: Separate presentation strings described the same workspace
state, including an unnecessary badge for the ordinary directory case.

Fix: Show neutral labels only for isolated worktrees, retain explicit
current-folder/worktree choices with checkmarks, and share explanatory
tooltips while preserving full paths and branches. Update translations,
usage documentation, and the release note.

Verification: Frontend interaction, translation and style checks;
TypeScript; make check; gofmt; go vet; make lint; browser comparisons in
light/dark themes at wide and phone widths. Required Go package and
production build results are recorded in the pull request.
2026-10-27 12:06:27 +08:00
Will 87f7238ab7 chore: retrigger the suite after a flaky boot run
The boot package ran 726s on that job against 264s here, and this change does
not reach it: projectiondb.OpenReadOnly/Inspect have one production caller and
it is the doctor command. No code change.
2026-10-27 11:51:12 +08:00
Will 1bcded8b1b docs(release-notes): record #14549 2026-10-27 11:51:12 +08:00
Will e0f288b8ed fix(doctor): a catalog that recorded a failure is not ok
Problem: `reasonix doctor catalogs` printed the projection's integrity_check
string as the catalog's state, so a history catalog whose history_sources row
recorded health='corrupt' printed `ok`, and a future-schema database -- which
the runtime opens as StateDegraded -- printed `ok` too. The command a user is
told to run to check their data agreed with neither the catalog nor
`catalogs reindex --json`, which reports the same state as "failed": 1.

Root cause: the state column came from Inspection.Integrity and nothing else.
The command never asked the catalog what it recorded, and never compared the
recorded schema against the schema this build migrates to.

Fix: read the facts reindex already reads. refreshStatus and refresh now share
one readStatusCounts with the new read-only Inspect readers, so the
diagnostic's failed is the number reindex reports, and the reason is the one
the catalog recorded. A newer schema reports degraded against
projectiondb.SupportedVersion, which ApplyMigrations also uses. --json gains
state, failed, pending, corruptLines/lagBytes and failures only when there is
something to say, so a healthy catalog's output is unchanged. Text stays one
line per catalog. Exit stays 0: like `catalogs reindex --json`, this command
reports rather than repairs.

Verification: red-first. A reindexed undecodable transcript printed
`history  ok`; it now prints `history  failed ... failed=1 source=<path>:
<recorded reason>`. A schema-2 database printed `ok`; it now prints
`degraded ... supported=1`. Controls: healthy line and exit unchanged, missing
line unchanged, healthy JSON keys unchanged. Mutation: dropping the failure
read turns the corrupt-source test red, dropping the degraded read turns the
future-schema test red. projectiondb.OpenReadOnly reads a live WAL, which
immutable=1 skipped. No projection schema, history index or reindex path
changed. Refs docs/USAGE_CATALOG.md:25, docs/HISTORY_SEARCH_CATALOG.md:42-43.
2026-10-27 11:51:12 +08:00
SivanCola 728a0ed165 feat(isolation): prepare Store-owned candidates from one baseline
Problem: candidate groups need a common baseline while preserving the result
Store's source, managed-root and identity ownership.

Root cause: ordinary Begin captures afresh, while arbitrary snapshot/path
adoption would bypass the owning Store and its existing settlement contract.

Fix: add an opaque process-local Preparation, recheck source/workspace/Git
directory identities, and create independent results from the captured
snapshot. Extract the existing Begin bodies into private shared helpers.
No best-of, runtime, default or persisted-format activation is included.

Verification: complete 239-package root inventory, full isolation race,
independent builtin/boot command, vet, full lint and make check pass with the
2664 baseline unchanged. Compiling unsafe controls distinguish baseline
refresh and replaced-directory admission; the preceding Store implementation
passes the new preparation tests. Linked-source overlay passes; Windows and
Linux amd64 compile only. Native/hosted validation and independent review
remain separate gates.
2026-10-26 01:50:29 +08:00
imcreazy 4f915fe504 ci(android): build the APK on a tag and attach it to the release 2026-10-26 01:39:33 +08:00
SivanCola 5d484c4fe2 feat(proc): expose tracked root wait completion
Problem: A foreground command can return bounded cancellation while cmd.Wait
is still blocked, so call return cannot establish resource-lifetime completion.

Root cause: The tracked wait owner exposes its logical result and cleanup
diagnostics but no separate observation of the still-running root Wait.

Fix: Register a stable root-wait channel before the waiter starts and close it
after Wait returns, before result publication. Expose it with the explicit
RootWaitCompleted name. Preserve existing execution, cancellation, retry and
error behavior. No production lease consumer or whole-scope claim is added.

Verification: Deterministic cancellation/retry/publication tests, portable Go
child execution and a real blocked-output process/lease regression pass. Root
Wait remains distinct from descendant or external-scope completion; nil means
no registered observation. Full local/static and platform evidence is recorded
in the PR without inheriting previous heads' native results.
2026-10-25 20:00:34 +08:00
SivanCola 25169a45f1 feat(mutation): admit explicitly opaque write effects
Problem: Directory-scoped coordination cannot exclude writers that address a
source file through a hardlink outside that directory.

Root cause: Path containment and named inode identity do not represent an
unresolved write effect; reinterpreting old directory records would silently
change their resource promise and nested admission could reuse a weaker hold.

Fix: Publish explicit version-3 opaque effects in the existing registry,
coordinate them symmetrically against all path-bearing transactions, and
require effect coverage when reusing a parent lease. Preserve ordinary named
parallelism, model capacity, previous directory semantics and final OS-locked
grant authority. No runtime or default requests the new effect here.

Verification: Full 239-package root inventory and independent builtin/boot
commands exit 0; mutation race 82 passes/zero skips; preceding-reader binary
preserves live/dead new records; weakened-owner control fails eight cases;
selected wait-snapshot composition has 36 race passes/zero skips. Root vet,
full lint, changed-file checks and formatting pass at unchanged baseline 2664.
Windows/Linux amd64 compile only; local execution is macOS.
2026-10-25 19:21:21 +08:00
336 changed files with 15744 additions and 880 deletions

No files matched your search

@@ -0,0 +1,24 @@
[
{
"package": "reasonix/internal/runtime/delegation",
"tests": [
"TestAncestorCopyAdmissionCoversUnpublishedRun",
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue",
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy",
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue",
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy",
"TestAncestorCopyAdmissionAllowsDifferentParents",
"TestAncestorCopyAdmissionReleasesAbandonedPreparation",
"TestAncestorCopyAdmissionReleasesAfterInvalidCopy",
"TestAncestorCopyAdmissionAllowsDifferentSources",
"TestAncestorCopyAdmissionUsesNearestSourceIdentity",
"TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/continue_from",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/fork_from"
]
}
]
@@ -0,0 +1,26 @@
[
{
"package": "reasonix/internal/runtime/agent",
"tests": [
"TestCarriedProofInvalidatesPermissionChange",
"TestCarriedProofFileStateCompatibility",
"TestCarriedProofFileStateCompatibility/legacy_file",
"TestCarriedProofFileStateCompatibility/legacy_absent",
"TestCarriedProofFileStateCompatibility/future",
"TestCarriedProofFileStateCompatibility/current_file",
"TestCarriedProofFileStateCompatibility/current_absent",
"TestCarriedProofRejectsDirectoryState",
"TestCarriedProofStandsOnlyForUnchangedContent",
"TestCarriedProofSeesADeletion"
]
},
{
"package": "reasonix/internal/assembly/boot",
"tests": [
"TestEffectCarriedProofTracksFileState",
"TestEffectCarriedProofTracksFileState/unchanged",
"TestEffectCarriedProofTracksFileState/content",
"TestEffectCarriedProofTracksFileState/mode"
]
}
]
+275
View File
@@ -0,0 +1,275 @@
name: Android
# The Android shell under `android/` ships from the same tag the desktop line
# publishes, and lands on the same release page.
#
# It is a separate workflow rather than a job inside release-studio.yml for two
# reasons. The toolchains share nothing — Go, the Android SDK, a Gradle wrapper —
# so a runner carrying both would install three toolchains to build one artifact.
# And `signing-contract` pins which workflows may reach the SignPath credentials;
# this one never touches them, and keeping it out of that graph is what keeps the
# fingerprint stable.
#
# A failure here does not hold back the desktop release: the two are independent
# by construction, and an APK is worth less than the desktop builds it sits
# beside.
on:
push:
tags: ['studio-v*']
workflow_dispatch:
inputs:
tag:
description: "Existing studio tag to build for (for example studio-v2.51.0)"
required: true
type: string
permissions:
contents: write # upload the APK to the release the tag belongs to
concurrency:
group: android-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
name: resolve the tag
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
candidate: ${{ steps.release.outputs.candidate }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
# The same tag grammar release-studio.yml enforces, spelled the same way:
# two workflows that disagree about which tags name a release would publish
# one artifact for a tag the other refused.
- name: Resolve tag and version
id: release
env:
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
tag="${IN_TAG:-$REF_NAME}"
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
exit 1
fi
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
if ! git merge-base --is-ancestor "$sha" origin/studio; then
echo "::error::$tag is not on studio history"
exit 1
fi
version="${tag#studio-}"
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
fi
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
echo "candidate=$candidate"; } >> "$GITHUB_OUTPUT"
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
build:
name: build the APK
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: read
# `publish` decides whether to attach anything, and the only honest answer to
# "will this APK install" is whether a key was restored.
outputs:
signed: ${{ steps.key.outputs.signed }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
with:
version: 10
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: "21"
cache: gradle
# The SDK is the one thing nothing sets for you, per android/README.md. The
# runner image carries one; this asserts the two pieces the build names
# rather than re-installing them, so a missing package fails here by name
# instead of surfacing later as an unresolved compileSdk.
- name: Provide the Android SDK
run: |
if [ -z "${ANDROID_HOME:-}" ]; then
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
exit 1
fi
for piece in "platforms/android-35" "build-tools/35.0.0"; do
if [ ! -d "$ANDROID_HOME/$piece" ]; then
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
exit 1
fi
done
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
# A key that exists signs; a key that does not leaves the artifact
# unsigned, which is what a release with no key has always produced. The
# decision is the repository's, not this workflow's: it reads the four
# secrets android/README.md already names.
- name: Restore the signing key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "no signing key configured: the APK will be unsigned"
exit 0
fi
mkdir -p android/keystore
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
{
echo "storeFile=keystore/release.jks"
echo "storePassword=${STORE_PASSWORD}"
echo "keyAlias=${KEY_ALIAS}"
# PKCS#12 keystores use one password for both; only write the second
# when it is genuinely a different fact.
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
} > android/keystore/keystore.properties
chmod 600 android/keystore/*
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "signing key restored"
- name: Build the kernel and the page
working-directory: android
run: |
./scripts/build-kernel.sh
./scripts/sync-page.sh
# The one end-to-end check that needs no phone: the kernel the shell starts,
# serving the page the APK carries. Running it here means an APK that would
# open blank fails the build instead of the install.
- name: Verify the packaged page
working-directory: android
run: |
(cd .. && go build -o reasonix-verify ./cmd/reasonix)
KERNEL_BIN="$PWD/../reasonix-verify" ./scripts/verify-page.sh
- name: Assemble
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
# The version comes from the tag, so the About screen and the update
# check name this release. versionCode keeps its existing shape, which
# is why the build digit stays 1: it is a single decimal place, and a
# number that grows past it would collide with the next patch.
./gradlew assembleRelease --no-daemon "-PreasonixVersion=${VERSION#v}"
- name: Name the artifact
id: apk
working-directory: android/app/build/outputs/apk/release
run: |
src="$(ls ./*.apk | head -n 1)"
if [ -z "$src" ]; then
echo "::error::assembleRelease produced no APK"
exit 1
fi
case "$src" in
*-unsigned.apk) name="ReasonixMobile-${{ needs.resolve.outputs.version }}-unsigned.apk" ;;
*) name="ReasonixMobile-${{ needs.resolve.outputs.version }}.apk" ;;
esac
mv "$src" "$name"
sha256sum "$name" > "$name.sha256"
{
echo "path=$PWD/$name"
echo "name=$name"
} >> "$GITHUB_OUTPUT"
ls -l "$name" "$name.sha256"
# An unsigned package is not a lesser APK, it is an unusable one: Android's
# package manager rejects it outright rather than reporting a signature
# mismatch. Say so where the run summary can be read, instead of letting a
# green build imply a shippable artifact.
- name: Say what an unsigned build means
if: steps.key.outputs.signed != 'true'
run: |
echo "::warning::no signing key configured, so this APK is unsigned. Android refuses to install an unsigned package, so the release will carry no APK; the file stays a workflow artifact for whoever signs it."
{
echo "### No APK on this release"
echo ""
echo "The build produced an unsigned APK, which Android refuses to install."
echo "It is attached to this run as a workflow artifact only. Add the four"
echo "signing secrets to publish one."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v8
with:
name: android-apk
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/apk/release/*.sha256
if-no-files-found: error
publish:
name: attach the APK
needs: [resolve, build]
# Nothing to attach unless the build was signed. Skipping is the honest
# outcome: a release page with an APK nobody can install is worse than a
# release page without one.
if: needs.build.outputs.signed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: android-apk
path: dist
# The release itself belongs to release-studio.yml, which creates it and
# owns its notes. This waits for it rather than creating a second one: two
# jobs racing to define a release is how a page ends up with the wrong body.
- name: Attach to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
REPO: ${{ github.repository }}
run: |
# 90 minutes: on the last Studio release the page appeared about 40
# minutes after the tag run started, and the APK build spends part of
# that before this wait begins.
for _ in $(seq 1 360); do
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
gh release upload "$TAG" dist/*.apk dist/*.sha256 --repo "$REPO" --clobber
echo "attached to $TAG"
exit 0
fi
sleep 15
done
echo "::error::release $TAG did not appear within 90 minutes; the APK was built but not published"
exit 1
+137 -1
View File
@@ -170,6 +170,23 @@ jobs:
shell: pwsh
run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
- name: test (Windows concurrency contracts)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
node --test scripts/windows-concurrency-tests.test.mjs
node scripts/windows-concurrency-tests.mjs "$RUNNER_TEMP/concurrency-windows.jsonl"
- name: upload Windows concurrency inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: concurrency-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/concurrency-windows.jsonl
if-no-files-found: error
retention-days: 7
- name: test
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
env:
@@ -179,6 +196,35 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test (Windows subagent status admission)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^TestSubagent(PersistedStatusAdmission|UnknownStatusStillReadableForDiagnosis)$' ./internal/runtime/delegation | tee "$RUNNER_TEMP/subagent-status-admission.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
["future-paused", "missing", "empty", "COMPLETED", "completed", "cancelled", "failed", "interrupted", "running"] as $states |
["continue", "copy", "ancestor", "legacy_ancestor", "current_copy"] as $routes |
(["TestSubagentPersistedStatusAdmission", "TestSubagentUnknownStatusStillReadableForDiagnosis"] +
[$states[] | "TestSubagentPersistedStatusAdmission/" + .] +
[$states[] as $state | $routes[] | "TestSubagentPersistedStatusAdmission/" + $state + "/" + .]) as $required |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
($required - $tests | length) == 0
' "$RUNNER_TEMP/subagent-status-admission.jsonl"
- name: upload Windows subagent status admission inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: subagent-status-admission-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/subagent-status-admission.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows composition links)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
@@ -272,6 +318,32 @@ jobs:
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
' "$RUNNER_TEMP/isolation-owners.jsonl"
jq -se '
. as $events |
{"reasonix/internal/platform/worktree": [
"TestApplyPathsRejectsRedirectedAncestors",
"TestApplyPathsRejectsRedirectedAncestors/outside_delete",
"TestApplyPathsRejectsRedirectedAncestors/inside_delete",
"TestApplyPathsRejectsRedirectedAncestors/dangling_delete",
"TestApplyPathsRejectsRedirectedAncestors/outside_write",
"TestApplyPathsRejectsRedirectedAncestors/inside_write",
"TestApplyPathsRejectsRedirectedAncestors/regular_parent",
"TestApplyPathsRejectsRedirectedAncestors/unchanged",
"TestApplyPathsRejectsRedirectedAncestors/missing_creation",
"TestWriteChangesRejectsRedirectedDeletion",
"TestWriteChangesUnlinksLeafDirectoryLink",
"TestWholeWorkspaceApplyPreservesFileToDirectoryTransition"],
"reasonix/internal/runtime/isolation": [
"TestApplyToolRetainsResultAfterAncestorReplacement"]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass") and
all(.value[]; . as $test |
[ $events[] | select(.Package == $package.key and .Test == $test and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass"))
' "$RUNNER_TEMP/isolation-owners.jsonl"
- name: upload Windows isolation test inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
@@ -320,6 +392,38 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows delegated Goal usage scope)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*|DelegatedUsageKeepsGoalOwner|EffectDelegatedUsageKeepsGoalOwner)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
jq -se '
["TestUsageScopePreservesCapturedOwnerAndWire", "TestUsageScopeKeepsEarlierEventOwner",
"TestUsageScopeWithoutCapability", "TestDelegatedUsageKeepsGoalOwner",
"TestDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestDelegatedUsageKeepsGoalOwner/replaced_goal",
"TestDelegatedUsageKeepsGoalOwner/cleared_goal", "TestDelegatedUsageKeepsGoalOwner/resumed_goal",
"TestDelegatedUsageKeepsGoalOwner/same_goal_next_turn", "TestDelegatedUsageKeepsGoalOwner/between_turns",
"TestDelegatedUsageKeepsGoalOwner/nested_after_replacement", "TestEffectDelegatedUsageKeepsGoalOwner",
"TestEffectDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/replaced_goal",
"TestEffectDelegatedUsageKeepsGoalOwner/nested_chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/same_goal",
"TestEffectDelegatedUsageKeepsGoalOwner/completed_before_goal"] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/contract/event", "reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/goal-usage-scope.jsonl"
- name: upload Windows delegated Goal usage scope inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: goal-usage-scope-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/goal-usage-scope.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows planner local budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
@@ -495,7 +599,7 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken|EffectReportedTotalTokenBudget)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
@@ -512,6 +616,38 @@ jobs:
"TestEffectRootTokenBudgetReachesDelegatedJobs/foreground_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background",
"TestEffectRootTokenBudgetReachesDelegatedJobs/background_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background_fleet"] - $tests | length) == 0
' "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
. as $events |
{"reasonix/internal/runtime/agent": [
"TestBudgetCountsKnownReportedTokens",
"TestBudgetCountsKnownReportedTokens/split",
"TestBudgetCountsKnownReportedTokens/total_only",
"TestBudgetCountsKnownReportedTokens/partial_split",
"TestBudgetCountsKnownReportedTokens/lower_total",
"TestBudgetCountsKnownReportedTokens/missing_total",
"TestBudgetCountsKnownReportedTokens/negative_split",
"TestBudgetCountsKnownReportedTokens/zero",
"TestBudgetCountsKnownReportedTokens/negative",
"TestReportedTokensEnforceLocalCapOnInheritedRoot",
"TestReportedTokensFollowOriginalAuxiliaryRoot",
"TestReportedTokenCountersSaturate"],
"reasonix/internal/assembly/boot": [
"TestEffectReportedTotalTokenBudget",
"TestEffectReportedTotalTokenBudget/split",
"TestEffectReportedTotalTokenBudget/total_only",
"TestEffectReportedTotalTokenBudget/partial_split",
"TestEffectReportedTotalTokenBudget/lower_total",
"TestEffectReportedTotalTokenBudget/missing_total",
"TestEffectReportedTotalTokenBudget/disabled"]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass") and
all(.value[]; . as $test |
[ $events[] | select(.Package == $package.key and .Test == $test and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass"))
' "$RUNNER_TEMP/root-token-budget.jsonl"
- name: upload Windows root token budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
+16
View File
@@ -117,6 +117,22 @@ go test ./internal/runtime/agent/ -v # verbose, one package
go test ./internal/tools/builtin/ -run TestGrep # one test
```
`testenv.IsolateUserState` installs a disposable user home and config-edit lock
scope. Default-path tests can clear `REASONIX_HOME` without losing the test
registry, while explicitly isolated Reasonix homes retain their own locks.
`REASONIX_TEST_HOME` carries the scope to helper subprocesses. A helper that
skips the isolation guard must install the inherited home with
`testscope.InstallHome` before running tests.
The environment variable alone cannot activate a test scope. Production
executables still use the OS account's home for shared config locks.
The guard removes its scratch home on normal completion. Scratch homes are
created under the original home, so an interrupted run can still leave
`.reasonix-test-home-*` directories; this is separate from persistent
config-edit locks in the real `.reasonix/locks` directory.
### Code style
- `gofmt` is enforced by CI — format before committing
+13
View File
@@ -169,6 +169,19 @@ Every package ships with a `.minisig` signature, and the release carries
The same release also carries the 2.x `reasonix` CLI as archives for
`darwin|linux|windows × amd64|arm64`.
The Android build ships on that release as `ReasonixMobile-<version>.apk`
(`arm64`, Android 8.0 or newer). The kernel and the page run on the phone, and
the agent reaches your servers over SSH; pairing with a computer is done by
scanning the code it shows.
The APK is signed with the Android release key rather than minisign, so it
carries a `.sha256` and no `.minisig`.
When that key is absent the build is named
`ReasonixMobile-<version>-unsigned.apk`. Android refuses to install an unsigned
package, so those builds stay a workflow artifact for whoever wants to sign one,
and the release carries no APK at all.
### Reasonix 1.x (stable)
The 1.x CLI installs through npm on any supported platform, or Homebrew on
+9
View File
@@ -156,6 +156,15 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
安卓版在同一个发布页上,文件名是 `ReasonixMobile-<版本>.apk`(`arm64`,Android 8.0 及以上)。
这个 APK 用 Android 发布密钥签名,不走 minisign,因此只有一个 `.sha256`,没有 `.minisig`。
该密钥缺失时产物名为 `ReasonixMobile-<版本>-unsigned.apk`。Android 不接受未签名的包,
这类构建只留作 workflow 产物供自行签名,发布页上不会挂 APK。
内核与界面都跑在手机上,Agent 通过 SSH 连接你自己的服务器;与电脑配对时扫它显示的二维码。
### Reasonix 1.x(稳定版)
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
+21
View File
@@ -20,10 +20,25 @@ plugins {
}
// One version, read by the About screen and the update check.
//
// A published build takes its version from the tag it was cut for, so the About
// screen and the update check name that release rather than the number this file
// was last edited to. Without the property — a local build — the numbers below
// are the answer.
def publishedVersion = findProperty("reasonixVersion") as String
def versionMajor = 2
def versionMinor = 0
def versionPatch = 0
def versionBuild = 1
if (publishedVersion) {
def parsed = publishedVersion =~ /^(\d+)\.(\d+)\.(\d+)/
if (!parsed.find()) {
throw new GradleException("reasonixVersion must start with MAJOR.MINOR.PATCH, got: ${publishedVersion}")
}
versionMajor = parsed.group(1) as int
versionMinor = parsed.group(2) as int
versionPatch = parsed.group(3) as int
}
// Where the two build inputs are expected. Both are produced from the checkout
// beside this module, by the scripts named in each message.
@@ -53,6 +68,12 @@ android {
// what `MainActivity` feeds real inset values for, since a WebView does
// not implement `env(safe-area-inset-*)`.
targetSdk 35
// versionCode must strictly increase, because Android refuses an update
// whose versionCode is not higher. The build digit is a single decimal
// place, so the scheme collides once a patch reaches 10: 2.55.10 and
// 2.56.0 both come out as 25601. Patch releases above 9 are not expected
// on Studio, and widening the digit now would renumber every build, so
// the constraint is recorded rather than fixed.
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
+59 -3
View File
@@ -420,7 +420,7 @@ own outcome).
| Flag | Default | Purpose |
| --- | --- | --- |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens). |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens; `audit` checks a batch's published figures against its retained rows). |
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
@@ -433,6 +433,7 @@ own outcome).
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`, `upstream` and `recall-search`; `none` \| `all`. |
| `-out` | *(stdout)* | Write the markdown report here. |
| `-json` | *(none)* | Write the JSON report here (optional). |
| `-batch` | *(none)* | Suite mode: write `results.jsonl` (one row per attempt) and `batch.json` (manifest v1) here, so the report's figures can be audited later. Audit mode: read the batch from here. See [Auditing a batch](#auditing-a-batch). |
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
@@ -455,10 +456,65 @@ Diff-mode flags:
| `-timeout` | `1200` | Agent timeout in seconds (diff mode). |
| `-attempts` | `1` | Diff mode: retry up to N times until a run passes (stochastic agent). |
## Auditing a batch
`-batch DIR` writes two files from a suite run: `results.jsonl` (one row per
attempt — the raw record every figure is computed from) and `batch.json`
(manifest v1). `-mode audit -batch DIR` re-derives the report's headline figures
from `results.jsonl`, prints the manifest's claim beside the recomputed value,
and exits non-zero naming any figure that moved. It also refuses a batch whose
manifest is missing, records no commit, or whose rows are truncated.
This is an **aggregation audit, not a replay**: it proves the published figures
follow from the retained rows. It does not re-execute the model, tools or
environment, and cannot show the experiment would reproduce. A real replay would
need the commit, a corpus fingerprint, the model and provider configuration, the
seed, the flags, the raw results and the runtime version; it is not implemented.
`schema_version` is top level. The rest is three groups:
| Group | Fields | Answers |
| --- | --- | --- |
| `over` | `command.mode` and `command.axes` (every flag that changes what a figure means; paths omitted); `tasks.count` and `tasks.digest` (sha256 over sorted, NUL-separated task ids — the corpus identity, never the task text); `filters`; `denominator` (`unit`, `definition`, `value`) | what the figures are over |
| `producer` | `commit` (or an explicit `unknown`, never a guess), `commit_source`, `dirty`, `model`, `provider`, `seed`, `started_at`, `finished_at`, `harness_version`, `go_version` | which run produced them |
| `trust` | `results_sha256`, `claimed` (the headline figures), `ledger`, `retention` | why they can still be checked |
The ledger accounts for every planned run, and the audit checks that the counts
form a funnel (`scored ≤ completed ≤ attempted`, `failed ≤ scored`,
`scored = pass1 + failed`, `rows = attempted + skipped`). A row count is never a
denominator: `denominator.value` is a count of scored tasks, and `rows` is
published only to show what was retained.
| Count | Meaning |
| --- | --- |
| `planned` | graded runs the batch set out to attempt: filtered tasks × trials |
| `attempted` | retained rows for runs that started (skipped rows are not attempts) |
| `completed` | attempts that ended and left a row; this harness writes a row only after an attempt ends, so it equals `attempted` and the equality is checked, not assumed |
| `scored` | completed first attempts of solvable, non-skipped tasks — the pass@1 denominator |
| `failed` | scored attempts the grader did not pass |
| `unmeasured` | attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals, never counted as zero |
| `excluded` | rows kept out of the accuracy denominator: skipped, `no_solution`, and retry entries |
Spend and cache fields are omitted from `claimed` when nothing was measured, so
an absent figure reads as unknown rather than as a measured zero. `seed` is
omitted for the same reason: the harness does not set a sampling seed.
`results_sha256` covers the values and the presence of every row; line endings,
indentation and key order are normalised away because none of them is evidence,
while number spelling is kept (a rewritten `1` is not `1.0`).
A successful audit records the verifier version (`audit.verifier_version`) and
the digest it checked in `batch.json`, so a re-verification can tell which code
checked the batch.
`results.jsonl` is retained unredacted beside the manifest — task prompts and
agent notes included — and is not public. The manifest itself carries a task-id
digest, no prompt text, no absolute paths, no endpoints or keys and no model
output. A claim whose rows are gone cannot be audited: there is nothing to
recompute from, and this tool will not certify it.
## Dataset retention
Keep every `-json` report and `-trajectories` directory from real runs: they
are the accumulating corpus — per-task contracts-to-be, full event
Keep every `-json` report, `-trajectories` directory and `-batch` directory from
real runs: they are the accumulating corpus — per-task contracts-to-be, full event
trajectories, checkpoint oracle verdicts, stop curves and phase traces — that
any future offline learning (routing, stop policies, budgets) would train
and evaluate on. The control plane stays deterministic and interpretable
+428
View File
@@ -0,0 +1,428 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// runAuditMode prints both sides of the audit before returning the failure, so
// a moved figure is visible even when the exit code is non-zero.
func runAuditMode(dir, outMD string) error {
report, err := auditBatch(dir)
if report != "" {
emit(report, outMD, "")
}
return err
}
// auditBatch re-derives the published figures from results.jsonl and checks
// them against batch.json. It reports both sides and never re-runs anything.
func auditBatch(dir string) (string, error) {
if strings.TrimSpace(dir) == "" {
return "", errors.New("audit needs -batch <directory>: the directory a suite run wrote")
}
manifestPath := filepath.Join(dir, batchManifestName)
data, err := os.ReadFile(manifestPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("no %s under %s: a batch that kept no manifest cannot be audited", batchManifestName, dir)
}
return "", fmt.Errorf("read %s: %w", batchManifestName, err)
}
var m batchManifest
if err := json.Unmarshal(data, &m); err != nil {
return "", fmt.Errorf("%s is not valid JSON: %w", batchManifestName, err)
}
if m.SchemaVersion != batchSchemaVersion {
return "", fmt.Errorf("%s declares schema version %d; this audit implements v%d", batchManifestName, m.SchemaVersion, batchSchemaVersion)
}
if commit := strings.TrimSpace(m.Producer.Commit); commit == "" || commit == "unknown" {
return "", fmt.Errorf("%s records no commit (%q): the figures cannot be tied to a version, so this audit stops", batchManifestName, m.Producer.Commit)
}
rows, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
if err != nil {
return "", err
}
hash := hashOf(canonical)
stats := gatherSuiteStats(rows)
fromRows := ledgerFromRows(rows)
figures, failures := compareClaims(m.Trust.Claimed, stats)
ledger, ledgerFailures := compareLedger(m.Trust.Ledger, fromRows)
failures = append(failures, ledgerFailures...)
failures = append(failures, ledgerInvariants(m.Trust.Ledger)...)
failures = append(failures, denominatorChecks(m, fromRows)...)
if hash != m.Trust.ResultsSHA256 {
failures = append([]string{fmt.Sprintf("results_sha256: manifest %s, rows %s", textOrUnknown(m.Trust.ResultsSHA256), hash)}, failures...)
}
report := renderAudit(dir, m, rows, hash, figures, ledger, failures)
if len(failures) > 0 {
return report, fmt.Errorf("%d mismatch(es): the published figures do not follow from the retained rows", len(failures))
}
if err := recordAuditReceipt(manifestPath, m, hash); err != nil {
return report, err
}
return report, nil
}
// readBatch parses results.jsonl once: the typed rows the figures come from,
// and the canonical bytes results_sha256 covers.
func readBatch(path string) ([]result, []byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, fmt.Errorf("no %s: the manifest describes rows that are not beside it", batchResultsName)
}
return nil, nil, fmt.Errorf("read %s: %w", batchResultsName, err)
}
if len(data) > 0 && data[len(data)-1] != '\n' {
return nil, nil, fmt.Errorf("%s does not end in a newline: its last row is truncated", batchResultsName)
}
var rows []result
var canonical bytes.Buffer
line := 0
for raw := range bytes.SplitSeq(data, []byte("\n")) {
if len(bytes.TrimSpace(raw)) == 0 {
continue
}
line++
var r result
if err := json.Unmarshal(raw, &r); err != nil {
return nil, nil, fmt.Errorf("%s line %d is not a complete row (truncated or corrupt): %w", batchResultsName, line, err)
}
rows = append(rows, r)
canon, err := canonicalJSON(raw)
if err != nil {
return nil, nil, fmt.Errorf("%s line %d: %w", batchResultsName, line, err)
}
canonical.Write(canon)
canonical.WriteByte('\n')
}
return rows, canonical.Bytes(), nil
}
// auditFigure is one number on both sides: what the manifest claimed and what
// the retained rows give. Unknown is printed as unknown, never as zero.
type auditFigure struct {
name string
claimed string
actual string
note string
moved bool
}
func compareClaims(c batchClaims, s suiteStats) ([]auditFigure, []string) {
rows := []struct {
name string
claimed string
actual string
required bool
}{
{"solved", intText(c.Solved), strconv.Itoa(s.passed), true},
{"pass1", intText(c.Pass1), strconv.Itoa(s.pass1), true},
{"prompt_tokens", intText(c.PromptTokens), strconv.Itoa(s.pTok), false},
{"completion_tokens", intText(c.CompletionTokens), strconv.Itoa(s.cTok), false},
{"cache_hit_tokens", intText(c.CacheHitTokens), strconv.Itoa(s.hit), false},
{"cache_miss_tokens", intText(c.CacheMissTokens), strconv.Itoa(s.miss), false},
{"cost", floatText(c.Cost), floatText(new(s.cost)), false},
{"currency", c.Currency, s.currency, false},
{"accounted", intText(c.Accounted), strconv.Itoa(s.accounted), true},
{"unaccounted", intText(c.Unaccounted), strconv.Itoa(s.unaccounted), true},
{"partial", intText(c.Partial), strconv.Itoa(s.partial), true},
{"tcts_median_ms", int64Text(c.TCTSMedianMs), strconv.FormatInt(median(s.ttcs), 10), false},
}
var figures []auditFigure
var failures []string
for _, row := range rows {
f := auditFigure{name: row.name, claimed: row.claimed, actual: row.actual}
switch {
case row.claimed == "":
f.claimed = "unknown"
f.note = "not claimed"
if row.required {
f.note = "required, absent"
failures = append(failures, row.name+": the manifest claims no "+row.name+"; an absent figure is required here")
}
case row.claimed != row.actual:
f.moved = true
failures = append(failures, fmt.Sprintf("%s: manifest %s, rows %s", row.name, row.claimed, row.actual))
}
figures = append(figures, f)
}
return figures, failures
}
// compareLedger checks the counts the rows can answer. Planned cannot be
// recomputed from rows — it is what the batch set out to do — so it is only
// held to the consistency checks in ledgerInvariants.
func compareLedger(declared, actual batchLedger) ([]auditFigure, []string) {
type entry struct {
name string
claimed *int
replayed *int
}
entries := []entry{
{"planned", declared.Planned, nil},
{"attempted", declared.Attempted, actual.Attempted},
{"completed", declared.Completed, actual.Completed},
{"scored", declared.Scored, actual.Scored},
{"failed", declared.Failed, actual.Failed},
{"unmeasured", declared.Unmeasured, actual.Unmeasured},
{"excluded", declared.Excluded, actual.Excluded},
{"rows", declared.Rows, actual.Rows},
{"skipped", declared.Skipped, actual.Skipped},
}
required := map[string]bool{"planned": true, "attempted": true, "completed": true, "scored": true, "failed": true, "unmeasured": true, "excluded": true}
var figures []auditFigure
var failures []string
for _, e := range entries {
f := auditFigure{name: e.name}
switch {
case e.claimed != nil:
f.claimed = strconv.Itoa(*e.claimed)
default:
f.claimed = "unknown"
f.note = "not claimed"
if required[e.name] {
f.note = "required, absent"
failures = append(failures, "ledger."+e.name+": absent; schema v1 requires this count, and absent is unknown, never zero")
}
}
if e.replayed == nil {
f.actual = "n/a"
f.note = joinNote(f.note, "not recomputable from rows")
} else {
f.actual = strconv.Itoa(*e.replayed)
}
if e.claimed != nil && e.replayed != nil && *e.claimed != *e.replayed {
f.moved = true
failures = append(failures, fmt.Sprintf("ledger.%s: manifest %d, rows %d", e.name, *e.claimed, *e.replayed))
}
figures = append(figures, f)
}
return figures, failures
}
// ledgerInvariants is the denominator guard: the counts must describe a
// funnel, so a manifest cannot publish a numerator larger than its base.
func ledgerInvariants(l batchLedger) []string {
var out []string
check := func(lower, upper *int, lowerName, upperName string) {
if lower == nil || upper == nil || *lower <= *upper {
return
}
out = append(out, fmt.Sprintf("ledger: %s %d exceeds %s %d", lowerName, *lower, upperName, *upper))
}
check(l.Scored, l.Planned, "scored", "planned")
check(l.Completed, l.Attempted, "completed", "attempted")
check(l.Scored, l.Completed, "scored", "completed")
check(l.Failed, l.Scored, "failed", "scored")
check(l.Unmeasured, l.Completed, "unmeasured", "completed")
check(l.Excluded, l.Rows, "excluded", "rows")
check(l.Unmeasured, l.Attempted, "unmeasured", "attempted")
if l.Rows != nil && l.Attempted != nil && l.Skipped != nil && *l.Rows != *l.Attempted+*l.Skipped {
out = append(out, fmt.Sprintf("ledger: rows %d is not attempted %d + skipped %d", *l.Rows, *l.Attempted, *l.Skipped))
}
return out
}
// denominatorChecks ties the published denominator to the ledger and to the
// outcome split, so passes and failures have to add up to it.
func denominatorChecks(m batchManifest, fromRows batchLedger) []string {
var out []string
d := m.Over.Denominator
if d.Unit != denominatorUnit || d.Definition != denominatorDefinition {
out = append(out, "denominator: the manifest defines it as "+strconv.Quote(d.Unit+" / "+d.Definition)+", which this audit does not implement")
}
if fromRows.Scored != nil && d.Value != *fromRows.Scored {
out = append(out, fmt.Sprintf("denominator: manifest %d, rows %d", d.Value, *fromRows.Scored))
}
if m.Trust.Ledger.Scored != nil && d.Value != *m.Trust.Ledger.Scored {
out = append(out, fmt.Sprintf("denominator: manifest %d, ledger.scored %d", d.Value, *m.Trust.Ledger.Scored))
}
if m.Trust.Claimed.Pass1 != nil && m.Trust.Ledger.Failed != nil && m.Trust.Ledger.Scored != nil {
if sum := *m.Trust.Claimed.Pass1 + *m.Trust.Ledger.Failed; sum != *m.Trust.Ledger.Scored {
out = append(out, fmt.Sprintf("ledger: scored %d is not pass1 %d + failed %d", *m.Trust.Ledger.Scored, *m.Trust.Claimed.Pass1, *m.Trust.Ledger.Failed))
}
}
return out
}
// recordAuditReceipt keeps the verifier identity in the manifest, so a later
// re-verification can tell which code checked it.
func recordAuditReceipt(manifestPath string, m batchManifest, hash string) error {
m.Audit = &batchAuditReceipt{
VerifierVersion: auditToolVersion,
VerifiedAt: time.Now().UTC().Format(time.RFC3339),
ResultsSHA256: hash,
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("encode audit receipt: %w", err)
}
if err := os.WriteFile(manifestPath, append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("record audit receipt: %w", err)
}
return nil
}
func renderAudit(dir string, m batchManifest, rows []result, hash string, figures, ledger []auditFigure, failures []string) string {
var b strings.Builder
fmt.Fprintf(&b, "## e2ebench batch audit — `%s`\n\n", filepath.Base(strings.TrimSpace(dir)))
b.WriteString("**What the figures are over**\n\n")
fmt.Fprintf(&b, "- schema: manifest v%d, audited by `%s/%d`\n", m.SchemaVersion, auditToolName, auditToolVersion)
fmt.Fprintf(&b, "- command: `%s` · axes: %s\n", m.Over.Command.Mode, axisText(m.Over.Command.Axes))
fmt.Fprintf(&b, "- task ids: `%s` (%d ids; task text is represented by this digest, not stored)\n", textOrUnknown(m.Over.Tasks.Digest), m.Over.Tasks.Count)
fmt.Fprintf(&b, "- filters: %s\n", filterText(m.Over.Filters))
fmt.Fprintf(&b, "- denominator: **%d %ss** — %s\n", m.Over.Denominator.Value, m.Over.Denominator.Unit, m.Over.Denominator.Definition)
b.WriteString("\n**Which run produced them**\n\n")
fmt.Fprintf(&b, "- commit: `%s` (%s, %s)\n", m.Producer.Commit, sourceText(m.Producer.CommitSource), dirtyText(m.Producer.Dirty))
fmt.Fprintf(&b, "- model: %s · provider: %s\n", nameText(m.Producer.Model), nameText(m.Producer.Provider))
fmt.Fprintf(&b, "- seed: %s\n", seedText(m.Producer.Seed))
fmt.Fprintf(&b, "- started: %s · finished: %s\n", m.Producer.StartedAt, m.Producer.FinishedAt)
fmt.Fprintf(&b, "- harness: %s · runtime %s\n", m.Producer.HarnessVersion, m.Producer.GoVersion)
b.WriteString("\n**Why they can still be trusted**\n\n")
fmt.Fprintf(&b, "- rows retained: %d in `%s`\n", len(rows), batchResultsName)
fmt.Fprintf(&b, "- results_sha256: %s\n", hashText(m.Trust.ResultsSHA256, hash))
if m.Audit != nil {
fmt.Fprintf(&b, "- last verified by `%s/%d` at %s over `%s`\n", auditToolName, m.Audit.VerifierVersion, m.Audit.VerifiedAt, m.Audit.ResultsSHA256)
}
fmt.Fprintf(&b, "- retention: %s\n", textOrUnknown(m.Trust.Retention))
b.WriteString("\n| claimed figure | manifest | rows | |\n|---|---:|---:|---|\n")
writeFigures(&b, figures)
b.WriteString("\n| ledger | manifest | rows | |\n|---|---:|---:|---|\n")
writeFigures(&b, ledger)
b.WriteString("\n")
if len(failures) > 0 {
b.WriteString("**Mismatches**\n\n")
for _, f := range failures {
fmt.Fprintf(&b, "- %s\n", f)
}
b.WriteString("\n")
}
if len(failures) > 0 {
fmt.Fprintf(&b, "**Verdict:** the published figures do **not** follow from the retained rows (%d mismatch(es)).\n\n", len(failures))
} else {
b.WriteString("**Verdict:** the published figures follow from the retained rows.\n\n")
}
b.WriteString("<sub>An aggregation audit, not a reproduction: the model, tools and environment are not re-executed, so this cannot show the experiment would reproduce. A real replay would need the commit, corpus fingerprint, model and provider configuration, seed, flags, raw results and runtime version; it is not implemented.</sub>\n")
return b.String()
}
func writeFigures(b *strings.Builder, figures []auditFigure) {
for _, f := range figures {
note := f.note
if f.moved {
note = joinNote(note, "**moved**")
}
fmt.Fprintf(b, "| %s | %s | %s | %s |\n", f.name, f.claimed, f.actual, note)
}
}
func axisText(axes []batchFlag) string {
if len(axes) == 0 {
return "none recorded"
}
parts := make([]string, len(axes))
for i, a := range axes {
parts[i] = a.Name + "=" + a.Value
}
return "`" + strings.Join(parts, "` · `") + "`"
}
func filterText(f batchFilters) string {
if strings.TrimSpace(f.Task) == "" {
return "none (`-task` unset: every task in the suite)"
}
return "`-task " + f.Task + "`"
}
func sourceText(source string) string {
if source == "" {
return "source unknown"
}
return "source " + source
}
func dirtyText(dirty *bool) string {
if dirty == nil {
return "dirty state unknown"
}
if *dirty {
return "dirty tree: the commit does not identify the code that ran"
}
return "clean tree"
}
// nameText prints an identity name, and says unknown when the field is absent
// rather than printing an empty name that reads as "none".
func nameText(name string) string {
if strings.TrimSpace(name) == "" {
return "unknown (absent from the manifest; absent is not zero)"
}
return "`" + name + "`"
}
func seedText(seed *int64) string {
if seed == nil {
return "unknown (absent from the manifest; an absent seed is not seed 0)"
}
return strconv.FormatInt(*seed, 10)
}
func hashText(claimed, actual string) string {
if claimed == "" {
return "unknown (the manifest claims no digest)"
}
if claimed != actual {
return "**MISMATCH** — manifest `" + claimed + "`, rows `" + actual + "`"
}
return "ok (`" + actual + "`)"
}
func textOrUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "unknown"
}
return s
}
func joinNote(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
default:
return a + "; " + b
}
}
func intText(p *int) string {
if p == nil {
return ""
}
return strconv.Itoa(*p)
}
func int64Text(p *int64) string {
if p == nil {
return ""
}
return strconv.FormatInt(*p, 10)
}
func floatText(p *float64) string {
if p == nil {
return ""
}
return strconv.FormatFloat(*p, 'f', -1, 64)
}
+458
View File
@@ -0,0 +1,458 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"sort"
"strconv"
"strings"
"time"
"reasonix/internal/contract/config"
)
const (
batchSchemaVersion = 1
batchResultsName = "results.jsonl"
batchManifestName = "batch.json"
// auditToolVersion is the version of the code below; every successful
// audit stamps it into the manifest, so a later reader can tell which
// verifier checked the batch.
auditToolVersion = 1
auditToolName = "e2ebench-audit"
// batchScope names the claim: an aggregation audit, not a replay.
batchScope = "aggregation audit: the claimed figures are recomputed from the retained rows; the model, tools and environment are not re-executed, so this is not a reproduction of the experiment"
// denominatorUnit and denominatorDefinition say what pass@1 divides by.
denominatorUnit = "task"
denominatorDefinition = "first attempts of solvable, non-skipped tasks: pass@1 divides passes by this count, never by the retained row count"
// batchRetention is the one line a reader needs before sharing the batch.
batchRetention = "results.jsonl is retained unredacted beside this manifest (task prompts and agent notes included) and is not public; the manifest carries a task-id digest, no prompt text, no absolute paths, no endpoints or keys and no model output"
)
// batchOver is what the figures are over: the command, the task set identity,
// the filters, and the denominator the accuracy is divided by.
type batchOver struct {
Command batchCommand `json:"command"`
Tasks batchTaskSet `json:"tasks"`
Filters batchFilters `json:"filters"`
Denominator batchDenominator `json:"denominator"`
}
// batchCommand records the mode and every flag that changes what a figure
// means. Output paths are deliberately absent: they are not evidence, and an
// absolute path is the operator's, not the batch's.
type batchCommand struct {
Mode string `json:"mode"`
Axes []batchFlag `json:"axes"`
}
type batchFlag struct {
Name string `json:"name"`
Value string `json:"value"`
}
// batchTaskSet identifies the corpus without carrying its text: a stable
// digest over the sorted task ids and how many there were.
type batchTaskSet struct {
Count int `json:"count"`
Digest string `json:"digest"`
}
type batchFilters struct {
// Task is -task as given; empty means no filter.
Task string `json:"task,omitempty"`
}
// batchDenominator names the unit pass@1 divides by, so a row count can never
// stand in for it.
type batchDenominator struct {
Unit string `json:"unit"`
Definition string `json:"definition"`
Value int `json:"value"`
}
// batchProducer is which run produced the figures. Absent optional fields are
// unknown, never zero: a missing seed is not seed 0.
type batchProducer struct {
Commit string `json:"commit"`
CommitSource string `json:"commit_source,omitempty"`
Dirty *bool `json:"dirty,omitempty"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
Seed *int64 `json:"seed,omitempty"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
HarnessVersion string `json:"harness_version"`
GoVersion string `json:"go_version"`
}
// batchTrust is why the figures can still be checked: the digest of the rows,
// the headline figures as published, and the ledger every planned run is
// accounted for in.
type batchTrust struct {
ResultsSHA256 string `json:"results_sha256"`
Claimed batchClaims `json:"claimed"`
Ledger batchLedger `json:"ledger"`
Retention string `json:"retention"`
}
// batchClaims are the headline figures the report publishes. A figure the run
// could not measure is absent, not zero.
type batchClaims struct {
Solved *int `json:"solved"`
Pass1 *int `json:"pass1"`
PromptTokens *int `json:"prompt_tokens,omitempty"`
CompletionTokens *int `json:"completion_tokens,omitempty"`
CacheHitTokens *int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens *int `json:"cache_miss_tokens,omitempty"`
Cost *float64 `json:"cost,omitempty"`
Currency string `json:"currency,omitempty"`
Accounted *int `json:"accounted"`
Unaccounted *int `json:"unaccounted"`
Partial *int `json:"partial"`
TCTSMedianMs *int64 `json:"tcts_median_ms,omitempty"`
}
// batchLedger accounts for every planned run. Rows is the retained record, not
// a denominator; Scored is the denominator.
type batchLedger struct {
Planned *int `json:"planned"`
Attempted *int `json:"attempted"`
Completed *int `json:"completed"`
Scored *int `json:"scored"`
Failed *int `json:"failed"`
Unmeasured *int `json:"unmeasured"`
Excluded *int `json:"excluded"`
Rows *int `json:"rows,omitempty"`
Skipped *int `json:"skipped,omitempty"`
Definitions map[string]string `json:"definitions,omitempty"`
}
// batchAuditReceipt is what a successful audit leaves behind.
type batchAuditReceipt struct {
VerifierVersion int `json:"verifier_version"`
VerifiedAt string `json:"verified_at"`
ResultsSHA256 string `json:"results_sha256"`
}
// batchManifest is written beside results.jsonl so the report never has to be
// reconstructed from a terminal scrollback.
type batchManifest struct {
SchemaVersion int `json:"schema_version"`
Scope string `json:"scope"`
Over batchOver `json:"over"`
Producer batchProducer `json:"producer"`
Trust batchTrust `json:"trust"`
Audit *batchAuditReceipt `json:"audit,omitempty"`
}
// ledgerDefinitions explains each count in the manifest itself, so the
// denominator cannot be reinterpreted after the fact.
var ledgerDefinitions = map[string]string{
"planned": "graded runs the batch set out to attempt: filtered tasks x trials",
"attempted": "retained rows for runs that started (skipped rows are not attempts)",
"completed": "attempts that ended and left a row; this harness writes a row only after an attempt ends",
"scored": "completed first attempts of solvable, non-skipped tasks: the pass@1 denominator",
"failed": "scored attempts the grader did not pass",
"unmeasured": "attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals",
"excluded": "rows kept out of the accuracy denominator: skipped, no_solution, and retry entries",
"rows": "rows retained in results.jsonl; a record of what was kept, never the denominator",
"skipped": "planned runs deliberately not started (anchor has no seed, or the token budget ran out)",
}
// batchProvenance is what the batch knows before any run happens. Everything
// else in the manifest is derived from the rows that were written.
type batchProvenance struct {
dir string
started time.Time
planned int
axes []batchFlag
tasks batchTaskSet
filters batchFilters
commit string
dirty *bool
model string
provider string
}
// captureBatchProvenance reads the checkout and model identity before the
// batch runs: git cannot describe a tree the agent has since changed.
func captureBatchProvenance(dir string, cfg suiteConfig, tasks []task, taskFilter string) batchProvenance {
commit, dirty := checkoutCommit(".")
model, provider := modelIdentity(cfg.model)
count, digest := taskSetDigest(tasks)
return batchProvenance{
dir: dir, started: time.Now().UTC(), planned: count * max(cfg.trials, 1),
axes: batchAxes(cfg), tasks: batchTaskSet{Count: count, Digest: digest},
filters: batchFilters{Task: taskFilter},
commit: commit, dirty: dirty, model: model, provider: provider,
}
}
// writeBatch writes the rows first and the manifest second, so a manifest
// never describes rows that are not beside it.
func writeBatch(p batchProvenance, results []result) error {
if err := os.MkdirAll(p.dir, 0o750); err != nil {
return fmt.Errorf("create batch directory: %w", err)
}
var raw, canonical bytes.Buffer
for _, r := range results {
line, err := json.Marshal(r)
if err != nil {
return fmt.Errorf("encode row: %w", err)
}
raw.Write(line)
raw.WriteByte('\n')
canon, err := canonicalJSON(line)
if err != nil {
return err
}
canonical.Write(canon)
canonical.WriteByte('\n')
}
// Rows carry task prompts and agent notes, so they stay the operator's.
if err := os.WriteFile(filepath.Join(p.dir, batchResultsName), raw.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", batchResultsName, err)
}
manifest := buildBatchManifest(p, results, canonical.Bytes())
b, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encode manifest: %w", err)
}
if err := os.WriteFile(filepath.Join(p.dir, batchManifestName), append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("write %s: %w", batchManifestName, err)
}
return nil
}
func buildBatchManifest(p batchProvenance, results []result, canonical []byte) batchManifest {
stats := gatherSuiteStats(results)
harness, goVersion := harnessIdentity()
source := ""
if p.commit != "unknown" {
source = "checkout"
}
return batchManifest{
SchemaVersion: batchSchemaVersion,
Scope: batchScope,
Over: batchOver{
Command: batchCommand{Mode: "suite", Axes: p.axes},
Tasks: p.tasks,
Filters: p.filters,
Denominator: batchDenominator{Unit: denominatorUnit, Definition: denominatorDefinition, Value: stats.ran},
},
Producer: batchProducer{
Commit: p.commit, CommitSource: source, Dirty: p.dirty,
Model: p.model, Provider: p.provider,
StartedAt: p.started.Format(time.RFC3339), FinishedAt: time.Now().UTC().Format(time.RFC3339),
HarnessVersion: harness, GoVersion: goVersion,
},
Trust: batchTrust{
ResultsSHA256: hashOf(canonical),
Claimed: claimsFor(stats),
Ledger: ledgerFor(results, p.planned),
Retention: batchRetention,
},
}
}
// claimsFor publishes the report's headline figures. Without a single
// accounted run there is no measured spend: those fields stay absent.
func claimsFor(s suiteStats) batchClaims {
c := batchClaims{
Solved: new(s.passed), Pass1: new(s.pass1),
Accounted: new(s.accounted), Unaccounted: new(s.unaccounted), Partial: new(s.partial),
}
if s.accounted > 0 {
c.PromptTokens, c.CompletionTokens = new(s.pTok), new(s.cTok)
c.Cost, c.Currency = new(s.cost), s.currency
if s.hit+s.miss > 0 {
c.CacheHitTokens, c.CacheMissTokens = new(s.hit), new(s.miss)
}
}
if s.passed > 0 {
c.TCTSMedianMs = new(median(s.ttcs))
}
return c
}
// ledgerFor accounts for every planned run. A retry is a row, not a planned
// unit: the plan is tasks x trials, which is why Scored and Attempted differ.
func ledgerFor(results []result, planned int) batchLedger {
l := ledgerFromRows(results)
l.Planned = new(planned)
l.Definitions = ledgerDefinitions
return l
}
func ledgerFromRows(results []result) batchLedger {
var attempted, completed, scored, failed, unmeasured, excluded, skipped int
for _, r := range results {
inDenominator := !r.Skipped && !r.NoSolution && r.Attempt <= 1
if r.Skipped {
skipped++
excluded++
continue
}
attempted++
completed++
if r.Unaccounted {
unmeasured++
}
if !inDenominator {
excluded++
continue
}
scored++
if !r.Passed {
failed++
}
}
return batchLedger{
Attempted: new(attempted), Completed: new(completed), Scored: new(scored),
Failed: new(failed), Unmeasured: new(unmeasured), Excluded: new(excluded),
Rows: new(len(results)), Skipped: new(skipped),
}
}
// canonicalJSON is what results_sha256 covers: line endings, indentation and
// key order are dropped because none of them is evidence, while a changed value
// or a missing row still changes the digest. Number spelling is kept, so a
// rewritten 1 is not silently equal to 1.0.
func canonicalJSON(line []byte) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(line))
dec.UseNumber()
var row any
if err := dec.Decode(&row); err != nil {
return nil, fmt.Errorf("row is not valid JSON: %w", err)
}
return json.Marshal(row)
}
func hashOf(data []byte) string {
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// checkoutCommit reports the checkout the batch runs from. Unknown is written
// explicitly rather than guessed: a directory git cannot describe is not a
// commit, and a dirty tree is not the commit it names.
func checkoutCommit(dir string) (string, *bool) {
commit := "unknown"
out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()
if err == nil {
if sha := strings.TrimSpace(string(out)); sha != "" {
commit = sha
}
}
var dirty *bool
out, err = exec.Command("git", "-C", dir, "status", "--porcelain").Output()
if err == nil {
state := strings.TrimSpace(string(out)) != ""
dirty = &state
}
return commit, dirty
}
// modelIdentity resolves the model and provider names the agent will run
// under. Names only: no endpoint, key or credential reaches the manifest.
func modelIdentity(flagModel string) (model, provider string) {
ref := strings.TrimSpace(flagModel)
cfg, err := config.Load()
if err == nil && cfg != nil {
if resolved, _, ok := cfg.ResolveModelWithFallback(ref); ok {
if name, rest, found := strings.Cut(resolved, "/"); found {
return rest, name
}
return resolved, ""
}
if ref == "" {
ref = strings.TrimSpace(cfg.DefaultModel)
}
}
if name, rest, found := strings.Cut(ref, "/"); found {
return rest, name
}
return ref, ""
}
func harnessIdentity() (string, string) {
version := "(devel)"
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" {
version = bi.Main.Version
}
return version, runtime.Version()
}
// taskSetDigest identifies the corpus by sorted id, not by name order: a
// reordered -task list is the same set. Ids are NUL-separated because a path
// name cannot contain NUL, so no two id sets share a digest string.
func taskSetDigest(tasks []task) (int, string) {
ids := make([]string, len(tasks))
for i, t := range tasks {
ids[i] = t.ID
}
sort.Strings(ids)
sum := sha256.Sum256([]byte(strings.Join(ids, "\x00")))
return len(ids), "sha256:" + hex.EncodeToString(sum[:])
}
// batchAxes lists the flags that change what a figure means, with their
// effective values. Paths and free text stay out: -steer records its count.
func batchAxes(cfg suiteConfig) []batchFlag {
tape := "off"
switch cfg.tape.mode {
case tapeRecord:
tape = "record"
case tapeReplay:
tape = "replay"
}
return []batchFlag{
{"profile", cfg.profile},
{"ablate", cfg.arm.Arm()},
{"cache", cfg.cacheArm},
{"anchor", cfg.anchor},
{"effort", valueOr(cfg.effort, "default")},
{"policy", valueOr(cfg.policy, "baseline")},
{"force-planner", formatBool(cfg.forcePlanner)},
{"checkpoints", formatBool(cfg.checkpoints)},
{"attempts", strconv.Itoa(max(cfg.attempts, 1))},
{"trials", strconv.Itoa(max(cfg.trials, 1))},
{"budget", strconv.Itoa(cfg.budget)},
{"segments", strconv.Itoa(max(cfg.segments, 1))},
{"steers", strconv.Itoa(len(cfg.steers))},
{"meter", onOff(cfg.meterConfig != "")},
{"faults", onOff(!cfg.meterFaults.empty())},
{"tape", tape},
{"reply-language", valueOr(cfg.replyLanguage, "auto")},
}
}
func valueOr(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return v
}
func formatBool(v bool) string {
if v {
return "true"
}
return "false"
}
func onOff(v bool) string {
if v {
return "on"
}
return "off"
}
+519
View File
@@ -0,0 +1,519 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
// a retry. Numbers are chosen so every headline figure is non-zero and the
// ledger has one of each interesting category.
func fixtureRows() []result {
return []result{
{
task: task{ID: "task-a", Prompt: "fixture prompt a"},
runMetrics: runMetrics{PromptTokens: 1000, CompletionTokens: 100, CacheHitTokens: 600, CacheMissTokens: 400, Cost: 0.01, Currency: "USD"},
Attempt: 1, Passed: true, WallMs: 1200, TTCSMs: 1200,
},
{
task: task{ID: "task-b", Prompt: "fixture prompt b"},
runMetrics: runMetrics{PromptTokens: 2000, CompletionTokens: 200, CacheHitTokens: 1500, CacheMissTokens: 500, Cost: 0.02, Currency: "USD"},
Attempt: 1, Passed: true, WallMs: 800, TTCSMs: 800,
},
{
task: task{ID: "task-c", Prompt: "fixture prompt c"},
runMetrics: runMetrics{PromptTokens: 3000, CompletionTokens: 300, CacheHitTokens: 0, CacheMissTokens: 3000, Cost: 0.03, Currency: "USD"},
Attempt: 1, Passed: false, WallMs: 500,
},
{
task: task{ID: "task-c"},
runMetrics: runMetrics{PromptTokens: 500, CompletionTokens: 50, Cost: 0.005, Currency: "USD"},
Attempt: 2, Passed: true, WallMs: 400, TTCSMs: 900,
},
}
}
func fixtureProvenance(dir string) batchProvenance {
count, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
return batchProvenance{
dir: dir,
started: timeAt("2026-10-28T00:00:00Z"),
planned: count,
axes: []batchFlag{{"profile", "baseline"}, {"trials", "1"}},
tasks: batchTaskSet{Count: count, Digest: digest},
commit: "0123456789abcdef0123456789abcdef01234567",
dirty: new(false),
model: "deepseek-flash",
}
}
func timeAt(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
t.Helper()
dir := t.TempDir()
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
t.Fatalf("write batch: %v", err)
}
return dir, readManifest(t, dir)
}
func readManifest(t *testing.T, dir string) batchManifest {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, batchManifestName))
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var m batchManifest
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("decode manifest: %v", err)
}
return m
}
func writeManifest(t *testing.T, dir string, m batchManifest) {
t.Helper()
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
t.Fatalf("encode manifest: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, batchManifestName), append(data, '\n'), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
}
// writeRows rewrites results.jsonl without touching the manifest, which is what
// a tampered batch looks like.
func writeRows(t *testing.T, dir string, rows []result) {
t.Helper()
var b bytes.Buffer
for _, r := range rows {
line, err := json.Marshal(r)
if err != nil {
t.Fatalf("encode row: %v", err)
}
b.Write(line)
b.WriteByte('\n')
}
if err := os.WriteFile(filepath.Join(dir, batchResultsName), b.Bytes(), 0o600); err != nil {
t.Fatalf("write rows: %v", err)
}
}
// A batch written by the producer audits clean: the figures the manifest
// published are the figures the retained rows give.
func TestBatchAuditRoundTrip(t *testing.T) {
dir, m := writeFixtureBatch(t)
if m.SchemaVersion != batchSchemaVersion || m.Scope == "" {
t.Fatalf("manifest schema = %d scope = %q, want v%d with a named claim", m.SchemaVersion, m.Scope, batchSchemaVersion)
}
if m.Over.Denominator.Value != 3 || m.Over.Denominator.Unit != denominatorUnit || m.Over.Denominator.Definition != denominatorDefinition {
t.Fatalf("denominator = %+v, want 3 tasks under the harness definition", m.Over.Denominator)
}
if m.Producer.Commit != "0123456789abcdef0123456789abcdef01234567" || m.Producer.CommitSource != "checkout" {
t.Fatalf("producer = %+v, want the checkout commit recorded", m.Producer)
}
if m.Trust.Ledger.Planned == nil || *m.Trust.Ledger.Planned != 3 {
t.Fatalf("planned = %v, want 3", m.Trust.Ledger.Planned)
}
if m.Trust.Ledger.Scored == nil || *m.Trust.Ledger.Scored != 3 {
t.Fatalf("scored = %v, want 3", m.Trust.Ledger.Scored)
}
if m.Trust.Ledger.Attempted == nil || *m.Trust.Ledger.Attempted != 4 || m.Trust.Ledger.Excluded == nil || *m.Trust.Ledger.Excluded != 1 {
t.Fatalf("ledger attempted/excluded = %v/%v, want 4/1", m.Trust.Ledger.Attempted, m.Trust.Ledger.Excluded)
}
// The manifest must not carry the raw task text the rows legitimately do.
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("fixture prompt")) {
t.Fatalf("the manifest carries raw task text:\n%s", raw)
}
out, err := auditBatch(dir)
if err != nil {
t.Fatalf("a producer-written batch did not audit: %v\n%s", err, out)
}
for _, want := range []string{
"| solved | 3 | 3 |",
"| pass1 | 2 | 2 |",
"| prompt_tokens | 6500 | 6500 |",
"| cost | 0.065 | 0.065 |",
"| scored | 3 | 3 |",
"| failed | 1 | 1 |",
"| planned | 3 | n/a |",
"results_sha256: ok",
"the published figures follow from the retained rows.",
"aggregation audit, not a reproduction",
} {
if !strings.Contains(out, want) {
t.Fatalf("audit output is missing %q:\n%s", want, out)
}
}
receipt := readManifest(t, dir).Audit
if receipt == nil || receipt.VerifierVersion != auditToolVersion || receipt.ResultsSHA256 != m.Trust.ResultsSHA256 {
t.Fatalf("audit receipt = %+v, want verifier v%d over the same digest", receipt, auditToolVersion)
}
}
// The axes carry what changes a figure's meaning, never the operator's machine:
// meter/tape/trajectory paths and steer text stay out of the manifest.
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
dir := t.TempDir()
prov := fixtureProvenance(dir)
prov.axes = batchAxes(suiteConfig{
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
attempts: 1, trials: 2, budget: 0, segments: 3,
meterConfig: "/Users/someone/.reasonix/config.toml",
meterFaults: faultScript{at: map[int]int{3: 429}},
tape: tapeConfig{mode: tapeRecord, root: "/tmp/tapes"},
steers: map[int]string{2: "secret steer text"},
trajDir: "/tmp/traj",
})
if err := writeBatch(prov, fixtureRows()); err != nil {
t.Fatalf("write batch: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"/Users", "/tmp", "secret steer text", ".reasonix"} {
if bytes.Contains(raw, []byte(forbidden)) {
t.Fatalf("the manifest carries %q:\n%s", forbidden, raw)
}
}
axes := map[string]string{}
for _, a := range readManifest(t, dir).Over.Command.Axes {
axes[a.Name] = a.Value
}
want := map[string]string{
"profile": "delivery", "effort": "high", "trials": "2", "segments": "3",
"meter": "on", "faults": "on", "tape": "record", "steers": "1", "ablate": "full",
}
for name, value := range want {
if axes[name] != value {
t.Fatalf("axis %s = %q, want %q (axes: %v)", name, axes[name], value, axes)
}
}
}
// The corpus digest is a set digest: order must not move it, and ids that only
// differ by a separator must not collide.
func TestTaskSetDigestIsSeparatedAndOrderIndependent(t *testing.T) {
n1, d1 := taskSetDigest([]task{{ID: "b"}, {ID: "a"}})
n2, d2 := taskSetDigest([]task{{ID: "a"}, {ID: "b"}})
if n1 != 2 || n2 != 2 || d1 != d2 {
t.Fatalf("order moved the corpus digest: %d/%s vs %d/%s", n1, d1, n2, d2)
}
_, joined := taskSetDigest([]task{{ID: "a\nb"}})
if joined == d1 {
t.Fatal("a newline inside one id collides with two ids")
}
}
// One token count moved after publication: the audit must fail and name the
// figure that moved, not just say the digest differs.
func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
dir, m := writeFixtureBatch(t)
tampered := fixtureRows()
tampered[0].PromptTokens += 1000
writeRows(t, dir, tampered)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a tampered row audited clean:\n%s", out)
}
for _, want := range []string{
"prompt_tokens: manifest 6500, rows 7500",
"| prompt_tokens | 6500 | 7500 | **moved** |",
"MISMATCH",
"the published figures do **not** follow from the retained rows",
} {
if !strings.Contains(out, want) {
t.Fatalf("audit output is missing %q:\n%s", want, out)
}
}
if readManifest(t, dir).Audit != nil {
t.Fatal("a failed audit recorded a verification receipt")
}
if m.Trust.ResultsSHA256 == "" {
t.Fatal("fixture manifest carries no digest")
}
}
// The three ways a batch can be unauditable are three different messages: no
// manifest, no commit to tie the figures to, and rows cut short.
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
t.Run("missing manifest", func(t *testing.T) {
dir := t.TempDir()
writeRows(t, dir, fixtureRows())
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("rows without a manifest audited clean:\n%s", out)
}
if !strings.Contains(err.Error(), "no batch.json") || !strings.Contains(err.Error(), "kept no manifest") {
t.Fatalf("missing-manifest error = %v", err)
}
})
t.Run("unknown commit", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Producer.Commit = "unknown"
m.Producer.CommitSource = ""
writeManifest(t, dir, m)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a manifest with an unknown commit audited clean:\n%s", out)
}
if !strings.Contains(err.Error(), "records no commit") {
t.Fatalf("no-commit error = %v", err)
}
})
t.Run("absent commit", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Producer.Commit = ""
writeManifest(t, dir, m)
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "records no commit") {
t.Fatalf("an absent commit answered %v, want the no-commit refusal", err)
}
})
t.Run("truncated rows", func(t *testing.T) {
dir, _ := writeFixtureBatch(t)
path := filepath.Join(dir, batchResultsName)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data[:len(data)-40], 0o600); err != nil {
t.Fatal(err)
}
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("truncated rows audited clean:\n%s", out)
}
if !strings.Contains(err.Error(), "truncated") {
t.Fatalf("truncated-rows error = %v", err)
}
})
t.Run("half a row with a newline", func(t *testing.T) {
dir, _ := writeFixtureBatch(t)
path := filepath.Join(dir, batchResultsName)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
cut := bytes.LastIndexByte(data[:len(data)-1], '\n')
if cut < 0 {
t.Fatal("fixture has fewer than two rows")
}
broken := append(append([]byte{}, data[:cut+1]...), data[cut+1:len(data)-20]...)
broken = append(broken, '\n')
if err := os.WriteFile(path, broken, 0o600); err != nil {
t.Fatal(err)
}
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "not a complete row") {
t.Fatalf("a half-written row answered %v, want a truncation refusal", err)
}
})
}
// manifestWithoutOptionals is pinned JSON, not a re-marshal of the writer's
// struct: seed, provider, dirty and cost are absent, and the audit must read
// each as unknown rather than as zero. This is the record the contract is
// about, so it is written out in full.
const manifestWithoutOptionals = `{
"schema_version": 1,
"scope": %q,
"over": {
"command": {"mode": "suite", "axes": [{"name": "profile", "value": "baseline"}, {"name": "trials", "value": "1"}]},
"tasks": {"count": 3, "digest": %q},
"denominator": {"unit": "task", "definition": %q, "value": 3}
},
"producer": {
"commit": "0123456789abcdef0123456789abcdef01234567",
"commit_source": "checkout",
"model": "deepseek-flash",
"started_at": "2026-10-28T00:00:00Z",
"finished_at": "2026-10-28T00:10:00Z",
"harness_version": "(devel)",
"go_version": "go1.26.5"
},
"trust": {
"results_sha256": %q,
"claimed": {
"solved": 3,
"pass1": 2,
"prompt_tokens": 6500,
"completion_tokens": 650,
"cache_hit_tokens": 2100,
"cache_miss_tokens": 3900,
"accounted": 4,
"unaccounted": 0,
"partial": 0,
"tcts_median_ms": 900
},
"ledger": {
"planned": 3,
"attempted": 4,
"completed": 4,
"scored": 3,
"failed": 1,
"unmeasured": 0,
"excluded": 1,
"rows": 4,
"skipped": 0
},
"retention": %q
}
}
`
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
dir := t.TempDir()
writeRows(t, dir, fixtureRows())
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
if err != nil {
t.Fatal(err)
}
_, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
manifest := fmt.Sprintf(manifestWithoutOptionals, batchScope, digest, denominatorDefinition, hashOf(canonical), batchRetention)
if err := os.WriteFile(filepath.Join(dir, batchManifestName), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
out, err := auditBatch(dir)
if err != nil {
t.Fatalf("a manifest with only optional fields absent did not audit: %v\n%s", err, out)
}
for _, want := range []string{
"seed: unknown (absent from the manifest; an absent seed is not seed 0)",
"provider: unknown (absent from the manifest; absent is not zero)",
"| cost | unknown | 0.065 | not claimed |",
} {
if !strings.Contains(out, want) {
t.Fatalf("audit output is missing %q:\n%s", want, out)
}
}
for _, forbidden := range []string{"seed: 0", "| cost | 0 |", "provider ``"} {
if strings.Contains(out, forbidden) {
t.Fatalf("an absent field read as %q:\n%s", forbidden, out)
}
}
}
// The digest covers values, not spelling: CRLF line endings and reordered keys
// are the same rows, so the same manifest still audits.
func TestBatchDigestIgnoresLineEndingsAndKeyOrder(t *testing.T) {
dir, m := writeFixtureBatch(t)
path := filepath.Join(dir, batchResultsName)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var rewritten bytes.Buffer
for line := range bytes.SplitSeq(data, []byte("\n")) {
if len(line) == 0 {
continue
}
rewritten.Write(reorderKeys(t, line))
}
if !bytes.Contains(rewritten.Bytes(), []byte("\r\n")) {
t.Fatal("fixture rewrite did not use CRLF")
}
if bytes.Equal(rewritten.Bytes(), data) {
t.Fatal("fixture rewrite left the bytes unchanged, so it proves nothing")
}
if err := os.WriteFile(path, rewritten.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
_, canonical, err := readBatch(path)
if err != nil {
t.Fatalf("re-spelled rows did not parse: %v", err)
}
if got := hashOf(canonical); got != m.Trust.ResultsSHA256 {
t.Fatalf("digest moved with spelling: %s, want %s", got, m.Trust.ResultsSHA256)
}
if out, err := auditBatch(dir); err != nil {
t.Fatalf("re-spelled rows did not audit: %v\n%s", err, out)
}
}
// reorderKeys emits the row with its keys in reverse order and a CRLF ending:
// both differences are outside the values results_sha256 covers.
func reorderKeys(t *testing.T, line []byte) []byte {
t.Helper()
var obj map[string]json.RawMessage
if err := json.Unmarshal(line, &obj); err != nil {
t.Fatalf("decode row: %v", err)
}
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Sort(sort.Reverse(sort.StringSlice(keys)))
var b bytes.Buffer
b.WriteByte('{')
for i, k := range keys {
if i > 0 {
b.WriteByte(',')
}
key, err := json.Marshal(k)
if err != nil {
t.Fatal(err)
}
b.Write(key)
b.WriteByte(':')
b.Write(obj[k])
}
b.WriteString("}\r\n")
return b.Bytes()
}
// A ledger is a funnel. Counts that cannot all be true together are rejected,
// so the published denominator cannot be larger than the runs behind it.
func TestBatchAuditRejectsALedgerThatDoesNotAddUp(t *testing.T) {
t.Run("scored above completed", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Trust.Ledger.Scored = new(5)
writeManifest(t, dir, m)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a ledger with scored > completed audited clean:\n%s", out)
}
if !strings.Contains(out, "ledger: scored 5 exceeds completed 4") {
t.Fatalf("audit output does not name the broken invariant:\n%s", out)
}
})
t.Run("denominator is not passes plus failures", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Trust.Ledger.Failed = new(0)
writeManifest(t, dir, m)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a ledger whose denominator is not pass1+failed audited clean:\n%s", out)
}
if !strings.Contains(out, "ledger: scored 3 is not pass1 2 + failed 0") {
t.Fatalf("audit output does not name the denominator split:\n%s", out)
}
})
}
+22 -15
View File
@@ -233,20 +233,9 @@ func (r result) class() string {
const defaultSuiteTokenBudget = 800_000
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
flag.Usage = printUsage
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve")
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve | audit")
addr := flag.String("addr", "127.0.0.1:7480", "serve mode: live dashboard listen address")
subset := flag.String("subset", "benchmarks/swebench/subset.json", "swebench mode: instance subset file")
reportIn := flag.String("report", "", "sft mode: the run report (-json output) whose grader verdicts decide which runs are exported")
@@ -276,6 +265,7 @@ func main() {
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
batchDir := flag.String("batch", "", "suite mode: write results.jsonl (one row per run) and batch.json (manifest v1) here, so a later -mode audit can re-derive the report's figures; audit mode: read the batch from here")
budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
// diff-mode flags
repo := flag.String("repo", ".", "repo root (diff mode)")
@@ -298,6 +288,10 @@ func main() {
os.Exit(2)
}
profile, arm, cache, anchor := axes.profile, axes.arm, axes.cache, axes.anchor
if *batchDir != "" && *mode != "suite" && *mode != "audit" {
fmt.Fprintln(os.Stderr, "e2ebench: -batch is written by -mode suite and read by -mode audit; -mode "+*mode+" does neither")
os.Exit(2)
}
if *mode == "swebench" {
if _, err := permissionFlag(*permission); err != nil {
@@ -321,6 +315,7 @@ func main() {
if dispatchOfflineMode(*mode, offlineModeArgs{
trajDir: *trajDir, suite: *suite, reportIn: *reportIn, outMD: *outMD, outJSON: *outJSON, addr: *addr,
batchDir: *batchDir,
}) {
return
}
@@ -342,10 +337,10 @@ func main() {
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
replyLanguage: agentReplyLanguage(),
}, *suite, *taskFilter, *outMD, *outJSON)
}, *suite, *taskFilter, *outMD, *outJSON, *batchDir)
}
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON, batchDir string) {
tasks, err := loadTasks(suite)
if err != nil {
fmt.Fprintln(os.Stderr, "load suite:", err)
@@ -363,7 +358,19 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
os.Exit(2)
}
// The provenance is captured before the first run: the commit a run ends
// on is not necessarily the commit it started from.
var batch batchProvenance
if batchDir != "" {
batch = captureBatchProvenance(batchDir, cfg, tasks, taskFilter)
}
results := runSuite(cfg, tasks)
if batchDir != "" {
if err := writeBatch(batch, results); err != nil {
fmt.Fprintln(os.Stderr, "write batch:", err)
os.Exit(1)
}
}
report := render(results)
if outMD != "" {
+3
View File
@@ -58,6 +58,7 @@ type offlineModeArgs struct {
outMD string
outJSON string
addr string
batchDir string
}
// dispatchOfflineMode runs mode if it is one of the offline ones and reports
@@ -82,6 +83,8 @@ func dispatchOfflineMode(mode string, a offlineModeArgs) bool {
emit(report, a.outMD, "")
case "sft":
fail("sft", runSFTMode(a.trajDir, a.suite, a.reportIn, a.outMD))
case "audit":
fail("audit", runAuditMode(a.batchDir, a.outMD))
case "serve":
fail("serve", runServeMode(a.trajDir, a.suite, a.addr))
default:
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"flag"
"fmt"
"strings"
)
// printUsage is the harness's help text. It names one thing -mode audit does
// not do — replay the experiment — and what a replay would need instead.
func printUsage() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Keep a batch's rows and manifest, then audit its figures against them:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -batch runs/today\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode audit -batch runs/today\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "Auditing is not replaying: -mode audit re-derives the report's headline figures\n"+
"from results.jsonl and checks them against batch.json, naming every figure that moved.\n"+
"It never re-executes the model, tools or environment, so it cannot show the experiment\n"+
"reproduces. A real replay would need the commit, corpus fingerprint, model and provider\n"+
"configuration, seed, flags, raw results and runtime version; it is not implemented.\n")
}
+19
View File
@@ -72,6 +72,25 @@ go run ./cmd/reasonix serve # from the repository root
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
```
## Messages sent during a run
**Settings → Appearance → Keyboard shortcuts → Messages sent while running** chooses the default:
- **Steer at the next tool boundary** (default) adds guidance to the current run.
- **Send after the whole run** queues a new turn until the active run finishes.
The choice is local to this device's browser profile, applies across projects,
and survives reloads. It does not change idle sends or messages already queued.
Skill invocations continue to use follow-up turns.
The queue still supports hold/resume, editing, and taking a message back into
the composer. **Send now** overrides the wait for a selected message by stopping
the current response and starting that message as a new turn; it is unavailable
while the queue is held.
Guidance not delivered before a run ends still becomes a follow-up through the
kernel's existing queue.
## Global show/hide shortcut
In the desktop shell, **Settings → Appearance → Global show / hide shortcut**
+74
View File
@@ -121,3 +121,77 @@
${EndIf}
Pop $R9
!macroend
# Closing a running copy before an install, an update or an uninstall. The
# uninstaller runs from inside the install folder, so the process counting skips
# this process by id: matching on the folder alone would find the uninstaller
# itself and end it.
!macro rxScript
FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w
FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n"
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n"
FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n"
FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n"
FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
FileClose $0
!macroend
!macro rxFind
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" find $R2`
Pop $R0
!macroend
!macro rxKill
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" kill $R2`
Pop $0
!macroend
!macro rxKillElevated
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -C "Start-Process -FilePath powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$PLUGINSDIR\rxclose.ps1','$INSTDIR','kill','$R2' -Verb RunAs -Wait -WindowStyle Hidden"`
Pop $0
!macroend
!macro customCheckAppRunning
InitPluginsDir
System::Call 'kernel32::GetCurrentProcessId() i .R2'
!insertmacro rxScript
!insertmacro rxFind
${if} $R0 == 0
${ifNot} ${isUpdated}
${andIfNot} ${Silent}
DetailPrint "$(appClosing)"
${endIf}
StrCpy $R1 0
rxCloseLoop:
IntOp $R1 $R1 + 1
!insertmacro rxKill
Sleep 1000
!insertmacro rxFind
${if} $R0 != 0
Goto rxClosed
${endIf}
${if} $R1 == 4
${andIfNot} ${Silent}
DetailPrint "Asking Windows for permission to close the elevated copy of ${PRODUCT_NAME}..."
!insertmacro rxKillElevated
Sleep 1000
!insertmacro rxFind
${if} $R0 != 0
Goto rxClosed
${endIf}
${endIf}
${if} $R1 < 6
Goto rxCloseLoop
${endIf}
StrCmp $LANGUAGE 2052 rxZh
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} is still running and the installer could not close it.$\r$\n$\r$\nRight-click its icon in the system tray (click the ^ if it is hidden) and choose Quit, or press Ctrl+Shift+Esc, end every process named $\"${PRODUCT_NAME}$\" (run Task Manager as administrator if the app was started as administrator), then click Retry." /SD IDCANCEL IDRETRY rxRetry
Quit
rxZh:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} 仍在运行,安装程序无法自动关闭它。$\r$\n$\r$\n请在任务栏右下角的托盘图标(看不到就点 ^)上右键,选择“退出”;或按 Ctrl+Shift+Esc 打开任务管理器,结束所有名为“${PRODUCT_NAME}”的进程(如果它是以管理员身份启动的,请用管理员身份打开任务管理器),然后点“重试”。" /SD IDCANCEL IDRETRY rxRetry
Quit
rxRetry:
StrCpy $R1 0
Goto rxCloseLoop
rxClosed:
${endIf}
!macroend
+1
View File
@@ -16,6 +16,7 @@
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"shortcut-live": "electron test/globalshortcut_live.js",
"theme-live": "electron test/theme_live.js",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_live.js",
"browser-fit-pages": "electron test/browser_fit_pages.js",
+9 -6
View File
@@ -5,17 +5,20 @@
// compositor leaves uncovered at a fractional scale matches the app.
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
// "auto" and anything unrecognised follow the system, as the page does.
function groundFor(prefs, systemDark) {
const theme = prefs?.["rx-theme"];
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
function groundFor(prefs, systemDark, painted) {
const source = themeSourceFor(prefs, painted);
const dark = source === "system" ? systemDark : source === "dark";
return dark ? GROUND.dark : GROUND.light;
}
// The scheme the window frame takes. Windows draws a frameless window's border
// from it, so a dark page on a light system needs it set to stay borderless.
function themeSourceFor(prefs) {
const theme = prefs?.["rx-theme"];
function themeSourceFor(prefs, painted) {
const theme = painted?.mode ?? prefs?.["rx-theme"];
if (theme === "scheduled") {
const scheme = painted?.scheme ?? prefs?.["rx-theme-resolved"];
return scheme === "dark" || scheme === "light" ? scheme : "system";
}
return theme === "dark" || theme === "light" ? theme : "system";
}
+8 -3
View File
@@ -337,9 +337,12 @@ function fitted() {
}
const storedPrefs = () => loadPrefs(prefsFile(app.getPath("userData")));
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors);
let paintedTheme = null;
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors, paintedTheme);
const followTheme = () => {
nativeTheme.themeSource = themeSourceFor(storedPrefs());
const source = themeSourceFor(storedPrefs(), paintedTheme);
if (nativeTheme.themeSource !== source) nativeTheme.themeSource = source;
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
};
function createWindow() {
@@ -400,9 +403,11 @@ const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), a
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
installApplicationMenu(uiLang);
followTheme();
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
void powerGuard?.refresh();
browserViews?.setSleepAfterMs(pageSleepAfterMs(storedPrefs()));
}, paint => {
paintedTheme = paint;
followTheme();
});
nativeTheme.on("updated", () => {
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
+10 -1
View File
@@ -52,7 +52,7 @@ function savePrefs(file, prefs) {
// in the same process and must not reach this file.
// fileOf is asked on each use: the profile directory is settled during boot,
// after these handlers exist.
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}, onTheme = () => {}) {
ipcMain.on("prefs:load", (event) => {
event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {};
});
@@ -71,6 +71,15 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
}
event.returnValue = true;
});
ipcMain.on("prefs:theme", (event, paint) => {
if (!fromWindow(event) || !event.senderFrame || event.senderFrame !== event.sender.mainFrame) return;
if (!paint || typeof paint !== "object" || Array.isArray(paint)) return;
const { mode, scheme } = paint;
if (mode !== "auto" && mode !== "scheduled" && mode !== "light" && mode !== "dark") return;
if (scheme !== "light" && scheme !== "dark") return;
if ((mode === "light" || mode === "dark") && mode !== scheme) return;
onTheme({ mode, scheme });
});
}
function pickPagePrefs(prefs) {
+1
View File
@@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
platform: process.platform,
titleBar,
setUnreadBadge: (unread) => ipcRenderer.invoke("window:unread-badge", unread),
syncTheme: (mode, scheme) => ipcRenderer.send("prefs:theme", { mode, scheme }),
minimiseWindow: () => ipcRenderer.invoke("window:minimise"),
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
+1 -1
View File
@@ -84,7 +84,7 @@ async function run() {
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
}))()`);
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "syncTheme", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
+169
View File
@@ -0,0 +1,169 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow, nativeTheme } = require("electron");
const { GROUND } = require("../src/ground");
const { loadPrefs, prefsFile } = require("../src/prefs");
const { until, wait } = require("./livekit");
const home = process.env.REASONIX_HOME;
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native theme smoke");
fs.mkdirSync(home, { recursive: true });
if (!fs.existsSync(path.join(home, "config.toml"))) {
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
}
process.chdir(home);
fs.mkdirSync(path.join(home, "electron-profile"), { recursive: true });
app.setPath("userData", path.join(home, "electron-profile"));
const startups = new Map();
app.on("browser-window-created", (_event, win) => {
// Native options settle after creation; load-start still precedes page paint.
win.webContents.once("did-start-loading", () => {
startups.set(win, { source: nativeTheme.themeSource, background: win.getBackgroundColor(), saved: loadPrefs(prefsFile(app.getPath("userData"))) });
});
});
const { current } = require("../src/main");
let untrusted;
async function run() {
const win = await until("Studio window", () => current().win && !current().win.webContents.isLoading() && current().win);
const js = source => win.webContents.executeJavaScript(source);
await until("real Studio workbench", () => js('!!document.querySelector(".chrome") && !document.getElementById("boot")'));
const saved = () => loadPrefs(prefsFile(app.getPath("userData")));
async function matches(scheme, source = scheme) {
await until(`native ${scheme}/${source} repaint`, async () => nativeTheme.themeSource === source &&
win.getBackgroundColor().toLowerCase().endsWith(GROUND[scheme].slice(1)) &&
await js(`document.documentElement.dataset.theme === ${JSON.stringify(scheme)}`));
console.log(JSON.stringify({ page: scheme, source: nativeTheme.themeSource, background: win.getBackgroundColor() }));
}
if (process.env.REASONIX_THEME_RESTART === "1") {
const startup = startups.get(win);
assert.equal(startup.saved["rx-theme"], "scheduled");
assert.equal(startup.source, startup.saved["rx-theme-resolved"]);
assert.ok(startup.background.toLowerCase().endsWith(GROUND[startup.source].slice(1)), JSON.stringify(startup));
const clock = new Date();
const actual = clock.getHours() >= 20 || clock.getHours() < 7 ? "dark" : "light";
assert.notEqual(actual, startup.source);
await matches(actual);
assert.equal(await js('localStorage.getItem("rx-theme")'), "scheduled");
console.log("ok restart: cached native startup tint, then current local-clock page/native repaint on a new kernel origin");
return;
}
nativeTheme.themeSource = "system";
const systemDark = nativeTheme.shouldUseDarkColors;
console.log(`host OS dark=${systemDark}; platform=${process.platform}`);
await js(`(() => {
const RealDate = Date;
window.__themeClock = { at: RealDate.now(), began: performance.now() };
window.Date = class extends RealDate {
constructor(...args) { super(...(args.length ? args : [window.__themeClock.at + performance.now() - window.__themeClock.began])); }
static now() { return window.__themeClock.at + performance.now() - window.__themeClock.began; }
};
})()`);
async function clock(hour, minute = 0, second = 0) {
const at = new Date(2026, 9, 29, hour, minute, second).getTime();
await js(`window.__themeClock = { at: ${at}, began: performance.now() }; window.dispatchEvent(new Event("focus"))`);
}
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
await until("settings navigation", () => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'));
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
await until("appearance modes", () => js('!!document.querySelector(\'[data-action="appearance.scheme"][data-value="scheduled"]\')'));
const mode = value => js(`document.querySelector('[data-action="appearance.scheme"][data-value="${value}"]').click()`);
async function change(field, value) {
await js(`(() => {
const input = document.querySelector('[data-action="appearance.schedule"][data-target="${field}"]');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(value)});
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
})()`);
}
await clock(19, 59, 57);
await mode("scheduled");
await matches("light");
await matches("dark");
console.log("ok real boundary timeout changes both nativeTheme.themeSource and BrowserWindow background");
await clock(6, 59, 58);
await matches("dark");
await matches("light");
await clock(21);
await matches("dark");
await change("start", "22:00");
await matches("light");
await change("start", "20:00");
await matches("dark");
await change("end", "21:00");
await matches("light");
await change("end", "07:00");
await matches("dark");
await js('document.querySelector(\'[data-action="theme.activate"][data-value="official-clear-blue"]\').click()');
await until("active theme pack", () => js('document.documentElement.dataset.pack === "official-clear-blue"'));
await matches("dark");
await js('document.querySelector(".appearance-advanced").open = true; document.querySelector(\'[data-action="appearance.contrast"][data-value="strong"]\').click()');
await matches("dark");
await clock(8);
await matches("light");
assert.equal(await js('document.documentElement.dataset.pack'), "official-clear-blue");
assert.equal(await js('document.documentElement.dataset.contrast'), "strong");
console.log("ok start/end edits, focus resume, active pack and contrast keep the native scheme aligned");
await clock(systemDark ? 8 : 21);
await matches(systemDark ? "light" : "dark");
await mode("auto");
await until("Auto native system following", () => nativeTheme.themeSource === "system");
const systemScheme = systemDark ? "dark" : "light";
await matches(systemScheme, "system");
await js('localStorage.setItem("rx-theme", "scheduled"); localStorage.setItem("rx-weight", "heavy"); window.dispatchEvent(new Event("pagehide"))');
assert.equal(saved()["rx-theme"], "scheduled");
await matches(systemScheme, "system");
await js('localStorage.setItem("rx-theme", "auto")');
console.log("ok simulated stale prefs:save cannot override the latest Auto paint or pin system following");
await clock(8);
await mode("scheduled");
await matches("light");
await mode("dark");
await matches("dark");
await js('window.reasonixHost.syncTheme("scheduled", "auto"); window.reasonixHost.syncTheme("light", "dark"); window.reasonixHost.syncTheme("invalid", "light")');
await wait(200);
await matches("dark");
untrusted = new BrowserWindow({ show: false, webPreferences: { preload: path.join(__dirname, "../src/preload.js"), sandbox: true, contextIsolation: true, nodeIntegration: false, partition: "theme-smoke-untrusted" } });
await untrusted.loadURL("data:text/html,<title>Untrusted theme reporter</title>");
await untrusted.webContents.executeJavaScript('window.reasonixHost.syncTheme("scheduled", "light")');
await wait(200);
await matches("dark");
untrusted.destroy(); untrusted = null;
await js('const iframe = document.createElement("iframe"); iframe.id = "theme-subframe"; iframe.srcdoc = "<p>Untrusted subframe</p>"; document.body.append(iframe)');
await until("isolated subframe", () => js('!!document.getElementById("theme-subframe").contentDocument?.querySelector("p")'));
assert.equal(await js('typeof document.getElementById("theme-subframe").contentWindow.reasonixHost'), "undefined");
await js('document.getElementById("theme-subframe").remove()');
console.log("ok real untrusted renderer and invalid payloads cannot repaint native chrome; subframes have no bridge");
await mode("scheduled");
const real = new Date();
const restartScheme = real.getHours() >= 20 || real.getHours() < 7 ? "light" : "dark";
await clock(restartScheme === "dark" ? 21 : 8);
await matches(restartScheme);
await js('window.dispatchEvent(new Event("pagehide"))');
assert.equal(saved()["rx-theme"], "scheduled");
assert.equal(saved()["rx-theme-resolved"], restartScheme);
const out = process.env.REASONIX_SCREENSHOTS;
if (out) {
fs.mkdirSync(out, { recursive: true });
await js('document.getElementById("set-mode").scrollIntoView({block:"start"})');
await wait(350);
fs.writeFileSync(path.join(out, "native-scheduled.png"), (await win.webContents.capturePage()).toPNG());
}
console.log("ok saved opposite-clock native tint; run again with REASONIX_THEME_RESTART=1 to check a complete restart");
console.log("note Windows border rendering and changing the host OS preference are not exercised on this host");
}
let exitCode = 0;
app.once("will-quit", () => app.exit(exitCode));
run().then(() => app.quit()).catch(error => {
untrusted?.destroy();
console.error(error.stack || error);
exitCode = 1;
app.quit();
});
+49
View File
@@ -1152,6 +1152,30 @@ test("only the Studio window reads or writes the preferences", () => {
fs.rmSync(dir, { recursive: true, force: true });
});
test("only the Studio main frame reports a validated resolved theme", () => {
const handlers = {};
const studio = { mainFrame: {} };
const browser = { mainFrame: {} };
const paints = [];
registerPrefs({ on: (name, fn) => (handlers[name] = fn) }, () => "", event => event.sender === studio,
() => {}, paint => paints.push(paint));
const send = (sender, senderFrame, paint) => handlers["prefs:theme"]({ sender, senderFrame }, paint);
send(browser, browser.mainFrame, { mode: "scheduled", scheme: "dark" });
send(studio, {}, { mode: "scheduled", scheme: "dark" });
send(studio, null, { mode: "scheduled", scheme: "dark" });
for (const paint of [null, [], "dark", { mode: "scheduled" }, { mode: "scheduled", scheme: "auto" },
{ mode: "other", scheme: "dark" }, { mode: "light", scheme: "dark" }, { mode: "dark", scheme: "light" }]) {
send(studio, studio.mainFrame, paint);
}
assert.deepEqual(paints, []);
for (const mode of ["scheduled", "auto", "dark"]) send(studio, studio.mainFrame, { mode, scheme: "dark", extra: "discarded" });
send(studio, studio.mainFrame, { mode: "light", scheme: "light" });
assert.deepEqual(paints, [
{ mode: "scheduled", scheme: "dark" }, { mode: "auto", scheme: "dark" },
{ mode: "dark", scheme: "dark" }, { mode: "light", scheme: "light" },
]);
});
function elf64(interpreter, machine = 62) {
const phoff = 64, phentsize = 56, phnum = interpreter ? 2 : 1;
const dataOffset = phoff + phnum * phentsize;
@@ -2183,6 +2207,31 @@ test("the window frame takes the stored theme, and auto follows the system", ()
assert.equal(themeSourceFor(undefined), "system");
});
test("scheduled grounds and frames follow the painted scheme rather than the opposite OS", () => {
const prefs = { "rx-theme": "scheduled" };
for (const [scheme, systemDark] of [["dark", false], ["light", true]]) {
const painted = { mode: "scheduled", scheme };
assert.equal(themeSourceFor(prefs, painted), scheme);
assert.equal(groundFor(prefs, systemDark, painted), GROUND[scheme]);
const saved = { ...prefs, "rx-theme-resolved": scheme };
assert.equal(themeSourceFor(saved), scheme);
assert.equal(groundFor(saved, systemDark), GROUND[scheme]);
}
assert.equal(themeSourceFor({ ...prefs, "rx-theme-resolved": "invalid" }), "system");
});
test("the latest painted mode wins over delayed preference saves without pinning Auto", () => {
const stale = { "rx-theme": "scheduled", "rx-theme-resolved": "dark" };
const auto = { mode: "auto", scheme: "dark" };
assert.equal(themeSourceFor(stale, auto), "system");
assert.equal(groundFor(stale, false, auto), GROUND.light);
assert.equal(groundFor(stale, true, auto), GROUND.dark);
const light = { mode: "light", scheme: "light" };
assert.equal(themeSourceFor(stale, light), "light");
assert.equal(groundFor(stale, true, light), GROUND.light);
assert.equal(themeSourceFor({ "rx-theme": "light" }, { mode: "scheduled", scheme: "dark" }), "dark");
});
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
test("hardware acceleration is off only when the saved preference says exactly that", () => {
+11
View File
@@ -16,6 +16,17 @@ reviewed: 2026-09-22
- An unsent draft takes precedence while the page is open.
- No feedback body or screenshots are persisted by this preference.
## Pane workspace metadata
- Each pane owns one workspace-metadata read after its initial status settles.
Chrome and the composer's workspace chip consume that same result.
- A failed initial status still allows the read using the runtime's known root;
history loads independently of both status and workspace metadata.
- Changing the pane's port or directory reads again and discards superseded
responses. Focusing an already-open pane reuses its own result.
- Opening the workspace chooser still refreshes its branches and worktree
sources; it does not issue another workspace-metadata read.
## Tests
`pnpm test` runs one worker per test file on the threads pool. Threads rather
+19
View File
@@ -39,9 +39,12 @@ node perf/rail.mjs # 左栏会话树:2×8 / 10×50 / 20×200 / 40×500
node perf/profile.mjs # CPU 采样,按自耗时列热点(流式那一段)
node perf/switchprof.mjs # CPU 采样,量点开一份长会话的那一下
node perf/panes.mjs # 并行窗格的代价:两个会话同时流式
node perf/rail-toggle-panes.mjs # Rail-fold frame cost with 1/3/6 panes; existing-pane scroll retention and background-output follow
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
node perf/measure.mjs # Custom conversation percentage, preset retention, narrow composer controls and populated phone layout
node perf/theme-schedule.mjs # Scheduled appearance: daily boundaries, midnight, reload/resume, modes, active packs and DST; THEME_SCHEDULE_SHOTS writes wide/phone light/dark screenshots
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
@@ -83,9 +86,18 @@ node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/recent-sessions.mjs # Recent: global history paging, finished-session discovery and project location (light/dark × wide/390px)
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
`theme-schedule.mjs` is a manual smoke, not a default guard. For real native
effects, build the normal Studio page and host, then run `pnpm theme-live` from
`desktop/electron` with an isolated `REASONIX_HOME`.
Run it again with the same home and `REASONIX_THEME_RESTART=1` to check restart
tint and current-clock reconciliation. It reads actual `nativeTheme.themeSource`
and `BrowserWindow` background effects, including rejected IPC reporters.
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
哪些中文不必翻)。`PERF_URL` 可以指向别的地址;`bench.mjs` 和 `scale.mjs` 另收 `PERF_SCALES`、
`PERF_DELTAS`,`rail.mjs` 收 `PERF_TREE`(`区数x每区会话数`,逗号分隔),
@@ -99,6 +111,13 @@ node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出
1440 → 960 → 640 → 420 → 1440px at each zoom.
- The native-scrollbar case requires a space-consuming gutter, not an exact
pixel width; overlay-scrollbar browsers explicitly skip that case.
- `?panes=N` seeds the mock hub's shared runtime/session book. Tree rows and open
panes must agree on both transcript path and runtime ID.
- `rail-toggle-panes.mjs` selects two existing sessions by path, verifies that the
original transcript DOM node is hidden and restored, and checks scroll retention
while new output arrives in the background.
- The same guard checks that a neighbouring pane following the latest output
continues to do so. `RAIL_TOGGLE_SHOTS` saves its before/hidden/returned views.
## 判据
@@ -0,0 +1,99 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shots = process.env.SHOTS ?? "/tmp/reasonix-14759-visibility";
mkdirSync(shots, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ locale: "zh-CN" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const box = page.locator(".compose textarea");
function check(ok, message) {
if (!ok) throw new Error(message);
}
async function stable() {
await box.waitFor();
await page.addStyleTag({ content: "*, *::before, *::after { transition: none !important; animation: none !important; }" });
await page.evaluate(() => document.fonts.ready);
}
async function hint(on, shot) {
const settings = page.locator('[data-action="chrome.settings"]');
const menu = page.locator("button.studio-menu");
if (await menu.isVisible() && await menu.getAttribute("aria-pressed") !== "true") await menu.click();
await settings.click();
await page.locator('.prefs-sheet input[data-action-keydown="settings.search"]').fill("placeholder");
await page.locator('[role="option"][data-target="composer-hint"]').click();
const section = page.locator('[data-setting="composer-hint"]');
await section.locator(`[data-value="${on ? "on" : "off"}"]`).click();
check(await section.locator(`[data-value="${on ? "on" : "off"}"]`).getAttribute("aria-pressed") === "true", "Setting must reflect the selected visibility");
check(await section.locator('[data-apply="immediate"]').count() === 1, "Visibility must be immediate");
if (shot) {
await section.evaluate((el) => el.scrollIntoView({ block: "center" }));
await page.screenshot({ path: `${shots}/${shot}.png` });
}
await page.locator('button[data-action="settings.close"]').click();
}
async function visible(on) {
await page.waitForFunction((on) => Boolean(document.querySelector(".compose textarea")?.placeholder) === on, on);
check(Boolean(await box.getAttribute("aria-label")), "Hidden hint must retain the accessible input label");
check(Boolean(await box.getAttribute("aria-describedby")), "Input instructions must remain associated");
}
try {
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ["light", "dark"]) {
await page.goto(url, { waitUntil: "networkidle" });
await stable();
if (await page.locator("html").getAttribute("data-theme") !== theme) await page.locator('[data-action="appearance.theme"]').click();
await visible(true);
check(await box.evaluate((el) => getComputedStyle(el, "::placeholder").opacity) === "1", "Visible guidance must retain full theme opacity");
await page.screenshot({ path: `${shots}/${theme}-${width}-on.png` });
await hint(false, `${theme}-${width}-settings-off`);
await visible(false);
await page.screenshot({ path: `${shots}/${theme}-${width}-off.png` });
await page.reload({ waitUntil: "networkidle" });
await stable();
await visible(false);
await page.screenshot({ path: `${shots}/${theme}-${width}-off-reloaded.png` });
await box.fill("preserved draft");
const ink = await box.evaluate((el) => ({ color: getComputedStyle(el).color, opacity: getComputedStyle(el).opacity }));
check(ink.opacity === "1", "Entered text must retain full opacity");
await hint(true);
await visible(true);
check(await box.inputValue() === "preserved draft", "Changing hint must not alter the draft");
check(await box.evaluate((el) => getComputedStyle(el).color) === ink.color, "Changing hint must not alter entered ink");
await box.fill("");
await page.reload({ waitUntil: "networkidle" });
await stable();
await visible(true);
console.log(`PASS: ${theme}/${width} default on, Appearance off/on, draft and reload persistence`);
}
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.reload({ waitUntil: "networkidle" });
await stable();
await hint(false);
await box.fill("visibility smoke input");
check(await box.evaluate((el) => document.activeElement === el), "Typing must focus the composer");
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));
await box.press("Enter");
check(await page.evaluate(() => !window.__sent?.length), "IME Enter must not submit");
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })));
await page.waitForTimeout(120);
await box.press("Enter");
await page.waitForFunction(() => window.__sent?.includes("visibility smoke input"));
await page.goto(`${url}?queue=3&running=1`, { waitUntil: "networkidle" });
await stable();
await page.locator(".queue .qi").first().waitFor();
await visible(false);
check(await page.locator(".queue .qi").count() === 3, "Hiding hint must retain queued content");
await hint(true);
await visible(true);
check(await page.locator(".queue .qi").count() === 3, "Showing hint must retain queued content");
await page.screenshot({ path: `${shots}/queue-hint-on.png` });
check(errors.length === 0, errors.join("\n"));
console.log(`PASS: accessible label, entered ink, focus, IME, submit and queue; screenshots: ${shots}`);
} finally {
await browser.close();
}
+14 -31
View File
@@ -103,6 +103,7 @@ function benchPack(): ThemePack | null {
}
class BenchPort extends MockPort {
private driverRunning: boolean | undefined;
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
async account() {
@@ -344,6 +345,8 @@ class BenchPort extends MockPort {
}
feed(ev: WireEvent) {
if (ev.kind === "turn_started") this.driverRunning = true;
if (ev.kind === "turn_done") this.driverRunning = false;
// The driver's frames are the fixture's own frames: a fed approval has to
// move /status the way a scripted one does, or a guard reads a card sitting
// over a status that still says the turn is running.
@@ -421,6 +424,7 @@ class BenchPort extends MockPort {
? { turnsUsed: 4, tokensUsed: 128_400, requestsUsed: 9, workDurationMs: 12 * 60_000, noProgressTurns: usage === "blocked" ? 2 : 0, ...(usage === "blocked" ? { stopCause: "goal_stuck", lastReason: "测试一直没有通过,改动没有让失败的数量减少" } : {}) }
: undefined;
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("fullaccess") ? { toolApprovalMode: "yolo" as const } : {}), ...(query.has("effort") ? { effort: query.get("effort") ?? "" } : {}), ...(query.has("modelref") ? { modelRef: query.get("modelref") ?? "" } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
st.running = this.driverRunning ?? st.running;
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
if (!JOBS) return st;
const jobs = Array.from({ length: JOBS }, (_, i) => ({
@@ -509,11 +513,14 @@ const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
// ?tree=none answers every tree read with no folders.
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
constructor() {
super(PANES);
}
// ?search=1 answers every query with two conversations that say it.
async searchHistory(q: string) {
if (!query.has("search")) return super.searchHistory(q);
@@ -524,12 +531,6 @@ class BenchHub extends MockHub {
}
private readonly settingsPort = new BenchPort();
private opened = false;
async open(req: Parameters<MockHub["open"]>[0]) {
this.opened = true;
return super.open(req);
}
machinePort(): AgentPort {
return this.settingsPort;
@@ -541,20 +542,6 @@ class BenchHub extends MockHub {
return port;
}
runtimes(): Promise<RuntimeView[]> {
if (NO_PANES && !this.opened) return Promise.resolve([]);
if (PANES <= 1) return super.runtimes();
return Promise.resolve(
Array.from({ length: PANES }, (_, i) => ({
id: `r${i + 1}`,
base: i === 0 ? "" : `/rt/r${i + 1}`,
root: "~/projects/DeepSeek-Reasonix",
name: "DeepSeek-Reasonix",
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
})),
);
}
async moveSessionTo(path: string, target: string) {
(window.__moves ??= []).push({ path, target });
return super.moveSessionTo(path, target);
@@ -574,16 +561,11 @@ class BenchHub extends MockHub {
return new Promise((resolve) => setTimeout(() => resolve(answer), archived ? slow : 0));
}
const wait = TREE_AT - performance.now();
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree(window)), wait));
if (query.get("tree") === "none") return Promise.resolve([]);
if (!WORKSPACES && query.has("trustpending") && PANES > 1) {
return super.tree().then((all) => all.map((ws, i) => i > 0 ? ws : { ...ws, sessions: Array.from({ length: PANES }, (_, k) => ({
path: `/sessions/pane-${k + 1}.jsonl`, name: `pane-${k + 1}`, title: `并行会话 ${k + 1}`, turns: 3, runtimeId: `r${k + 1}`,
})) }));
}
if (!WORKSPACES) return super.tree();
return Promise.resolve(
Array.from({ length: WORKSPACES }, (_, w) => ({
if (!WORKSPACES) return super.tree(window);
return super.runtimes().then((runtimes) =>
this.recentTree(Array.from({ length: WORKSPACES }, (_, w) => ({
root: `~/projects/workspace-${w}`,
name: `workspace-${w}`,
remembered: true,
@@ -593,12 +575,13 @@ class BenchHub extends MockHub {
name: `w${w}-s${s}`,
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
turns: (s % 40) + 1,
runtimeId: runtimes.find((runtime) => runtime.sessionPath === `/sessions/w${w}-s${s}.jsonl`)?.id,
...(query.has("rows") ? {
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
} : {}),
}))),
})),
})), window),
);
}
}
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 2400, height: 1200 }, locale: "en-US" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const url = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
url.searchParams.set("pref", "en");
await page.goto(url.href, { waitUntil: "networkidle" });
await page.locator(".compose textarea").waitFor();
await page.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "text", text: "A populated conversation must remain readable when a custom width meets a narrow window." });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
});
await page.locator(".flow .out .txt").waitFor();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#prefs-appearance").click();
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
const slider = page.getByRole("slider", { name: "Conversation width", exact: true });
await slider.fill("67");
await page.getByRole("button", { name: "Full width", exact: true }).click();
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
assert.equal(await slider.inputValue(), "67", "preset switching retains the custom percentage");
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
await page.waitForFunction(() => {
const pane = document.querySelector(".pane").clientWidth;
return Math.abs(document.querySelector(".compose").getBoundingClientRect().width / pane - 0.67) < 0.01;
});
for (const selector of [".flow", ".compose"]) {
const ratio = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width / document.querySelector(".pane").clientWidth);
assert.ok(Math.abs(ratio - 0.67) < 0.01, `${selector} uses 67% of the conversation pane: ${ratio}`);
}
for (const [name, wideFlow, wideComposer] of [
["Full width", true, false], ["Full width, composer too", true, true], ["Reading", false, false],
]) {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#prefs-appearance").click();
await page.getByRole("button", { name, exact: true }).click();
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
await page.waitForFunction(([flow, composer]) => {
const fits = (selector, wide) => {
const width = document.querySelector(selector).getBoundingClientRect().width;
return wide ? width > 1800 : Math.abs(width - 760) < 1;
};
return fits(".flow", flow) && fits(".compose", composer);
}, [wideFlow, wideComposer]);
}
console.log("PASS existing presets: reading, wide transcript and wide composer keep their geometry");
console.log("PASS 67%: transcript and composer resize together; presets retain the value");
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#prefs-appearance").click();
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
await slider.fill("30");
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
await page.setViewportSize({ width: 1366, height: 900 });
await page.waitForFunction(() => document.querySelector(".compose").getBoundingClientRect().width < 350);
const outside = await page.locator(".compose").evaluate((el) => {
const box = el.getBoundingClientRect();
return [...el.querySelectorAll("button")].filter((button) => {
const rect = button.getBoundingClientRect();
return button.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
&& rect.width > 0 && (rect.right > box.right + 1 || rect.left < box.left - 1);
}).map((el) => el.textContent);
});
assert.deepEqual(outside, [], "toolbar actions stay within a 30% composer on a laptop");
const workspace = page.locator(".compose .worktree-trigger").first();
await workspace.click();
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible in a 30% composer");
await page.keyboard.press("Escape");
console.log("PASS 30%: composer actions remain contained on a laptop");
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForFunction(() => document.querySelector(".flow").getBoundingClientRect().width > 300);
for (const selector of [".flow", ".compose"]) {
const width = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width);
assert.ok(width > 300 && width <= 390, `${selector} uses the phone layout rather than 30%: ${width}`);
}
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "phone has no horizontal page overflow");
await workspace.click();
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible on a phone");
await page.keyboard.press("Escape");
assert.deepEqual(errors, []);
console.log("PASS phone: populated conversation and composer use the available width");
} finally {
await browser.close();
}
+68 -9
View File
@@ -15,7 +15,8 @@ try {
page.setDefaultTimeout(8000);
page.on("pageerror", (error) => failures.push(error.message));
await page.goto(`${BASE}?pref=zh&turns=3`, { waitUntil: "networkidle" });
const add = page.locator(".projects-heading .projects-add");
const add = page.locator('.projects-heading [data-action="workspace.add"]');
const recent = page.locator('.projects-heading [data-action="session.recent"]');
const more = page.locator(".projects-heading .wsmore");
await add.waitFor();
await page.addStyleTag({ content: "*{transition:none!important;animation:none!important}" });
@@ -29,11 +30,14 @@ try {
const addHover = await paint(add);
await more.hover();
const moreHover = await paint(more);
check(`${scheme}: both header actions visibly highlight on hover`,
addHover.fill !== rest.fill && moreHover.fill !== rest.fill);
await recent.hover();
const recentHover = await paint(recent);
check(`${scheme}: all header actions visibly highlight on hover`,
addHover.fill !== rest.fill && moreHover.fill !== rest.fill && recentHover.fill !== rest.fill);
check(`${scheme}: project header actions have the same hover fill and ink`,
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink,
JSON.stringify({ add: addHover, more: moreHover }));
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink
&& recentHover.fill === addHover.fill && recentHover.ink === addHover.ink,
JSON.stringify({ add: addHover, more: moreHover, recent: recentHover }));
await more.click();
const menu = page.getByRole("menu", { name: "项目列表的更多操作", exact: true });
await menu.waitFor();
@@ -50,15 +54,22 @@ try {
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
await page.keyboard.press("Shift+Tab");
const recentFocus = await recent.evaluate((button) => ({
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
const moreFocus = await more.evaluate((button) => ({
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
check(`${scheme}: both header actions retain visible keyboard focus`,
addFocus.focused && moreFocus.focused
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none"),
JSON.stringify({ add: addFocus, more: moreFocus }));
check(`${scheme}: all header actions retain visible keyboard focus`,
addFocus.focused && moreFocus.focused && recentFocus.focused
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none")
&& !recentFocus.outline.includes("none"),
JSON.stringify({ add: addFocus, more: moreFocus, recent: recentFocus }));
await more.click();
await menu.waitFor();
await page.locator(".projects-heading-title").click();
@@ -73,6 +84,54 @@ try {
await page.mouse.move(900, 500);
const after = await paint(more);
check(`${scheme}: dismissing the menu restores the resting background`, after.fill === rest.fill);
await recent.click();
const recentDialog = page.getByRole("dialog", { name: "最近的会话", exact: true });
await recentDialog.locator(".recent-session").first().waitFor();
check(`${scheme}: Recent opens saved session discovery`, await recentDialog.isVisible());
await page.keyboard.press("Escape");
await recentDialog.waitFor({ state: "hidden" });
check(`${scheme}: closing Recent returns focus to its own header entry`,
await recent.evaluate((button) => button === document.activeElement));
await page.goto(`${BASE}?pref=zh&ws=20&sess=8&turns=3`, { waitUntil: "networkidle" });
await add.waitFor();
const headerTop = await add.evaluate((button) => button.getBoundingClientRect().top);
const tree = page.getByRole("tree", { name: "项目与会话", exact: true });
const rows = tree.getByRole("treeitem");
await rows.first().focus();
await page.keyboard.press("End");
const last = rows.last();
check(`${scheme}: End reaches the final local or remote row`,
await last.evaluate((row) => row === document.activeElement));
const bottom = await last.evaluate((row) => {
const scroller = row.closest(".scroll");
const r = row.getBoundingClientRect(), s = scroller.getBoundingClientRect();
return { top: scroller.scrollTop, height: scroller.clientHeight, visible: r.top >= s.top && r.bottom <= s.bottom };
});
check(`${scheme}: a long project list scrolls to its final rows`, bottom.top > bottom.height && bottom.visible);
const addAtEnd = await add.evaluate((button) => {
const r = button.getBoundingClientRect();
return { top: r.top, reached: button.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)) };
});
check(`${scheme}: project controls stay fixed and clickable while the tree scrolls`,
addAtEnd.top === headerTop && addAtEnd.reached, JSON.stringify(addAtEnd));
await page.keyboard.press("Home");
check(`${scheme}: Home returns to the first project`,
await rows.first().evaluate((row) => row === document.activeElement));
await page.keyboard.press("Shift+F10");
await page.getByRole("menu", { name: "项目操作", exact: true }).waitFor();
await page.keyboard.press("Escape");
await more.click();
await page.getByRole("menuitem", { name: "选择本机会话", exact: true }).click();
await page.getByRole("button", { name: "完成选择", exact: true }).click();
check(`${scheme}: leaving batch selection returns focus to the fixed header`,
await more.evaluate((button) => button === document.activeElement));
await add.click();
await dialog.waitFor();
check(`${scheme}: the fixed add action retains local and remote project locations`,
await dialog.getByRole("button", { name: "选择本机文件夹", exact: true }).isEnabled()
&& await dialog.getByRole("button", { name: "gpu-box · SSH", exact: true }).isEnabled());
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "hidden" });
await page.close();
}
} finally {
@@ -3,6 +3,8 @@
// median frame with PERF_GUARD panes stays within FRAME_RATIO_MAX of the one-pane median, a pane that
// was hidden comes back at the scroll position it left, and the transcript does not inherit the animated
// column widths: with a long transcript the fold costs style recalculation of every element under it.
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const CPU = Number(process.env.PERF_CPU ?? 4);
@@ -12,6 +14,8 @@ const COUNTS = (process.env.PERF_N ?? "1,3,6").split(",").map(Number);
const GUARD = Number(process.env.PERF_GUARD ?? 3);
const FRAME_RATIO_MAX = Number(process.env.PERF_FRAME_RATIO ?? 2.5);
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.RAIL_TOGGLE_SHOTS;
if (SHOTS) await mkdir(SHOTS, { recursive: true });
const pct = (a, p) => [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * p))];
const browser = await chromium.launch();
const rows = [];
@@ -62,13 +66,41 @@ for (const n of COUNTS) {
}
rows.push({ panes: n, frames: frames.length, p50: pct(frames, 0.5).toFixed(1), p95: pct(frames, 0.95).toFixed(1), worst: Math.max(...frames).toFixed(1), over50: frames.filter((f) => f > 50).length });
if (n === GUARD) {
await page.evaluate(() => { document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop = 400; });
const first = page.locator('.sessrow[data-target="/sessions/pane-1.jsonl"]');
const second = page.locator('.sessrow[data-target="/sessions/pane-2.jsonl"]');
assert.equal(await first.getAttribute("data-live"), "", "the first row must identify an existing pane");
assert.equal(await second.getAttribute("data-live"), "", "the second row must identify another existing pane");
assert.equal(await first.getAttribute("aria-selected"), "true", "the measured pane must be the first session");
const scroller = await page.locator('.pane:not([data-off]) .scroll[data-pane="flow"]').elementHandle();
assert.ok(scroller);
await scroller.evaluate((root) => { root.scrollTop = 400; });
await page.waitForTimeout(200);
await page.locator(".sessrow").nth(1).click();
await page.waitForTimeout(500);
await page.locator(".sessrow").nth(0).click();
await page.waitForTimeout(500);
kept = await page.evaluate(() => document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop);
assert.equal(await scroller.evaluate((root) => root.scrollTop), 400, "the initial reading position must be reachable");
if (SHOTS) await page.screenshot({ path: `${SHOTS}/before-hidden.png` });
await second.click();
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.ok(await scroller.evaluate((root) => root.closest(".pane").hasAttribute("data-off")), "the original pane must actually be hidden");
assert.equal(await page.locator(".pane").count(), n, "switching existing conversations must not open a new pane");
await page.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "text", text: "Background scroll preservation probe." });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
});
await page.waitForFunction(() => document.querySelector(".pane:not([data-off]) .flow")?.textContent.includes("Background scroll preservation probe."));
if (SHOTS) await page.screenshot({ path: `${SHOTS}/while-hidden.png` });
await first.click();
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-1.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.ok(await scroller.evaluate((root) => root === document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]')), "scroll-retention precondition: inspect the originally hidden transcript");
kept = await scroller.evaluate((root) => root.scrollTop);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-return.png` });
await second.click();
await page.waitForFunction(() => {
if (document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") !== "true") return false;
const root = document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]');
return root && root.scrollHeight - root.clientHeight - root.scrollTop <= 48;
});
console.log(`existing pane round trip: scrollTop 400 -> hidden with new output -> ${kept}; following neighbour remains at latest`);
}
await ctx.close();
}
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const before = process.env.RECENT_BEFORE_URL;
const shots = process.env.RECENT_SHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
const errors = [];
async function settle(page, selector) {
await page.locator(selector).evaluateAll((elements) => Promise.all(elements.flatMap((element) => {
element.getBoundingClientRect();
return element.getAnimations().map((animation) => animation.finished.catch(() => {}));
})));
}
async function showRail(page) {
if (await page.locator(".rail[inert]").count()) {
await page.locator('[data-action="chrome.rail"]:not(.railveil)').filter({ visible: true }).first().click();
}
await page.locator(".rail:not([inert]) .projects-header").waitFor();
await settle(page, ".app, .rail");
}
try {
for (const scheme of ["light", "dark"]) {
for (const [size, width] of [["wide", 1440], ["phone", 390]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
const tag = `${scheme}-${size}`;
if (before && shots) {
await page.goto(`${before}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
await page.screenshot({ path: `${shots}/before-${tag}.png` });
}
await page.goto(`${base}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
if (shots) await page.screenshot({ path: `${shots}/after-heading-${tag}.png` });
await page.locator('.wsrow[data-target="~/projects/workspace-1"]').click();
await page.locator('.studio-session-segments [data-value="live"]').click();
const recent = page.getByRole("button", { name: "Recent sessions", exact: true });
const hit = await recent.evaluate((element) => {
const r = element.getBoundingClientRect();
return r.left >= 0 && r.right <= innerWidth && element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
});
assert.ok(hit, `${tag}: Recent is reachable in the project heading`);
await recent.click();
const dialog = page.getByRole("dialog", { name: "Recent sessions", exact: true });
await dialog.locator(".recent-session").first().waitFor();
await settle(page, ".pickveil");
assert.equal(await dialog.evaluate((element) => getComputedStyle(element).opacity), "1", "the dialog is actually painted");
assert.equal(await dialog.locator(".recent-session").count(), 50);
const bounds = await dialog.locator(".pickcard").boundingBox();
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width && bounds.y >= 0 && bounds.y + bounds.height <= 900, `${tag}: dialog stays inside the viewport`);
if (shots) await page.screenshot({ path: `${shots}/after-recent-${tag}.png` });
for (let i = 1; i <= 6; i++) {
await dialog.getByRole("button", { name: "Next page", exact: true }).click();
await dialog.getByText(`Page ${i + 1}`, { exact: true }).waitFor();
await dialog.locator('.picklist[aria-busy="false"] .recent-session').first().waitFor();
}
assert.equal(await dialog.locator(".recent-session").count(), 50, "history past 300 remains paged rather than hidden");
const selected = dialog.locator('.recent-session[data-target="/sessions/w1-s150.jsonl"]');
await selected.click();
await dialog.waitFor({ state: "detached" });
const located = page.locator('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]');
await located.waitFor();
await page.waitForFunction(() => document.querySelector('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.equal(await page.locator('.studio-session-segments [data-value="all"]').getAttribute("aria-pressed"), "true");
assert.equal(await page.locator('.wsrow[data-target="~/projects/workspace-1"]').getAttribute("aria-expanded"), "true");
if (size === "wide") {
assert.ok(await located.evaluate((element) => element === document.activeElement), `${tag}: selection focuses the session in its project`);
} else {
assert.equal(await page.locator(".app").getAttribute("data-rail"), "off", "phone navigation reveals the conversation");
await showRail(page);
assert.ok(await located.isVisible(), "reopening the phone rail reveals the selected session in its expanded project");
const selectedBounds = await located.boundingBox();
assert.ok(selectedBounds && selectedBounds.x >= 0 && selectedBounds.x + selectedBounds.width <= width, "the selected phone row is on screen, not covered by a closing rail");
}
if (shots) await page.screenshot({ path: `${shots}/after-located-${tag}.png` });
console.log(`${tag}: heading hit target, 50-row global pages beyond 300, viewport containment, finished-session project location`);
await page.close();
}
}
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(`${base}?pref=en&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
await page.locator('.studio-session-segments [data-value="live"]').click();
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
await page.evaluate(() => window.__feed({ kind: "turn_done" }));
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor({ state: "detached" });
await page.getByRole("button", { name: "Recent sessions", exact: true }).click();
await page.locator('.recent-session[data-target="/sessions/mock.jsonl"]').click();
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
console.log("running -> finished -> Recent -> existing pane: passed");
await page.close();
assert.deepEqual(errors, [], "no page errors");
} finally {
await browser.close();
}
+22 -2
View File
@@ -36,8 +36,9 @@ try {
<time class="sessmeta sessage">9 天前</time>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sesscopy">
<div class="sessrow sesscopy" tabindex="0">
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
<button class="wsdel" data-action="session.delete" aria-label="删除该会话"><svg viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" /></svg></button>
</div>
</div>
</div>`);
@@ -96,7 +97,26 @@ try {
assert.ok(contained, "quick actions fit inside the compact row");
}
}
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
// An earlier version has no row menu: its × is the only way a pointer deletes it.
for (const how of ["hover", "focus"]) {
await page.mouse.move(width + 200, 400);
if (how === "hover") await page.locator(".sesscopy").hover();
else await page.locator(".sesscopy").focus();
await page.waitForFunction(() => getComputedStyle(document.querySelector(".sesscopy .wsdel")).opacity === "1", null, { timeout: 1000 }).catch(() => {});
const del = await page.evaluate(() => {
const row = document.querySelector(".sesscopy");
const button = row.querySelector(".wsdel");
const box = button.getBoundingClientRect();
const edge = row.getBoundingClientRect();
return {
shown: box.width > 0 && box.height > 0 && getComputedStyle(button).opacity === "1",
inside: box.top >= edge.top && box.bottom <= edge.bottom && box.left >= edge.left && box.right <= edge.right,
};
});
assert.ok(del.shown, `${scheme}/${width}/${how}: an earlier version shows its delete button`);
assert.ok(del.inside, `${scheme}/${width}/${how}: the delete button sits inside its row`);
}
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable, an earlier version's × shows`);
await page.close();
}
}
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.THEME_SCHEDULE_SHOTS;
if (SHOTS) await mkdir(SHOTS, { recursive: true });
const browser = await chromium.launch();
const errors = [];
try {
for (const width of [1440, 390]) {
const page = await browser.newPage({ viewport: { width, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
await page.clock.install({ time: new Date("2026-10-29T19:58:00-04:00") });
await page.goto(`${BASE}?pref=en&turns=0`);
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /Appearance/ }).click();
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
const start = page.getByLabel("Dark mode starts", { exact: true });
const end = page.getByLabel("Light mode resumes", { exact: true });
assert.equal(await start.inputValue(), "20:00");
assert.equal(await end.inputValue(), "07:00");
const boxes = await page.locator('#set-mode input[type="time"]').evaluateAll((inputs) => inputs.map((input) => {
const box = input.getBoundingClientRect();
return { left: box.left, right: box.right, width: box.width };
}));
assert.ok(boxes.every((box) => box.width > 0 && box.left >= 0 && box.right <= width));
await page.clock.pauseAt(new Date("2026-10-29T19:59:59-04:00"));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "light");
if (SHOTS) {
await page.locator("#set-mode").scrollIntoViewIfNeeded();
await page.screenshot({ path: `${SHOTS}/after-light-${width}.png`, animations: "disabled" });
}
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "dark");
await page.clock.runFor(1000);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-dark-${width}.png`, animations: "disabled" });
await page.clock.fastForward("03:59:58");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("07:00:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await start.fill("21:30");
await end.fill("06:15");
await page.clock.resume();
await page.reload();
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /Appearance/ }).click();
assert.equal(await start.inputValue(), "21:30");
assert.equal(await end.inputValue(), "06:15");
assert.equal(await page.locator('[data-value="scheduled"]').getAttribute("aria-pressed"), "true");
await page.clock.setSystemTime(new Date("2026-10-30T23:00:00-04:00"));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.setSystemTime(new Date("2026-10-31T09:00:00-04:00"));
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.locator('[data-action="appearance.scheme"][data-value="dark"]').click();
assert.equal(await start.count(), 0);
await page.emulateMedia({ colorScheme: "light" });
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.locator('[data-action="appearance.scheme"][data-value="auto"]').click();
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.emulateMedia({ colorScheme: "dark" });
await page.waitForFunction(() => document.documentElement.dataset.theme === "dark");
console.log(`ok ${width}px: exact boundaries, midnight, saved controls/reload, focus/visibility resume, explicit/system modes`);
await page.close();
}
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
await page.clock.install({ time: new Date("2026-11-01T00:59:00-04:00") });
await page.goto(`${BASE}?pref=zh&turns=0&pack=sky`);
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /外观/ }).click();
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
await page.getByLabel("深色开始时间", { exact: true }).fill("01:30");
await page.getByLabel("恢复浅色时间", { exact: true }).fill("01:45");
await page.clock.pauseAt(new Date("2026-11-01T01:29:59-04:00"));
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
assert.equal(await page.locator("html").getAttribute("data-pack"), "bench-sky");
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#080D10");
await page.clock.fastForward("15:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.clock.fastForward("45:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("15:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#F2F6F8");
await page.getByLabel("深色开始时间", { exact: true }).fill("02:30");
await page.getByLabel("恢复浅色时间", { exact: true }).fill("04:00");
await page.clock.setSystemTime(new Date("2026-03-08T01:59:59-05:00"));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.clock.fastForward(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("01:00:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
console.log("ok Chinese controls, active pack repaint, skipped and repeated DST hour transitions");
await page.close();
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
+10 -5
View File
@@ -108,6 +108,8 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.live-stream-budget", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
@@ -124,6 +126,10 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-environment", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-page", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -148,6 +154,7 @@ export const ACTIONS: UIAction[] = [
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
{ id: "composer.running-message-intent", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
@@ -510,6 +517,7 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.full-installer", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -561,11 +569,7 @@ export const ACTIONS: UIAction[] = [
// different capability from changing its opacity, so the four sliders share
// one id and say which parameter on data-value.
//
// These stay apart on purpose. theme.activate installs a palette pack;
// appearance.scheme is light, dark or follow-the-system. wallpaper.change
// and wallpaper.remove decide which image there is; appearance.background is
// how the image that is already there is drawn. And weight and contrast are
// two different things a reader wants, however alike they look in CSS.
// A pack supplies colours; the scheme decides when its light or dark set is used.
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -590,6 +594,7 @@ export const ACTIONS: UIAction[] = [
// Kept where the window keeps them: these are this browser's, and no kernel
// state moves.
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.schedule", kind: "view", target: "entity", proof: "browser" },
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
+14 -3
View File
@@ -606,8 +606,10 @@ export const EN: Record<string, string> = {
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
"使用当前位置": "Stay where I am",
"隔离": "Isolated",
"原目录": "Local checkout",
"隔离目录": "Worktree",
"独立工作树": "Isolated worktree",
"直接在已打开的项目文件夹中工作,与使用该目录的其他会话共享文件。": "Work directly in the opened project folder, sharing files with other sessions using it.",
"在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。": "Edit code in a separate Git working directory; ports, databases, and other runtime resources may still be shared.",
"发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。": "Create an isolated worktree when you send the message, using the selected branch's committed version without uncommitted changes; ports, databases, and other runtime resources may still be shared.",
"移除": "Remove",
"删除": "Delete",
"删除该会话": "Delete this session",
@@ -788,7 +790,12 @@ export const EN: Record<string, string> = {
"内置": "Built in",
"第三方": "Third party",
"{n} 个已装": "{n} installed",
"跟随系统时会随系统主题切换;手动选择后将保持固定。": "Following the system switches with it; once you choose a theme yourself, it stays fixed.",
"跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。": "Follow the system switches with your OS; Light and Dark stay fixed; Scheduled switches daily using this device's clock.",
"定时": "Scheduled",
"深色开始时间": "Dark mode starts",
"恢复浅色时间": "Light mode resumes",
"每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。": "Switches daily in local time, including overnight: dark at the start, light at the end.",
"开始和结束时间相同时全天使用浅色。": "Equal start and end times keep light mode all day.",
// ── 连接与来源 ───────────────────────────────────────────────────
"添加模型来源": "Add a model source",
@@ -1694,6 +1701,10 @@ export const EN: Record<string, string> = {
"正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首": "Prose and the composer both follow the window; a very wide composer makes a line harder to find the start of",
"每行长度控制在一眼能回到行首的范围": "Keeps a line short enough that your eye finds the next one",
"正文跟随窗口宽度,宽屏上不留两侧空白": "Prose follows the window, with nothing left empty at the sides",
"自定义比例": "Custom percentage",
"按比例调整对话和输入框占用的区域": "Set the conversation and composer width as a percentage",
"对话区域宽度": "Conversation width",
"自定义比例同时调整对话和输入框;窄窗口会优先保证可用空间。": "Custom percentage resizes both the conversation and composer; narrow windows prioritize usable space.",
"行宽只管正文:代码、命令与工具输出始终占满可用宽度。": "Line width applies to prose only — code, commands and tool output always use the width available.",
"决策模型": "Decision models",
"为 system_one 工具配置辅助决策后端,不会替换当前对话的主模型。": "Configure auxiliary decision backends for the system_one tool without replacing the main chat model.",
@@ -59,13 +59,10 @@ export const EN_COMPOSER: Record<string, string> = {
"创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.",
"创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.",
"配置工作目录": "Configure workspace",
"独立 worktree · {branch}": "Isolated worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}",
"新建 worktree": "New worktree",
"独立 worktree": "Isolated worktree",
"独立工作树 · {branch}": "Isolated worktree · {branch}",
"当前目录": "Current folder",
"此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.",
"如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.",
"使用当前目录": "Use current folder",
"关闭工作目录配置": "Close workspace configuration",
"发送消息时开始创建。": " Creation starts when you send the message.",
"选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.",
@@ -78,7 +75,6 @@ export const EN_COMPOSER: Record<string, string> = {
"选择起始分支": "Choose source branch",
"搜索分支": "Search branches",
"搜索分支…": "Search branches…",
"当前目录": "Current folder",
"查看完整路径": "Show full path",
"复制完整路径": "Copy full path",
"复制操作编号": "Copy operation ID",
@@ -108,6 +104,10 @@ export const EN_COMPOSER: Record<string, string> = {
"停下": "Stop",
"发送": "Send",
"插话": "Steer",
"排队发送": "Queue",
"点按排队 · 回车换行": "Tap Queue · Enter for a new line",
"{key} 排队 · Enter 换行": "{key} to queue · Enter for a new line",
"Enter 排队 · Shift+Enter 换行": "Enter to queue · Shift+Enter for a new line",
"正在停止…": "Stopping…",
"正在发送…": "Sending…",
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
+12 -1
View File
@@ -102,6 +102,11 @@ export const EN_SETTINGS: Record<string, string> = {
"开启(15 分钟后)": "On (after 15 minutes)",
"保持打开": "Keep open",
"发送快捷键": "Send shortcut",
"运行中发送消息": "Messages sent while running",
"选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。": "Choose the default for messages sent during a run. Saved on this device only; messages already queued are unchanged.",
"下个工具边界插话": "Steer at the next tool boundary",
"整轮结束后发送": "Send after the whole run",
"可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。": "Use Send now in the queue to stop the current response and start a new turn. Hold, edit, and take back remain available.",
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
"Enter 发送": "Enter to send",
"{key} 发送": "{key} to send",
@@ -185,7 +190,6 @@ export const EN_SETTINGS: Record<string, string> = {
"搜索设置…": "Search settings…",
"搜索设置": "Search settings",
"没有匹配的设置": "No matching setting",
"设置项:{n}": "Settings: {n}",
"立即生效": "In effect now",
"当前会话": "This session",
"当前模型": "This model",
@@ -1031,6 +1035,8 @@ export const EN_SETTINGS: Record<string, string> = {
"会话显示": "Conversation display",
"导航与侧栏": "Navigation and sidebar",
"窗口与性能": "Window and performance",
"位于项目文件夹内": "Inside a project folder",
"位于项目文件夹 {path} 内,出于安全不从这里启动": "Inside the project folder {path}, so it is not started from here for safety",
"运行环境": "Where it runs",
"记录与用量": "What it remembers",
"这台机器": "This machine",
@@ -1150,6 +1156,11 @@ export const EN_SETTINGS: Record<string, string> = {
"思考自动": "Thinking automatic",
"会话折叠": "Transcript folding",
"会话中各部分默认展开还是收起。失败的步骤与正在运行的操作可以单独处理;已手动展开或收起的块保持原样。": "Whether each part of a conversation starts open or folded. Failed steps and work still running can be handled on their own; a block you have already opened or closed stays as it is.",
"收起时显示计划进度": "Show collapsed plan progress",
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
"输入框提示": "Composer hint",
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "Show task and queue guidance in the empty composer. On by default; saved only on this machine. Does not affect typing or sending.",
"计划进度": "Plan progress",
"折叠预设": "Folding preset",
"精简": "Compact",
"详尽": "Detailed",
@@ -6,6 +6,13 @@ export const EN_WINDOW: Record<string, string> = {
"打开项目": "Open project",
"项目列表的更多操作": "More project list actions",
"项目与会话": "Projects and conversations",
"最近的会话": "Recent sessions",
"按最近活动排列,包含已完成的会话。选择后定位到所属项目。": "Ordered by last activity, including finished sessions. Select one to locate its project.",
"搜索最近会话 / 项目": "Search recent sessions or projects",
"此运行环境不支持最近会话,请更新 Reasonix": "This environment does not support recent sessions. Update Reasonix.",
"正在读取会话…": "Reading sessions…",
"最近": "Recent",
"第 {n} 页": "Page {n}",
"选择本机文件夹": "Choose a local folder",
"打开第一个项目": "Open your first project",
"展开所有项目": "Expand all projects",
+6
View File
@@ -53,6 +53,7 @@ export interface HostPort {
toggleMaximiseWindow(): void;
isWindowMaximised(): Promise<boolean>;
closeWindow(): void;
syncTheme(mode: string, scheme: "light" | "dark"): void;
/** Whether this shell can hand back a picture of its own window. */
capturesWindow(): boolean;
/** A PNG of this window as drawn now; null when the shell cannot or the
@@ -189,6 +190,7 @@ interface ElectronBridge {
toggleMaximiseWindow(): Promise<void>;
isWindowMaximised(): Promise<boolean>;
closeWindow(): Promise<void>;
syncTheme?(mode: string, scheme: "light" | "dark"): void;
captureWindow?(): Promise<Uint8Array | null>;
graphics?(): Promise<GraphicsInfo | null>;
globalShortcut?(): Promise<GlobalShortcutInfo | null>;
@@ -250,6 +252,9 @@ class ElectronHost implements HostPort {
closeWindow() {
void this.api.closeWindow();
}
syncTheme(mode: string, scheme: "light" | "dark") {
this.api.syncTheme?.(mode, scheme);
}
capturesWindow() {
return typeof this.api.captureWindow === "function";
}
@@ -370,6 +375,7 @@ class BrowserHost implements HostPort {
captureWindow() {
return Promise.resolve(null);
}
syncTheme() {}
showTextContextMenu() {
return Promise.resolve(null);
}
@@ -49,6 +49,23 @@ it("keeps local project moves and host-qualified pin/archive calls on their owni
} finally { vi.unstubAllGlobals(); }
});
it("carries the same global recent-page query to local and remote tree owners", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal("fetch", fetch);
try {
const hub = new SseHub();
const window = { order: "recent" as const, scope: "active" as const, offset: 350, limit: 50, hideLegacy: true, q: "release plan" };
await hub.tree(window);
await hub.remoteTree("gpu box", window);
const paths = fetch.mock.calls.map(([path]) => new URL(path, "http://localhost"));
expect(paths[0].pathname).toBe("/tree");
expect(paths[1].pathname).toBe("/remotes/gpu%20box/tree");
for (const path of paths) {
expect(Object.fromEntries(path.searchParams)).toMatchObject({ order: "recent", scope: "active", offset: "350", limit: "50", hideLegacy: "1", q: "release plan" });
}
} finally { vi.unstubAllGlobals(); }
});
// The same defect SseHttp was fixed for, still standing in the hub port: asking
// for JSON first threw away every answer that was not an envelope, and what
// reached the window was "this request did not reach the kernel (HTTP 502)" —
+19 -9
View File
@@ -101,6 +101,8 @@ export interface OpenWithTarget {
id: string;
name: string;
available: boolean;
why?: string;
within?: string;
}
export interface TreeWorkspace {
@@ -109,6 +111,8 @@ export interface TreeWorkspace {
countsComplete?: boolean;
pendingMetadata?: number;
failedMetadata?: number;
// Echoed only by kernels that serve globally ordered recent pages.
recentOffset?: number;
root: string;
name: string;
workspaceRoots?: string[];
@@ -123,8 +127,7 @@ export interface TreeWorkspace {
// paging sends neither, and then `sessions` is all there is.
total?: number;
archived?: number;
// How many rows the request's scope and word match that `sessions` does not
// carry. Absent on a kernel that predates it, and then only `total` says.
// Matching rows after this page/window. Absent on kernels predating paging.
remaining?: number;
sessions: TreeSession[];
}
@@ -133,14 +136,18 @@ export function workspaceContains(workspace: TreeWorkspace, root: string | undef
return root !== undefined && (workspace.root === root || !!workspace.workspaceRoots?.includes(root));
}
// How much of each folder tree() builds: `limit` for all of them (0 is every
// row), `limits` overriding it per folder root.
// The sessions a tree request counts, independently of its ordering.
export type TreeScope = "" | "active" | "archived";
// `scope` says which rows the window counts (active ones, only the archived, or
// every row) and `q` keeps them to those whose title or name contains it.
// Ordinary windows bound each project; recent windows page across all projects.
// `q` filters titles/names and project names before either window is selected.
export interface TreeWindow {
retryMetadata?: boolean;
order?: "recent";
// Recent pages are global across projects, unlike each project's tree window.
offset?: number;
hideLegacy?: boolean;
// 0 means all rows; `limits` overrides this per project in ordinary windows.
limit?: number;
limits?: Record<string, number>;
scope?: TreeScope;
@@ -150,6 +157,9 @@ export interface TreeWindow {
export function treeQuery(window: TreeWindow | undefined): string {
const q = new URLSearchParams();
if (window?.retryMetadata) q.set("retryMetadata", "1");
if (window?.order) q.set("order", window.order);
if (window?.offset !== undefined) q.set("offset", String(window.offset));
if (window?.hideLegacy) q.set("hideLegacy", "1");
if (window?.limit !== undefined) q.set("limit", String(window.limit));
if (window?.limits && Object.keys(window.limits).length > 0) q.set("limits", JSON.stringify(window.limits));
if (window?.scope) q.set("scope", window.scope);
@@ -220,7 +230,7 @@ export interface HubPort extends SharePort {
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }): Promise<RuntimeView>;
// The far machine's own workspaces, or null while nothing is open on it:
// there is no kernel over there to ask until a pane puts one there.
remoteTree(host: string): Promise<TreeWorkspace[] | null>;
remoteTree(host: string, window?: TreeWindow): Promise<TreeWorkspace[] | null>;
// Erases a conversation on that machine; its kernel decides whether it may.
removeRemoteSession(host: string, path: string): Promise<void>;
// Folders on the far machine, read over the link alone — no kernel has to be
@@ -473,8 +483,8 @@ export class SseHub implements HubPort {
await this.post<void>(`/remotes/${encodeURIComponent(host)}/sessions/remove`, { path });
}
async remoteTree(host: string) {
const path = `/remotes/${encodeURIComponent(host)}/tree`;
async remoteTree(host: string, window?: TreeWindow) {
const path = `/remotes/${encodeURIComponent(host)}/tree` + treeQuery(window);
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) await SseHub.fail(path, res);
return (await res.json()) as TreeWorkspace[];
+3 -3
View File
@@ -94,10 +94,10 @@ export interface Appearance {
textSize?: number;
fontUi?: string;
fontMono?: string;
// How far prose may run: "standard" holds it to a reading line length,
// "full" lets it use the window. Cards, commands and output are not
// governed by it.
// Custom width sizes the conversation and composer as a percentage of the pane.
width?: string;
widthPercent?: number;
widthRange?: ZoomRange;
wallpaper?: Wallpaper;
}
@@ -16,3 +16,16 @@ it("keeps session placement and pin/archive flags in the same mock tree", async
expect(archived.map((s) => s.path)).toEqual([second.path, first.path]);
expect(archived[0]).toMatchObject({ archived: true, pinned: false });
});
it("retains seeded history while closing and reopening its pane with a fresh identity", async () => {
const hub = new MockHub(3);
const original = (await hub.runtimes())[1];
await hub.close(original.id);
const closed = (await hub.tree()).flatMap((workspace) => workspace.sessions).find((session) => session.path === original.sessionPath);
expect(closed).toBeDefined();
expect(closed?.runtimeId).toBeUndefined();
const reopened = await hub.open({ root: original.root, sessionPath: original.sessionPath });
expect(reopened.id).not.toBe(original.id);
expect((await hub.runtimes()).find((runtime) => runtime.id === reopened.id)?.sessionPath).toBe(original.sessionPath);
expect((await hub.tree()).flatMap((workspace) => workspace.sessions).find((session) => session.path === original.sessionPath)?.runtimeId).toBe(reopened.id);
});
+38 -11
View File
@@ -12,13 +12,24 @@ export class MockHub implements HubPort {
private readonly machine = { configured: false };
private readonly settings = new MockPort();
private readonly remoteSettings = new Map<string, MockPort>();
private readonly views: RuntimeView[] = [
{ id: "r1", base: "", root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", sessionPath: "/sessions/mock.jsonl" },
];
private readonly views: RuntimeView[];
private readonly seeded: TreeSession[];
private readonly roots = ["~/projects/DeepSeek-Reasonix", "~/projects/my-website"];
private seq = 1;
private seq: number;
private readonly flags = new Map<string, Pick<TreeSession, "pinned" | "archived" | "unread">>();
constructor(panes = 1) {
this.seeded = panes > 1 ? Array.from({ length: panes }, (_, i) => ({
path: `/sessions/pane-${i + 1}.jsonl`, name: `pane-${i + 1}`, title: `并行会话 ${i + 1}`, turns: 3,
})) : [];
const initial = panes === 1 ? [{ path: "/sessions/mock.jsonl" }] : this.seeded;
this.views = initial.map((session, i) => ({
id: `r${i + 1}`, base: i === 0 ? "" : `/rt/r${i + 1}`, root: this.roots[0],
name: "DeepSeek-Reasonix", sessionPath: session.path,
}));
this.seq = this.views.length;
}
private decorate(tree: TreeWorkspace[], host = ""): TreeWorkspace[] {
return tree.map((ws) => ({ ...ws, sessions: ws.sessions.map((session) => ({ ...session,
...this.flags.get(JSON.stringify([host, session.path])) })) }));
@@ -68,9 +79,9 @@ export class MockHub implements HubPort {
// The fixture's own far machine: one workspace with a conversation in it, so
// the tree under a connected host can be designed without one.
remoteTree(host: string) {
remoteTree(host: string, window?: TreeWindow) {
if (host !== "gpu-box") return Promise.resolve(null);
return Promise.resolve<TreeWorkspace[]>(this.decorate([
return Promise.resolve<TreeWorkspace[]>(this.recentTree(this.decorate([
{
root: "/srv/training",
name: "training",
@@ -80,7 +91,7 @@ export class MockHub implements HubPort {
],
},
{ root: "/srv/scratch", name: "scratch", sessions: [] },
], host));
], host), window));
}
async openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?: string }) {
@@ -183,25 +194,41 @@ export class MockHub implements HubPort {
onTreeChanged(_listener: () => void) { return () => {}; }
tree(_window?: TreeWindow) {
tree(window?: TreeWindow) {
const open = new Map(this.views.filter((v) => v.sessionPath).map((v) => [v.sessionPath!, v.id]));
return Promise.resolve(
this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
this.recentTree(this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
root,
name: root.split("/").pop() ?? root,
open: this.views.some((v) => v.root === root),
remembered: true,
sessions: this.placed(root,
i === 0
? [
? this.seeded.length ? this.seeded.map((session) => ({ ...session, runtimeId: open.get(session.path) })) : [
{ path: "/sessions/mock.jsonl", name: "mock", title: "并行会话演示", turns: 3, runtimeId: open.get("/sessions/mock.jsonl") },
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl"), legacy: true },
]
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }]),
}))),
}))), window),
);
}
protected recentTree(tree: TreeWorkspace[], window?: TreeWindow): TreeWorkspace[] {
if (window?.order !== "recent") return tree;
const q = window.q?.trim().toLowerCase() || "";
const rows = tree.flatMap((ws, workspace) => ws.sessions
.filter((session) => !session.archived && !(window.hideLegacy && !q && session.legacy) &&
(!q || ws.name.toLowerCase().includes(q) || (session.title || session.name).toLowerCase().includes(q)))
.map((session) => ({ workspace, session })))
.sort((a, b) => (b.session.lastActiveAt ?? 0) - (a.session.lastActiveAt ?? 0) || (a.session.path < b.session.path ? -1 : a.session.path > b.session.path ? 1 : 0));
const offset = window.offset ?? 0;
const end = window.limit === 0 ? rows.length : offset + (window.limit ?? 50);
return tree.map((ws, workspace) => ({ ...ws, recentOffset: offset,
remaining: rows.slice(end).filter((row) => row.workspace === workspace).length,
sessions: rows.slice(offset, end).filter((row) => row.workspace === workspace).map((row) => row.session),
}));
}
addWorkspace(path: string) {
if (!this.roots.includes(path)) this.roots.push(path);
return Promise.resolve<TreeWorkspace>({ root: path, name: path.split("/").pop() ?? path, remembered: true, sessions: [] });
+4 -2
View File
@@ -17,15 +17,17 @@ export class MockLook {
// What the kernel announces; the zoom test holds it equal to config.ZoomMin..ZoomMax.
private static readonly ZOOM = { min: 0.8, max: 1.8, step: 0.05 };
private static readonly WIDTH = { min: 30, max: 100, step: 1 };
async appearance(): Promise<Appearance> {
return { ...this.look, zoomRange: MockLook.ZOOM };
return { ...this.look, zoomRange: MockLook.ZOOM, widthPercent: this.look.widthPercent || 80, widthRange: MockLook.WIDTH };
}
async saveAppearance(look: Appearance): Promise<Appearance> {
const { min, max } = MockLook.ZOOM;
const zoom = look.zoom ? Math.min(Math.max(look.zoom, min), max) : look.zoom;
this.look = { ...look, zoom, wallpaper: this.look.wallpaper && { ...this.look.wallpaper, ...look.wallpaper } };
const widthPercent = look.widthPercent ? Math.min(Math.max(Math.round(look.widthPercent), MockLook.WIDTH.min), MockLook.WIDTH.max) : this.look.widthPercent;
this.look = { ...look, zoom, widthPercent, wallpaper: this.look.wallpaper && { ...this.look.wallpaper, ...look.wallpaper } };
return this.appearance();
}
@@ -18,6 +18,7 @@ export class SseLook extends SseHttp {
fontUi: look.fontUi ?? "",
fontMono: look.fontMono ?? "",
width: look.width ?? "",
widthPercent: look.widthPercent ?? 0,
opacity: look.wallpaper?.opacity ?? 0,
dim: look.wallpaper?.dim ?? 0,
focusX: look.wallpaper?.focusX ?? 0.5,
+59 -2
View File
@@ -1,8 +1,65 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from "vitest";
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, setLiveGraceMinutes, setOpenSessionLimit } from "./prefs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, runningMessageIntent, setLiveGraceMinutes, setOpenSessionLimit, setRunningMessageIntent, onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "./prefs";
beforeEach(() => localStorage.clear());
afterEach(() => { vi.restoreAllMocks(); setRunningMessageIntent("steer"); localStorage.clear(); });
describe("default intent for messages sent during a run", () => {
it("defaults to steering and reads only the offered follow-up choice", () => {
expect(runningMessageIntent()).toBe("steer");
for (const raw of ["", "queued", "invalid"]) {
localStorage.setItem("rx-running-message-intent", raw);
expect(runningMessageIntent()).toBe("steer");
}
setRunningMessageIntent("followup");
expect(runningMessageIntent()).toBe("followup");
expect(localStorage.getItem("rx-running-message-intent")).toBe("followup");
});
it("holds the choice for this window if storage cannot be written", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("unavailable"); });
setRunningMessageIntent("followup");
expect(runningMessageIntent()).toBe("followup");
});
});
describe("collapsed plan progress on this machine", () => {
it("is off by default and only accepts an explicit on preference", () => {
expect(showsPlanProgress()).toBe(false);
for (const raw of ["", "true", "off", "garbage"]) {
localStorage.setItem("rx-plan-progress", raw);
expect(showsPlanProgress()).toBe(false);
}
setShowsPlanProgress(true);
expect(showsPlanProgress()).toBe(true);
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
setShowsPlanProgress(false);
expect(showsPlanProgress()).toBe(false);
});
it("notifies mounted subscribers and removes them on unsubscribe", () => {
const heard = vi.fn();
const stop = onPlanProgressChange(heard);
setShowsPlanProgress(true);
stop();
setShowsPlanProgress(false);
expect(heard).toHaveBeenCalledTimes(1);
});
it("stays usable with unavailable storage", () => {
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("unavailable"); });
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("unavailable"); });
try {
expect(showsPlanProgress()).toBe(false);
expect(() => setShowsPlanProgress(true)).not.toThrow();
expect(showsPlanProgress()).toBe(false);
} finally {
get.mockRestore();
set.mockRestore();
}
});
});
describe("how long a finished session stays in the running list", () => {
it("is off until this machine chooses otherwise", () => {
+88
View File
@@ -160,6 +160,60 @@ export function setNavRailMode(mode: NavRailMode): void {
navRailListeners.forEach((fn) => fn());
}
const COMPOSER_HINT_KEY = "rx-composer-hint";
const composerHintListeners = new Set<() => void>();
export function showsComposerHint(): boolean {
try {
return localStorage.getItem(COMPOSER_HINT_KEY) !== "off";
} catch {
return true;
}
}
export function onComposerHintChange(fn: () => void): () => void {
composerHintListeners.add(fn);
return () => {
composerHintListeners.delete(fn);
};
}
export function setShowsComposerHint(on: boolean): void {
try {
localStorage.setItem(COMPOSER_HINT_KEY, on ? "on" : "off");
} catch {
/* unavailable storage keeps the default */
}
composerHintListeners.forEach((fn) => fn());
}
const PLAN_PROGRESS_KEY = "rx-plan-progress";
const planProgressListeners = new Set<() => void>();
export function showsPlanProgress(): boolean {
try {
return localStorage.getItem(PLAN_PROGRESS_KEY) === "on";
} catch {
return false;
}
}
export function onPlanProgressChange(fn: () => void): () => void {
planProgressListeners.add(fn);
return () => {
planProgressListeners.delete(fn);
};
}
export function setShowsPlanProgress(on: boolean): void {
try {
localStorage.setItem(PLAN_PROGRESS_KEY, on ? "on" : "off");
} catch {
/* unavailable storage keeps the default */
}
planProgressListeners.forEach((fn) => fn());
}
// How long a session that finished its turn stays in the sidebar's running list,
// in minutes. Off (0) unless this machine chose a value: leaving the list the
// moment a turn ends is the long-standing behaviour, and keeping a row there is
@@ -488,6 +542,40 @@ export function useSendShortcut(): SendShortcut {
return useSyncExternalStore(subscribeSendShortcut, sendShortcut, sendShortcut);
}
export type RunningMessageIntent = "steer" | "followup";
const RUNNING_MESSAGE_KEY = "rx-running-message-intent";
const runningMessageListeners = new Set<() => void>();
let transientRunningMessage: RunningMessageIntent = "steer";
let runningMessageStorageFailed = false;
export function runningMessageIntent(): RunningMessageIntent {
if (runningMessageStorageFailed) return transientRunningMessage;
try {
return localStorage.getItem(RUNNING_MESSAGE_KEY) === "followup" ? "followup" : "steer";
} catch {
return transientRunningMessage;
}
}
export function setRunningMessageIntent(next: RunningMessageIntent): void {
transientRunningMessage = next;
try { localStorage.setItem(RUNNING_MESSAGE_KEY, next); runningMessageStorageFailed = false; } catch { runningMessageStorageFailed = true; }
runningMessageListeners.forEach((fn) => fn());
}
function subscribeRunningMessage(fn: () => void): () => void {
runningMessageListeners.add(fn);
const storage = (event: StorageEvent) => {
if (event.key === RUNNING_MESSAGE_KEY || event.key === null) fn();
};
window.addEventListener("storage", storage);
return () => { runningMessageListeners.delete(fn); window.removeEventListener("storage", storage); };
}
export function useRunningMessageIntent(): RunningMessageIntent {
return useSyncExternalStore(subscribeRunningMessage, runningMessageIntent, runningMessageIntent);
}
// The sidebar remains the default session switcher. Tabs are a local display
// choice and do not change which sessions stay open in the kernel.
const SESSION_TABS_KEY = "rx-show-session-tabs";
@@ -0,0 +1,51 @@
export interface ThemeSchedule { start: string; end: string }
const KEY = "rx-theme-schedule";
const DEFAULT: ThemeSchedule = { start: "20:00", end: "07:00" };
const listeners = new Set<() => void>();
let cached = DEFAULT;
let lastRaw: string | null | undefined;
let volatile: ThemeSchedule | null = null;
function valid(value: unknown): value is ThemeSchedule {
if (!value || typeof value !== "object") return false;
const { start, end } = value as ThemeSchedule;
return typeof start === "string" && typeof end === "string" &&
/^([01]\d|2[0-3]):[0-5]\d$/.test(start) && /^([01]\d|2[0-3]):[0-5]\d$/.test(end);
}
export function themeSchedule(): ThemeSchedule {
if (volatile) return volatile;
try {
const raw = localStorage.getItem(KEY);
if (raw !== lastRaw) {
lastRaw = raw;
cached = DEFAULT;
try {
const value: unknown = JSON.parse(raw ?? "null");
if (valid(value)) cached = { start: value.start, end: value.end };
} catch { /* unreadable preferences keep the default */ }
}
return cached;
} catch {
return cached;
}
}
export function onThemeScheduleChange(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
export function setThemeSchedule(next: ThemeSchedule): void {
if (!valid(next)) return;
cached = { start: next.start, end: next.end };
try {
lastRaw = JSON.stringify(cached);
localStorage.setItem(KEY, lastRaw);
volatile = null;
} catch {
volatile = cached;
}
listeners.forEach((fn) => fn());
}
+42 -6
View File
@@ -21,11 +21,15 @@ and selected states, while long titles truncate within the remaining width.
## Project header actions
- The project header is a non-shrinking sibling of the tree scroller. Local and
remote rows share that scroller; adding a project, folding all projects and
entering batch selection remain reachable at either end of the list.
- Batch selection returns focus to the header menu when it ends.
- The open-project and more-actions buttons share `--overlay` hover fill and
`--text` ink. The more-actions button retains both while its menu is open.
Project-row actions keep their `--raised` fill.
- `perf/project-header.mjs` checks hover parity, menu dismissal, keyboard focus
and the folder picker in both schemes.
- `perf/project-header.mjs` checks hover parity, menu dismissal, keyboard focus,
the folder picker and a long mixed local/remote list in both schemes.
## Reset and the window
@@ -257,8 +261,8 @@ and selected states, while long titles truncate within the remaining width.
back into scope, draws its tick and nothing else. `data-just-done` carries
that event and `data-run="done"` carries the state; keeping them apart is
what stops history from replaying as news.
- Unread is a fact the kernel owns, drawn beside the run tick and never merged with it: an 8px dot at the row end, so titles keep their column and it cannot be read as the project's status dot, a semibold title, and a spoken "未读" on the dot.
- In dark an unread row also takes a 4% tint, below hover and selection. In light it would sit one step from the selected row, so light has none. A remote row keeps its dot left of the delete button, and its turn count shifts left to make room.
- Unread is a fact the kernel owns, separate from run state: a green 8px dot replaces the session's date until it is marked viewed, with a semibold title and a localized spoken "未读" on the dot. Local and remote rows use the same date slot; a missing timestamp does not hide an unread completion.
- In dark an unread row also takes a 4% tint, below hover and selection. In light it would sit one step from the selected row, so light has none.
- A folded project row sums the unread sessions under it into an outlined count among its action buttons, unlike the filled feedback badge, and drops it when the project opens. A pane tab carries a 6px dot; the tab strip is currently hidden by the Studio sheet. Nothing here animates.
## The queue
@@ -457,7 +461,25 @@ and selected states, while long titles truncate within the remaining width.
keeps the reading width, where the line being typed stays one glance. The
full-all measure is the opt-in that moves `--column` with it, for a reader who
wants the composer on the same edges as the transcript.
- Below 680px both columns take the phone rule instead (see *Phones*).
- Custom percentage sets `--doc`, `--column` and `--wide` from
`--measure-width`: 30–100% of the conversation pane, with rail clearance.
- Settings → Appearance → Size → Line width offers presets and a 1% slider,
initially 80%. `desktop.appearance.width_percent` persists independently of
`desktop.conversation_width`, so selecting a preset retains the custom value.
- An unchanged effective percentage stays unset in the stored configuration.
- Only custom mode shows the conversation-and-composer hint; presets retain
the prose-width guidance for code, commands and tool output.
- The composer is a `conversation` container as well as the pane: toolbar labels
respond to the width of the composer, including a custom percentage, rather
than assuming it fills the pane.
- The default reading-width composer retains mode and workspace labels: labels
retire at 720px of actual composer space rather than at the pane's old 780px.
- The workspace control remains available even below 360px of composer space.
Model and effort labels shrink together before the toolbar wraps below 280px;
compacting labels must not remove access to workspace configuration.
- Below 760px the transcript and composer take the phone width instead. The
populated flow selector must retain enough specificity to override its
percentage width, not just the empty conversation's rule.
- Docking the agent's browser puts the pane on a grid, and the browser's column
spans the body's row and the composer's. Inside the conversation column `100%`
is that column, so the composer's own centring keeps it off the divider, and
@@ -850,6 +872,9 @@ what exists.
only content.
- A very faint gilt line under the header says this is the window's own thing,
not content.
- The header's expand/restore and close buttons share a 32px hit-region height,
matching the search control's minimum height. Inline-flex centres SVG and text
content independently of font fallback; native window controls retain their own sizing.
- The search entry sits beside the title. That position used to hold one
explanation addressed to two of the three tiers; that sentence now lives on
each settings block (`.apply`). Results are laid under the header without
@@ -1601,7 +1626,7 @@ A sub-agent row, by the list's own width:
- The title takes every pixel the row's visible content leaves it. The age sits against the row's right padding and the earlier-versions badge beside it; nothing is held back on the right for a control that is not showing.
- The hover buttons (archive) take no width until the row is hovered or focused, then 24px plus 6px; on a device without hover they are always drawn.
- Taking the room only while shown means the title shortens when the buttons appear, never before. Pinning stays an overlay at the left, so the pin never moves the title.
- An unread dot is an overlay 11px from the right edge: an unread row keeps 22px after its age (or after its badge, 26px) for it, and the dot hides while the buttons are shown.
- The unread dot takes the age's place in normal flow, before the versions or legacy badge. It stays visible beside hover actions without overlay spacing; clearing canonical unread restores the original age.
- A legacy `margin-right: 44px` on the age made room for a delete button the rail has long since hidden; the rail's rule sets it to zero.
- The current session reads as current in its fill and a 1px neutral outline, never in a state colour. The fill is a 13% text tint against hover's 4%.
- The outline is drawn inside the border box (`outline-offset: -1px`): the row's transparent 2px left border would push an inset shadow off the edge.
@@ -1622,6 +1647,10 @@ A sub-agent row, by the list's own width:
the edited title; pending requests disable all editing and dismissal.
- The native dialog owns focus containment and the backdrop. Its colours follow
the active light or dark palette.
- Typography follows the shared modal hierarchy: `--fs-title`/`--w-semi` for
the heading and `--fs-ui`/`--w-reg` for hints, input, errors and actions.
Interface size and weight preferences apply at every width; narrow screens
retain the same type roles rather than introducing another font size.
## Card shell
@@ -1661,6 +1690,7 @@ A sub-agent row, by the list's own width:
- Project headings have no separate chevron. Clicking a heading or using the tree arrow keys toggles its conversations; known empty projects do not advertise expansion. The heading toolbar switches all projects together, while search reveals matching conversations independently of saved folds.
- The project toolbar keeps collapse/expand beside the label and open-project plus and overflow at the right. The plus opens the common local/SSH location picker; project-row chat icons create conversations. Empty lists retain a text entry.
- Each project previews five recent sessions and keeps an older active/opening session visible. Search and explicit scopes show their matches. Show more reveals loaded rows and uses the existing bounded kernel paging path.
- Recent stays in the project toolbar rather than adding a fifth session segment. Its modal shows session identity, owning project and activity age. A separate page-status line keeps the three footer buttons on one row at phone widths.
- Time metadata comes only from canonical last-active timestamps. Loaded/total session counts remain available to assistive technology without visible badges.
- The sidebar defaults to 316px, respects saved widths and retains its existing 232px floor. Folder glyphs, titles, action slots and conversation indents align across environments. Hover actions reserve their space.
- The diagonal toolbar glyphs reuse Tabler's MIT-licensed [collapse][tabler-collapse] and [expand][tabler-expand] paths through the existing Studio icon component.
@@ -1713,3 +1743,9 @@ A sub-agent row, by the list's own width:
- Thinking expansion and collapsed previews are independent display preferences. Folding presets change expansion only; manual disclosure choices keep their priority.
- The optional preview occupies one line below the header, with a left rule and width-based ellipsis. It shows the opening text, including during streaming, rather than following the newest tokens.
- The preview remains inside the disclosure summary so it is visible and clickable while collapsed. Expanded reasoning replaces it; the settings demonstration uses the same preview styling.
## Collapsed plan progress
- Appearance → Conversation display offers “Show collapsed plan progress,” off by default and persisted on this machine. Changing it immediately updates mounted plan cards.
- A nonempty collapsed plan draws the expanded plan's `.prog2` track below its summary, using completed steps over total steps from `stepDone`. Pending and running steps do not count as complete.
- Opening the card replaces the optional track with the existing expanded progress. Goal-only, empty and hidden cards have no progress track; collapse, Escape, outside dismissal and focus return remain unchanged.
@@ -59,6 +59,15 @@ and at every contrast step, so nothing paints differently until a pack sets one.
- The running compose ring, call lines, pips, carets and the delegated nest keep
`net` and `deleg`.
## Composer guidance
- Composer guidance keeps its theme-owned `--ghost` ink at full opacity. The
Appearance visibility preference suppresses the placeholder text at its
choice owner, not by lowering contrast or changing shared neutral inks.
- `perf/composer-placeholder.mjs` checks the default-on preference, live
visibility changes and reload persistence through Appearance, alongside
entered text, focus, IME, submission and queued guidance.
## Elevation
| Pack key | Variable | Default | Job |
@@ -335,6 +344,10 @@ pack states a shape, never a number the layout has to agree with.
and fails on a size outside the scale.
- `settings-type.test.ts` holds the stylesheet to tokens, so a literal size or
colour cannot be added to it.
- The neutral ink the roles set on buttons would land on a primary action's
fill, where the component pairs `--accent-fg` with `--accent`; an enabled
`[data-primary]` button therefore keeps `--accent-fg`, and nothing else is
recoloured.
## Weight and contrast
+5 -35
View File
@@ -201,7 +201,7 @@
.btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); }
.picker { position: relative; flex: none }
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: var(--track-cap); color: var(--ok-ink); padding: 1.5px 6px; border-radius: var(--r-pill); background: color-mix(in srgb, var(--ok) 13%, transparent) }
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--ui); letter-spacing: var(--track-cap); color: var(--muted); padding: 1.5px 6px; border-radius: var(--r-pill); background: var(--overlay) }
.isolab[hidden] { display: none }
.hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) }
@@ -1731,7 +1731,7 @@
.turntools > .picker:first-of-type .nm { flex: 1; min-width: 0 }
.go { display: inline-flex; align-items: center; gap: 7px; flex: none }
.compose .row > * { position: relative; z-index: 1; animation: rowin var(--m-panel) both }
.compose .row > * { position: relative; z-index: 8; animation: rowin var(--m-panel) both }
@keyframes rowin { from { opacity: 0; transform: translateY(4px) } to { opacity: 1; transform: none } }
.compose .row [data-pending] { pointer-events: none; opacity: .5; transition: opacity var(--m-line) }
@@ -2470,7 +2470,7 @@
margin: 0; flex: 1; min-width: 0; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--faint);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.prefs-hd .btn { flex: none; align-self: center }
.prefs-hd > .btn { flex: none; align-self: center; height: 32px; display: inline-flex; align-items: center; justify-content: center }
.prefs-hd .esc { margin-left: 5px; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
.prefs-body { flex: 1; display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 0 }
@@ -2533,33 +2533,6 @@
.prefs-main[data-sec="ext"] .prefs-col { max-width: 980px }
.prefs-intro {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px;
margin-bottom: 20px; padding: 0 2px 17px; border-bottom: 1px solid var(--hair);
}
.prefs-intro-mark {
display: grid; place-items: center; width: 34px; height: 34px;
border: 1px solid var(--border);
border-radius: var(--r-md); background: var(--overlay); color: var(--muted);
}
.prefs-intro-mark svg {
width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.45;
stroke-linecap: round; stroke-linejoin: round;
}
.prefs-intro-copy { display: flex; flex-direction: column; gap: 2px; min-width: 0 }
.prefs-intro-overline {
font: var(--w-semi) var(--fs-micro) var(--ui); letter-spacing: .14em; text-transform: uppercase; color: var(--faint);
}
.prefs-intro h2 { margin: 0; font: var(--w-semi) var(--fs-title)/1.25 var(--ui); color: var(--text); letter-spacing: -.01em }
.prefs-intro-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; min-width: 0 }
.prefs-intro-meta strong {
max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font: var(--w-med) var(--fs-ui) var(--mono); color: var(--muted);
}
.prefs-intro-meta > span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
.prefs-intro-meta[data-danger] strong { color: var(--err-ink) }
.grp {
margin-bottom: 16px; padding: 19px 20px 17px; outline: 3px solid transparent; outline-offset: 2px;
border-radius: var(--r-md); background: var(--surface);
@@ -2659,11 +2632,6 @@
.prefs-find { grid-column: 1 / -1; grid-row: 2; width: 100%; max-width: none; margin-left: 0 }
.prefs-found { right: 14px; width: calc(100% - 28px) }
.prefs-main { --main-pad-b: 40px; padding: 20px 14px var(--main-pad-b) }
.prefs-intro { grid-template-columns: auto minmax(0, 1fr); gap: 11px; margin-bottom: 16px }
.prefs-intro-meta {
grid-column: 2; flex-direction: row; align-items: baseline; justify-content: space-between;
}
.prefs-intro-meta strong { max-width: 60% }
.grp { padding: 17px 15px 15px }
.advset { padding-inline: 15px }
}
@@ -5059,6 +5027,8 @@
}
.fontown::placeholder { color: var(--ghost) }
.fontown:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px }
.fontown[type="time"] { color-scheme: light }
:root[data-theme="dark"] .fontown[type="time"] { color-scheme: dark }
.fontsample {
padding: 7px 10px; border-radius: var(--r-sm); background: var(--overlay);
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
const SHEETS = import.meta.glob(["./app.css", "./studio.css"], { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const layer = (sheet: string, selector: string) => {
const esc = selector.replace(/[.*+?^${}()|[\]\\>]/g, "\\$&");
const body = SHEETS[sheet].match(new RegExp(`(?:^|\\n)\\s*${esc}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
return Number(body.match(/z-index:\s*(\d+)/)?.[1]);
};
describe("composer layers", () => {
it("keeps the controls row, and the menus it opens, above the plan bar that floats over it", () => {
expect(layer("./app.css", ".compose .row > *")).toBeGreaterThan(layer("./studio.css", ".studio-todo"));
});
});
@@ -1,4 +1,3 @@
.prefs-main[data-sec="persona"]:has(.preset-workspace) .prefs-intro { display: none; }
.prefs-main[data-sec="persona"]:has(.preset-page) { padding: 0; overflow: hidden; }
.prefs-col:has(.preset-page) { height: 100%; max-width: none; animation: none; }
.preset-workspace[data-editor] { height: 100%; min-height: 0; }
@@ -35,7 +35,7 @@ const MARKS: Record<string, string> = {
// change per call site rather than per rule — recorded so the count cannot grow
// while the work is outstanding. Never widen this to land a change.
const MIXED = new Set([
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "isolab", "k",
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "k",
"lb", "meta", "mk", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt",
"sc", "st", "sym", "sz", "tag", "u", "uspan", "v",
]);
+7 -1
View File
@@ -5,13 +5,19 @@
.studio-model-control { flex: 0 1 auto; min-width: 0; }
.studio-model-control .model-picker .nm { max-width: none; }
}
@container conversation (max-width: 360px) {
.studio-effort-control { flex: 0 1 auto; }
.studio-effort-control > .picker { min-width: 0; max-width: 100%; }
.studio-effort-picker { min-width: 0; max-width: 100%; }
.studio-effort-picker > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
.chrome .r-fold { display: contents; }
.chrome .crumb-context { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
.chrome .crumb-context .isolab { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
[data-fold~="bar"] .chrome .r-fold { display: none; position: absolute; top: calc(100% + 6px); right: 12px; z-index: 80; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); box-shadow: 0 12px 32px -14px #000; }
[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; }
[data-fold~="bar"] .tab { flex: 0 1 auto; min-width: 0; padding-inline: 6px; overflow: hidden; text-overflow: ellipsis; }
@container conversation (max-width: 360px) {
@container conversation (max-width: 280px) {
.turntools { flex-wrap: wrap; }
.studio-effort-picker::before { display: none; }
.studio-model-group { flex-basis: 100%; flex-wrap: wrap; }
@@ -8,12 +8,12 @@
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-float);
font-family: var(--ui);
font: var(--w-reg) var(--fs-ui)/1.55 var(--ui);
}
.session-rename-dialog::backdrop { background: rgb(0 0 0 / .28); }
.session-rename-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.session-rename-dialog h2 { margin: 0; font-size: calc(24px * var(--fs-scale, 1)); font-weight: 650; line-height: 1.3; }
.session-rename-dialog p { margin: 5px 0 18px; color: var(--muted); font-size: calc(16px * var(--fs-scale, 1)); }
.session-rename-dialog h2 { margin: 0; font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.session-rename-dialog p { margin: 5px 0 18px; color: var(--muted); }
.session-rename-dialog input {
display: block;
box-sizing: border-box;
@@ -25,7 +25,6 @@
background: var(--raised);
color: var(--text);
font: inherit;
font-size: calc(16px * var(--fs-scale, 1));
}
.session-rename-dialog footer { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.session-rename-dialog button {
@@ -36,18 +35,17 @@
background: var(--raised);
color: var(--text);
font: inherit;
font-size: calc(16px * var(--fs-scale, 1));
cursor: pointer;
}
.session-rename-dialog .session-rename-auto { margin-right: auto; }
.session-rename-dialog .session-rename-save { border-color: var(--text); background: var(--text); color: var(--surface); }
.session-rename-dialog .session-rename-close { display: grid; place-items: center; min-height: 28px; width: 28px; padding: 0; border: 0; background: transparent; }
.session-rename-dialog .session-rename-close svg { width: 16px; height: 16px; }
.session-rename-dialog .session-rename-error { margin: 12px 0 0; color: var(--err); font-size: calc(14px * var(--fs-scale, 1)); }
.session-rename-dialog .session-rename-error { margin: 12px 0 0; color: var(--err); }
.session-rename-dialog button:disabled { opacity: .5; cursor: default; }
.session-rename-dialog :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 400px) {
.session-rename-dialog { padding: 20px; }
.session-rename-dialog footer { gap: 8px; }
.session-rename-dialog button { padding: 0 12px; font-size: calc(14px * var(--fs-scale, 1)); }
.session-rename-dialog button { padding: 0 12px; }
}
@@ -30,7 +30,6 @@
.rail .sessrow:has(> .sessedit) > :is(.sesspin-action, .sesspin-marker, .sessquick) { visibility: hidden; pointer-events: none; }
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within)::before,
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within) > .pip { visibility: hidden; }
.rail .sessrow:has(.sessquick):is(:hover, :focus-within) > .unread-dot { visibility: hidden; }
@media (hover: none) {
.rail .sessquick { width: 24px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
.rail .sesspin-action { opacity: 1; pointer-events: auto; }
@@ -2,7 +2,8 @@
.prefs-col :is(strong, b, h4, th, .nm, .who, .lbl, .kind, .me-name, .spon-link, .paperpick, .rmtadd, .uhero-scope, .ttl, .comm-name, .btn:not(.sm)) { font-size: var(--fs-ui); font-weight: var(--w-semi); color: var(--text); }
.prefs-col .grp-hd h3 { font-size: var(--fs-lead); font-weight: var(--w-semi); color: var(--text); }
.prefs-col :is(button.act, label.act, .btn.sm) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--text); }
.prefs-col :is(.prefs-intro-overline, .k, .uhero-k, .uhero-estk, .composition-builtin-badge) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--muted); }
.prefs-col :is(.k, .uhero-k, .uhero-estk, .composition-builtin-badge) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--muted); }
.prefs-col :is(.v, .n, text, .composition-builtin-description) { font-weight: var(--w-reg); }
.prefs-col :is(.cur, div.v) { font-size: var(--fs-display); font-weight: var(--w-semi); }
.prefs-col .mi-mono { font-size: var(--fs-meta); }
.prefs-col :is(.btn, .act)[data-primary]:not(:disabled) { color: var(--accent-fg); }
@@ -18,6 +18,11 @@ it("keeps the settings scope on two neutral text tiers and one emphasis weight",
expect(scope).toMatch(/--w-med:\s*var\(--w-semi\)/);
});
it("only ever names a neutral text colour from the two tiers", () => {
for (const d of declarations.filter((x) => x.prop === "color")) expect(d.value).toMatch(/^var\(--(text|muted)\)$/);
it("only ever names a neutral text colour from the two tiers, bar a primary action's paired foreground", () => {
for (const d of declarations.filter((x) => x.prop === "color" && x.value !== "var(--accent-fg)")) expect(d.value).toMatch(/^var\(--(text|muted)\)$/);
});
it("lets an enabled primary action keep the foreground its fill is paired with", () => {
const rule = css.match(/\.prefs-col :is\(\.btn, \.act\)\[data-primary\]:not\(:disabled\)\s*\{([^}]*)\}/)?.[1] ?? "";
expect(rule).toMatch(/color:\s*var\(--accent-fg\)/);
});
+11 -26
View File
@@ -871,7 +871,7 @@ body { background: var(--page) !important; }
.activity-group { --fs-scale: var(--activity-scale, 1); margin: var(--sp-4) 0 0; overflow: clip; }
.activity-group > summary {
min-height: 28px; padding: 0; display: flex; align-items: center; gap: var(--sp-3);
color: var(--faint); font-size: calc(11px * var(--fs-scale, 1)); font-weight: 500; line-height: 1.5;
color: var(--faint); font-size: var(--fs-meta); font-weight: 500; line-height: 1.5;
cursor: pointer; user-select: none;
}
.activity-group > summary:hover { color: var(--muted); }
@@ -894,11 +894,11 @@ body { background: var(--page) !important; }
border-radius: 4px;
color: var(--net);
background: var(--net-wash);
font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui);
font: 500 var(--fs-meta)/1.5 var(--ui);
}
.activity-title { color: inherit; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui); }
.activity-errors { color: var(--err-ink); font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui); }
.activity-count { margin-left: 0; color: var(--faint); font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui); }
.activity-title { color: inherit; font: 500 var(--fs-meta)/1.5 var(--ui); }
.activity-errors { color: var(--err-ink); font: 500 var(--fs-meta)/1.5 var(--ui); }
.activity-count { margin-left: 0; color: var(--faint); font: 500 var(--fs-meta)/1.5 var(--ui); }
.activity-fold-icon { margin-left: 2px; color: var(--faint); transition: transform 140ms ease; }
.activity-group[open] .activity-fold-icon { transform: rotate(180deg); }
.activity-group[open] > summary { position: sticky; top: 0; z-index: 1; }
@@ -1683,7 +1683,7 @@ body { background: var(--page) !important; }
@media (max-width: 760px) {
.studio-brand-sub { display: none; }
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
.flow, .flow:not([data-empty]) { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
.compose { width: calc(100% - 20px); margin-inline: auto; }
}
@@ -1928,11 +1928,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
}
.rail .sessrow[data-run="done"] { animation: none; }
.rail .sessrow[data-just-done] { animation: studio-session-done 1.2s ease-out both; }
.rail .sessrow > .unread-dot { position: absolute; inset-inline-end: 11px; top: 50%; translate: 0 -50%; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-ink); pointer-events: none; }
.rail .sessrow[data-unread] > .sesscopies { margin-inline-end: 26px; }
.rail .sessrow-context[data-unread] > .sesstitle { padding-inline-end: 26px; }
.rail .sessrow-remote > .unread-dot { inset-inline-end: 36px; }
.rail .sessrow-remote[data-unread] > .sessmeta { margin-inline-end: 58px; }
.rail .sessrow > .unread-dot { flex: none; margin-inline-start: auto; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); pointer-events: none; }
.rail .sessrow[data-unread] .sesstitle { color: var(--text); font-weight: var(--w-semi); }
:root[data-theme="dark"] .rail .sessrow[data-unread]:not(:hover):not([aria-selected="true"]) { background: color-mix(in srgb, var(--text) 4%, transparent); }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .rail .sessrow[data-unread]:not(:hover):not([aria-selected="true"]) { background: color-mix(in srgb, var(--text) 4%, transparent); } }
@@ -1940,7 +1936,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
@keyframes studio-session-run { 55%, 100% { transform: translateX(110%); } }
@keyframes studio-session-wait { 50% { background: color-mix(in srgb, var(--warn) 8%, transparent); } }
@keyframes studio-session-done { from { background: color-mix(in srgb, var(--ok) 18%, transparent); } }
.rail .sessrow > .wsdel { display: none; }
.rail .session-more {
position: absolute; top: 7px; right: 4px;
width: 27px;
@@ -2042,7 +2037,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
.studio-todo-sum > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.studio-todo-sum small { flex: none; color: var(--faint); font: 600 calc(9px * var(--fs-scale, 1))/1 var(--mono); font-variant-numeric: tabular-nums; }
.studio-todo-fold { transition: transform var(--m-line) }
.studio-todo[data-open] .studio-todo-fold { transform: rotate(180deg) }
.studio-todo[data-open] .studio-todo-fold { transform: rotate(180deg) } .studio-todo-progress { padding: 0 12px 8px; }
.studio-todo-body {
position: absolute; left: -1px; right: -1px; bottom: calc(100% + 6px); z-index: 8; max-height: min(46vh, 380px); overflow-y: auto; overscroll-behavior: contain;
padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: var(--shadow-float); animation: todo-in var(--m-card) var(--ease-out) both;
@@ -2384,14 +2379,12 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
.compose .policy > .mode { min-height: 32px; padding-inline: 7px; font-size: calc(10px * var(--fs-scale, 1)); }
.btn.send { width: 32px; height: 32px; }
/* The composer adapts to its conversation column, including when Browser is
open. Retire labels by priority before allowing controls to wrap. */
.pane { container: conversation / inline-size; }
.pane, .compose { container: conversation / inline-size; }
.turntools { min-width: 0; min-height: 32px; align-items: center; }
.studio-model-control .model-picker { width: auto; max-width: 100%; }
.studio-model-control .model-picker .nm { min-width: 0; }
@container conversation (max-width: 780px) {
@container conversation (max-width: 720px) {
.studio-branch-pop .studio-branch { width: 30px; justify-content: center; padding-inline: 0; }
.studio-branch-pop .studio-branch .lb,
.studio-branch-pop .studio-branch small { display: none; }
@@ -2430,7 +2423,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
}
@container conversation (max-width: 360px) {
.studio-branch-pop { display: none; }
.studio-meterrail .studio-meter-tariff i,
.studio-meterrail .studio-meter-cost,
.studio-meterrail .studio-meter-mask { display: none; }
@@ -3356,8 +3348,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
background: color-mix(in srgb, var(--page) 82%, var(--float)) !important;
scrollbar-color: color-mix(in srgb, var(--muted) 34%, transparent) transparent;
}
.prefs-intro { border-color: var(--hair); }
.prefs-intro-mark { background: var(--accent-wash); border-color: var(--hair); color: var(--accent); }
.grp,
.advset,
.setting {
@@ -3585,7 +3575,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
.prefs {
--prefs-body: var(--fs-ui);
--prefs-meta: var(--fs-meta);
--prefs-title: var(--fs-title);
font-family: var(--ui);
font-size: var(--prefs-body);
}
@@ -3593,17 +3582,13 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
.prefs-find,
.prefs-nav button,
.prefs-found .what { font-size: var(--fs-ui); }
.prefs-nav .navsec,
.prefs-intro-overline {
.prefs-nav .navsec {
font-size: calc(11.5px * var(--fs-scale, 1));
letter-spacing: .08em;
text-transform: none;
}
.prefs-nav .nv,
.prefs-intro-meta > span,
.apply { font-size: var(--prefs-meta); }
.prefs-intro h2 { font-size: var(--prefs-title); }
.prefs-intro-meta strong { font-family: var(--ui); font-size: var(--fs-ui); }
.grp > .grp-hd h3, .grp > h3.grp-hd { font-size: var(--fs-lead); }
.grp .hint,
.grp .note,
@@ -216,6 +216,7 @@
}
:root[data-measure="full"], :root[data-measure="full-all"] { --doc: 100%; --wide: 100% }
:root[data-measure="full-all"] { --column: 100% }
:root[data-measure="custom"] { --doc: var(--measure-width); --column: var(--measure-width); --wide: var(--measure-width) }
:root[data-weight="heavy"] { --w-reg: 500; --w-med: 600; --w-semi: 700 }
+15 -3
View File
@@ -1,9 +1,10 @@
.projects-header { margin: 8px 15px 6px; }
.projects-header { flex: none; margin: 8px 15px 6px; }
.projects-heading { display: flex; align-items: center; justify-content: space-between; min-height: 28px; padding-inline: 7px; color: var(--faint); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); }
.projects-heading-actions { display: flex; align-items: center; gap: 2px; }
.projects-heading-title { display: flex; align-items: center; gap: 6px; }
.projects-add { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: 6px; padding: 0; background: transparent; color: var(--muted); cursor: pointer; }
.projects-add .studio-icon { width: 16px; height: 16px; }
.projects-add.projects-recent { width: auto; padding-inline: 6px; font: inherit; }
.projects-add:hover:not(:disabled), .projects-heading .wsmore:is(:hover, [aria-expanded="true"]) { background: var(--overlay); color: var(--text); }
.projects-add:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
.projects-add:disabled { opacity: .4; cursor: default; }
@@ -47,8 +48,7 @@
.rail .rmtws-node .sessdel { opacity: 0; }
.rail .rmtws-node .sessrow:is(:hover, :focus-within) .sessdel { opacity: 1; }
.rail .sessage { margin-left: auto; margin-right: 0; font-size: var(--fs-micro); }
.rail .sessage + .sesscopies { margin-inline-start: 6px; }
.rail .sessrow[data-unread]:not(:has(> .sesscopies)) > .sessage { margin-inline-end: 22px; }
.rail :is(.sessage, .unread-dot) + .sesscopies { margin-inline-start: 6px; }
.rail .sessmore { width: calc(100% - 12px); margin: 2px 0 5px 3px; padding-left: 9px; text-align: left; }
.rail .sessrow:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
.rail .sessrow[aria-selected="true"] { background: color-mix(in srgb, var(--text) 13%, transparent); color: var(--text); font-weight: var(--w-reg); box-shadow: none; }
@@ -64,3 +64,15 @@
@media (hover: none) { .rowbtns { opacity: 1; } }
.studio-session-segments button { font-size: calc(12px * var(--fs-scale, 1)); }
.studio-session-segments b { font-size: calc(10px * var(--fs-scale, 1)); }
.recent-description { margin: 0 0 12px; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.recent-machine { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--muted); }
.recent-machine select { flex: 1; min-width: 0; padding: 5px 8px; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xs); font: var(--w-reg) var(--fs-ui) var(--ui); }
.recent-sessions .picklist { min-height: 160px; }
.recent-session { min-height: 48px; }
.recent-session .recent-session-text { display: flex; flex: 1; flex-direction: column; gap: 3px; }
.recent-session-text b, .recent-session-text small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-session-text b { color: var(--text); font-weight: var(--w-med); }
.recent-session-text small { color: var(--muted); font-size: var(--fs-micro); }
.recent-session .sessage { flex: none; margin-left: auto; color: var(--muted); }
.recent-sessions .pickact { flex-wrap: wrap; }
.recent-page { order: -1; flex-basis: 100%; text-align: center; white-space: nowrap; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
@@ -8,11 +8,12 @@ const rule = (selector: string) => {
};
describe("the unread mark in the rail", () => {
it("sits at the row end without moving the title", () => {
it("uses a green mark in the date slot without overlaying the title or quick actions", () => {
const dot = rule(".rail .sessrow > .unread-dot");
expect(dot).toMatch(/position:\s*absolute/);
expect(dot).toMatch(/inset-inline-end/);
expect(dot).not.toMatch(/margin/);
expect(dot).toMatch(/margin-inline-start:\s*auto/);
expect(dot).toMatch(/flex:\s*none/);
expect(dot).toMatch(/background:\s*var\(--ok\)/);
expect(dot).not.toMatch(/position:\s*absolute|inset-inline-end/);
expect(rule(".rail .sessrow[data-unread] .sesstitle")).not.toMatch(/padding-inline-start|margin/);
});
@@ -22,12 +23,6 @@ describe("the unread mark in the rail", () => {
expect(dark).toMatch(/background:\s*color-mix\(in srgb, var\(--text\) 4%, transparent\)/);
});
it("keeps the dot clear of the remote row's delete button", () => {
const more = 27 + 4;
const end = Number(rule(".rail .sessrow-remote > .unread-dot").match(/inset-inline-end:\s*(\d+)px/)?.[1]);
expect(end).toBeGreaterThanOrEqual(more + 4);
});
it("draws the folded count unlike the feedback badge", () => {
const pill = rule(".rail .wsacts > .unread-count");
expect(pill).toMatch(/border:\s*1px solid/);
@@ -3,7 +3,7 @@
.worktree-setup-context { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 24px }
.worktree-setup-context > .studio-icon { color: var(--brand); width: 24px; height: 24px; flex-shrink: 0 }
.worktree-setup-context strong { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-lead); font-weight: var(--w-med) }
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-micro) }
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--overlay); color: var(--muted); font-size: var(--fs-micro) }
.worktree-setup h1 { margin: 0; font: var(--w-med) var(--fs-display)/1.3 var(--ui); letter-spacing: -.025em }
.worktree-setup-description { margin: 10px 0 22px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.7 }
.worktree-setup-phase { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 13px 14px; border-radius: 9px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-ui) }
@@ -1,6 +1,6 @@
.worktree-trigger .lb { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
:root .compose .mode.worktree-trigger[data-on] { color: var(--brand); background: var(--brand-wash) }
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--brand) }
:root .compose .mode.worktree-trigger[data-on] { color: var(--muted); background: var(--overlay) }
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--muted) }
.workspace-palette { position: fixed; z-index: 1000; width: min(400px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 16px; border: 1px solid var(--hair); border-radius: 16px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui) }
.workspace-palette .studio-icon { width: 18px; height: 18px; flex-shrink: 0 }
.workspace-palette-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--hair); font-size: var(--fs-ui); font-weight: var(--w-med) }
@@ -9,7 +9,7 @@
.workspace-palette-identity { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 16px }
.workspace-palette-identity > .studio-icon { width: 23px; height: 23px; color: var(--brand) }
.workspace-palette-identity > strong { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: var(--w-med) }
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--brand); background: var(--brand-wash); font-size: var(--fs-micro) }
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--muted); background: var(--overlay); font-size: var(--fs-micro) }
.worktree-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; margin: 0 0 14px; background: var(--raised); border: 1px solid var(--hair); border-radius: 9px }
.worktree-modes button { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 6px; min-height: 36px; padding: 6px; color: var(--muted); background: none; font: inherit; font-size: var(--fs-meta); cursor: pointer }
.worktree-modes button[aria-pressed="true"] { background: var(--float); border-color: var(--hair); color: var(--text) }
+2
View File
@@ -84,6 +84,7 @@ const Settings = lazy(async () => {
const NO_REPORT: PaneReport = {
status: null,
workspace: null,
title: "",
steer: 0,
run: "idle",
@@ -561,6 +562,7 @@ export function App({ hub }: { hub: HubPort }) {
host={runtimes.find((rt) => rt.id === active)?.host}
port={activePort}
status={report.status}
workspace={report.workspace}
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
steer={report.steer}
account={account}
+31 -9
View File
@@ -18,12 +18,15 @@ import { LegacySessionsSection } from "./LegacySessionsSection";
import { OpenSessionLimitSection } from "./OpenSessionLimitSection";
import { LiveStreamBudgetSection } from "./LiveStreamBudgetSection";
import { NewAnswerSection } from "./NewAnswerSection";
import { PlanProgressSection } from "./PlanProgressSection";
import { ComposerHintSection } from "./ComposerHintSection";
import { SessionQuickSection } from "./SessionQuickSection";
import { NavEntriesSection } from "./NavEntriesSection";
import { NavRailSection } from "./NavRailSection";
import { GraphicsSection } from "./GraphicsSection";
import { GlobalShortcutSection } from "./GlobalShortcutSection";
import { SendShortcut } from "./SendShortcut";
import { RunningMessageSetting } from "./RunningMessageSetting";
import { KeysSheet } from "./KeysSheet";
import { SessionTabsSetting } from "./SessionTabsSetting";
import { EscStopSetting } from "./EscStopSetting";
@@ -33,6 +36,7 @@ import { ThemeRemove } from "./ThemeRemove";
import { WallpaperSceneImport } from "./WallpaperSceneImport";
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
import { useThemeInventory } from "./useThemeInventory";
import { ThemeScheduleFields } from "./ThemeScheduleFields";
// "" follows the machine; the rest are explicit, the same shape the light/dark
// control uses.
@@ -47,6 +51,7 @@ export const SCHEMES: [string, string][] = [
["auto", "跟随系统"],
["light", "浅色"],
["dark", "深色"],
["scheduled", "定时"],
];
interface Props {
@@ -74,13 +79,11 @@ const WEIGHTS: [string, string, string][] = [
["heavy", "加粗", "小字号下更扎实,屏幕距离较远或有反光时更易读"],
];
// Two answers, because there are two: a line short enough that the eye finds
// the next one without hunting, or a window used to its edge. A step between
// them would be a number nobody could tell apart from its neighbour.
const MEASURES: [string, string, string][] = [
["standard", "适宜阅读", "每行长度控制在一眼能回到行首的范围"],
["full", "铺满窗口", "正文跟随窗口宽度,宽屏上不留两侧空白"],
["full-all", "连输入框一起铺满", "正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首"],
["custom", "自定义比例", "按比例调整对话和输入框占用的区域"],
];
const EFFECTS: [EffectsMode, string, string][] = [
@@ -159,6 +162,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
// Until the kernel has announced its range there is nothing to draw the
// slider from; it is held inert rather than given a range of its own.
const zoomRange = look.zoomRange ?? { min: 0, max: 2, step: 1 };
const widthRange = look.widthRange ?? { min: 0, max: 2, step: 1 };
const setPaper = useCallback(
(patch: Partial<NonNullable<Look["wallpaper"]>>) =>
look.wallpaper && onLook({ ...look, wallpaper: { ...look.wallpaper, ...patch } }),
@@ -233,7 +237,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<div className="grp-hd">
<h3>{t("明暗")}</h3>
</div>
<p className="hint">{t("跟随系统时会随系统主题切换;手动选择后将保持固定。")}</p>
<p className="hint">{t("跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。")}</p>
<div className="grp-items">
<div className="seg" data-text role="group" aria-label={t("明暗")}>
{SCHEMES.map(([id, name]) => (
@@ -242,6 +246,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</button>
))}
</div>
{theme === "scheduled" && <ThemeScheduleFields />}
</div>
<ApplyNote id="mode" />
</section>
@@ -361,21 +366,35 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
))}
</div>
</div>
{/* Beside the size because they are one question asked twice: how big
the letters are, and how far they run before the eye comes back. */}
<div className="prow">
<span className="tx">{t("行宽")}</span>
<div className="seg" data-text role="group" aria-label={t("正文行宽")}>
{MEASURES.map(([id, name, why]) => (
<button key={id} data-action="appearance.measure" data-value={id}
aria-pressed={(look.width || "standard") === id} title={t(why)} onClick={() => set({ width: id })}>
aria-pressed={(look.width || "standard") === id} title={t(why)}
disabled={id === "custom" && !look.widthRange} onClick={() => set({ width: id })}>
{t(name)}
</button>
))}
</div>
</div>
{look.width === "custom" && (
<div className="prow">
<span className="tx">{t("对话区域宽度")}</span>
<input className="slider" type="range"
style={at(look.widthPercent ?? 1, widthRange.min, widthRange.max)}
min={widthRange.min} max={widthRange.max} step={widthRange.step}
disabled={!look.widthRange} value={look.widthPercent ?? 1}
aria-label={t("对话区域宽度")} aria-valuetext={pct((look.widthPercent ?? 1) / 100)}
data-action="appearance.measure" data-value="custom"
onChange={(e) => set({ widthPercent: Number(e.target.value) })} />
<span className="now">{pct((look.widthPercent ?? 1) / 100)}</span>
</div>
)}
</div>
<p className="hint">{t("行宽只管正文:代码、命令与工具输出始终占满可用宽度。")}</p>
<p className="hint">{t(look.width === "custom"
? "自定义比例同时调整对话和输入框;窄窗口会优先保证可用空间。"
: "行宽只管正文:代码、命令与工具输出始终占满可用宽度。")}</p>
<ApplyNote id="size" />
</section>
@@ -556,12 +575,15 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<NewAnswerSection />
<GraphicsSection />
<SendShortcut />
<ComposerHintSection />
<RunningMessageSetting />
<EscStopSetting />
<GlobalShortcutSection />
<KeysSheet />
<EscStopSetting />
<PageSleepSetting />
<SessionTabsSetting />
<Folding />
<PlanProgressSection />
<Notifications port={port} />
{/* What makes this page read as a console is that all of it arrives at
+6 -11
View File
@@ -10,6 +10,7 @@ import { ariaChord, chord } from "./keys";
import { PhonePop } from "./PhonePop";
import { ViewSwitch } from "./ViewSwitch";
import { TooltipButton } from "./TooltipButton";
import { workspaceDirectoryDescription } from "./workspaceDirectory";
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
import type { PaneView } from "../state/paneview";
@@ -27,6 +28,7 @@ interface Props {
// controls that need a session simply do nothing until one is focused.
port: AgentPort | null;
status: SessionStatus | null;
workspace: WorkspaceInfo | null;
title?: string;
steer: number;
account: AccountState | null;
@@ -43,7 +45,7 @@ interface Props {
onError?: (e: unknown) => void;
}
export function Chrome({ port, status, title, steer, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
export function Chrome({ port, status, workspace: ws, title, steer, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
const root = status?.workspaceRoot || status?.cwd || "";
const project = root ? base(root) : "—";
// The folder mark opens the project itself. Only a shell that can enter a
@@ -55,13 +57,6 @@ export function Chrome({ port, status, title, steer, account, host, theme, onThe
const openProject = () => {
if (hub && root) void hub.openWorkspace(root).catch((e) => onError?.(e));
};
const [ws, setWs] = useState<WorkspaceInfo | null>(null);
useEffect(() => {
let current = true;
setWs(null);
if (port) port.workspaces().then((value) => { if (current) setWs(value); }).catch(() => { if (current) setWs(null); });
return () => { current = false; };
}, [port, root]);
const gathered = useFolded("bar");
const [more, setMore] = useState(false);
@@ -99,9 +94,9 @@ export function Chrome({ port, status, title, steer, account, host, theme, onThe
<span className="crumb-proj" title={root}>
{project}
</span>
<span className="isolab" hidden={!ws || ws.current !== root} title={root}>
{ws?.isolated ? t("隔离目录") : t("原目录")}
</span>
{ws?.isolated && ws.current === root && <span className="isolab" title={`${workspaceDirectoryDescription(true)}\n${root}`}>
{t("独立工作树")}
</span>}
<span className="isolab hostlab" hidden={!host}>
{host}
</span>
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Composer } from "./Composer";
import { ComposerHintSection } from "./ComposerHintSection";
import { MockPort } from "../port/mock";
import { boot, STORAGE } from "../i18n";
import { onComposerHintChange, setShowsComposerHint, showsComposerHint } from "../state/prefs";
beforeEach(() => { localStorage.clear(); localStorage.setItem(STORAGE, "zh"); boot(); });
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
function draw(queued = false) {
const onSubmit = vi.fn(async () => true);
const view = render(<Composer port={new MockPort()} status={null} running={queued} queued={queued} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()} />);
return { ...view, onSubmit, box: screen.getByRole<HTMLTextAreaElement>("combobox", { name: "任务输入" }) };
}
describe("composer hint visibility", () => {
it("defaults on, accepts only explicit off, and tolerates unavailable storage", () => {
expect(showsComposerHint()).toBe(true);
for (const value of ["", "on", "false", "garbage"]) {
localStorage.setItem("rx-composer-hint", value);
expect(showsComposerHint()).toBe(true);
}
setShowsComposerHint(false);
expect(showsComposerHint()).toBe(false);
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
expect(showsComposerHint()).toBe(true);
expect(() => setShowsComposerHint(false)).not.toThrow();
get.mockRestore();
set.mockRestore();
});
it("notifies mounted subscribers and removes them on unsubscribe", () => {
const heard = vi.fn();
const stop = onComposerHintChange(heard);
setShowsComposerHint(false);
stop();
setShowsComposerHint(true);
expect(heard).toHaveBeenCalledTimes(1);
});
it.each([false, true])("applies immediately and persists on remount (queued=%s)", async (queued) => {
const settings = render(<ComposerHintSection />);
const view = draw(queued);
expect(view.box.placeholder.length).toBeGreaterThan(0);
await userEvent.click(within(settings.container).getByRole("button", { name: "关闭" }));
expect(view.box.placeholder).toBe("");
expect(view.box.getAttribute("aria-label")).toBe("任务输入");
expect(view.box.getAttribute("aria-describedby")).toBeTruthy();
expect(localStorage.getItem("rx-composer-hint")).toBe("off");
view.unmount();
const remounted = draw(queued);
expect(remounted.box.placeholder).toBe("");
await userEvent.click(within(settings.container).getByRole("button", { name: "显示" }));
await waitFor(() => expect(remounted.box.placeholder.length).toBeGreaterThan(0));
expect(localStorage.getItem("rx-composer-hint")).toBe("on");
});
it("keeps entered text, focus, IME confirmation and submission when hidden", async () => {
localStorage.setItem("rx-composer-hint", "off");
const { box, onSubmit } = draw();
await userEvent.type(box, "implement the change");
expect(box.value).toBe("implement the change");
expect(document.activeElement).toBe(box);
fireEvent.compositionStart(box);
fireEvent.keyDown(box, { key: "Enter", isComposing: true });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.compositionEnd(box);
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 120);
fireEvent.keyDown(box, { key: "Enter" });
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("implement the change"));
});
});
+14 -11
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit, WorkspaceInfo } from "../port/port";
import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
@@ -18,7 +18,7 @@ import { ChipMirror, useSkillChips } from "./ChipMirror";
import { useIme } from "./ime";
import { usePlaceholderFit } from "./placeholderfit";
import { useFitHeight } from "./fitHeight";
import { useSendShortcut } from "../state/prefs";
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
import { sendAria, sendHint, sendsOnKey } from "./sendkey";
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { kindOf, nameOf, previewURL } from "./chipfile";
@@ -68,10 +68,10 @@ interface Props {
sidePanel?: ReactNode;
changeCount?: number;
countElsewhere?: boolean;
// The work tree's Git state as git itself reports it, refreshed on the same
// path as changeCount (turn boundaries, writes). null is "not answered yet";
// repo:false is a workspace with no repository, and the two render apart.
// Git state follows changeCount's refresh path (turn boundaries and writes).
// null is unread; repo:false is a workspace without a repository.
git?: WorkspaceGit | null;
workspace?: WorkspaceInfo | null;
// The switch already answers with the workspace's Git state.
onTreeChanged?: (git: WorkspaceGit) => void;
// Bumped when settings change; a source edited there can change the ladder.
@@ -79,7 +79,7 @@ interface Props {
draftKey?: string;
}
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, workspace = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
const touch = touchKeyboard();
const providerOrder = useProviderOrder();
const modelGroups = useModelFilter();
@@ -195,6 +195,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
}, chips, addSession);
const ime = useIme();
const sendShortcut = useSendShortcut();
const runningIntent = useRunningMessageIntent();
// A counter, not a boolean: asking twice in a row has to move the cursor twice.
useEffect(() => {
@@ -217,7 +218,9 @@ export function Composer({ port, status, running, queued = false, quote, restore
pending.current = null;
}, []);
useFitHeight(box, text, placeCaret);
const placeholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
const showHint = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
const fittedPlaceholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
const placeholder = showHint ? fittedPlaceholder : "";
// Attachments ride into the turn as path references, exactly as they do from
// the CLI — the host saved the bytes, the turn parser resolves the token. A
@@ -637,7 +640,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
? t("正在添加附件…")
: failed
? t("有附件添加失败,请重试或移除")
: sendHint(sendShortcut, running, touch)}
: sendHint(sendShortcut, running, touch, runningIntent)}
</span>
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
</div>
@@ -691,7 +694,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("计划")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
/>
</div>
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} workspace={workspace} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
kernel turns off the moment a plan is approved. The lifecycle is a
separate reading — an approved plan is still running, and saying so
@@ -788,7 +791,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
<path d="M8 12.8V3.4M4.2 7.2 8 3.4l3.8 3.8" />
</svg>
</span>
<span>{t(submitting ? "正在发送…" : running ? "插话" : "发送")}</span>
<span>{t(submitting ? "正在发送…" : running ? runningIntent === "followup" ? "排队发送" : "插话" : "发送")}</span>
</button>}
</span>
</div>
@@ -18,7 +18,7 @@ it.each(["failed", "cancelled"])("restores composer focus after dismissing %s pr
const st = { workspaceRoot: "/source", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", effort: "auto", toolApprovalMode: "ask" } as SessionStatus;
render(<Composer port={port} status={st} running={false} fresh git={{ repo: true, branch: "main", name: "project", detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()}/>);
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(screen.getByRole("button", { name: "新建 worktree" }));
fireEvent.click(screen.getByRole("button", { name: "独立工作树" }));
fireEvent.click(screen.getByRole("button", { name: "关闭工作目录配置" }));
const box = screen.getByLabelText("任务输入") as HTMLTextAreaElement;
box.focus();
@@ -0,0 +1,16 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { onComposerHintChange, setShowsComposerHint, showsComposerHint } from "../state/prefs";
import { Group } from "./Group";
export function ComposerHintSection() {
const on = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
return (
<Group id="composer-hint" title={t("输入框提示")} hint={t("在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。")}>
<div className="seg" data-text role="group" aria-label={t("输入框提示")}>
<button data-action="appearance.composer-hint" data-value="off" aria-pressed={!on} onClick={() => setShowsComposerHint(false)}>{t("关闭")}</button>
<button data-action="appearance.composer-hint" data-value="on" aria-pressed={on} onClick={() => setShowsComposerHint(true)}>{t("显示")}</button>
</div>
</Group>
);
}
@@ -55,3 +55,18 @@ it("closes on Escape and offers nothing where the kernel's machine cannot open p
draw(false);
expect(screen.queryByRole("button", { name: "用其他程序打开项目" })).toBeNull();
});
it("says why an installed program is not offered, and also when nothing is", async () => {
shell.reveals = true;
const blocked = { id: "cursor", name: "Cursor", available: false, why: "inside_project", within: "/home/u" };
const openWith = vi.fn().mockResolvedValue(undefined);
const openWithTargets = vi.fn().mockResolvedValue([blocked]);
render(<OpenWithMenu hub={{ openWith, openWithTargets }} root="/w" onError={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "用其他程序打开项目" }));
const item = await screen.findByRole("menuitem", { name: /用 Cursor 打开/ });
expect(item.hasAttribute("disabled")).toBe(true);
expect(item.getAttribute("title")).toContain("/home/u");
expect(screen.queryByText("没有找到可用的程序")).toBeNull();
await userEvent.click(item);
expect(openWith).not.toHaveBeenCalled();
});
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { openWithName } from "./openwith_label";
import { blockedInProject, blockedNote, openWithName } from "./openwith_label";
import type { HubPort, OpenWithTarget } from "../port/hub";
import { host } from "../port/host";
import { t } from "../i18n";
@@ -19,7 +19,7 @@ export function OpenWithMenu({ hub, root, onError }: {
useEffect(() => {
if (!open) return;
let alive = true;
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => x.available))).catch(() => {});
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => x.available || blockedInProject(x)))).catch(() => {});
return () => { alive = false; };
}, [hub, open]);
if (!shell || !root) return null;
@@ -51,12 +51,15 @@ export function OpenWithMenu({ hub, root, onError }: {
data-action="workspace.open-with"
data-target={root}
data-value={target.id}
disabled={!target.available}
title={target.available ? undefined : blockedNote(target)}
onClick={() => {
setOpen(false);
hub.openWith(root, target.id).catch(onError);
}}
>
<span>{t("用 {name} 打开", { name: openWithName(target) })}</span>
{!target.available && <small>{t("位于项目文件夹内")}</small>}
</button>
))}
</div>
+5 -2
View File
@@ -53,6 +53,7 @@ import { Find } from "./Find";
import { useFind } from "./usefind";
import { RunAnalysis } from "./RunAnalysis";
import { useBrowserTabs } from "./BrowserPanel";
import { useWorkspaceInfo } from "./useWorkspaceInfo";
import { useRevealAgentPages, useRevealBrowserOpen } from "./browserreveal";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { useBrowserFull } from "./browserfull";
@@ -102,6 +103,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
const status = sessionRead.kind === "settled" ? sessionRead.status : null;
const ready = sessionRead.kind === "settled";
const workspace = useWorkspaceInfo(port, status?.workspaceRoot || status?.cwd || rt.root, ready);
const [tab, showView] = useShowView(rt.id, onManualBrowser);
const [sideAsk, setSideAsk] = useState(false);
const [worktreeTarget, setWorktreeTarget] = useState<HTMLDivElement | null>(null);
@@ -463,6 +465,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
useEffect(() => {
onReport(rt.id, {
status,
workspace,
title,
steer: waiting(queue).filter((it) => it.intent === "steer").length,
run: trustWaiting && run === "idle" ? "halt" : run,
@@ -477,7 +480,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
rows: traj.rows.length,
surfaces,
});
}, [rt.id, onReport, status, title, queue, run, running, blocked, trustWaiting, cost, contextPercent, ctx, mcp, walletDisplay, s.interactions, docked, tab, traj.rows.length, surfaces]);
}, [rt.id, onReport, status, workspace, title, queue, run, running, blocked, trustWaiting, cost, contextPercent, ctx, mcp, walletDisplay, s.interactions, docked, tab, traj.rows.length, surfaces]);
return (
<section
@@ -636,7 +639,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
{alert && <div className="cmpalert">{alert}</div>}
<RootMissingBanner items={s.items} onPick={onOpenProject} />
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} workspace={workspace} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<MeterRail
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost} turnCost={s.metrics.coverage === "none" ? NO_COST : s.metrics.turn}
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { PlanFold } from "./PlanFold";
import { PlanProgressSection } from "./PlanProgressSection";
import { boot, STORAGE } from "../i18n";
import { setShowsPlanProgress } from "../state/prefs";
import type { PlanStep } from "../state/session";
beforeEach(() => {
localStorage.clear();
localStorage.setItem(STORAGE, "zh");
boot();
setShowsPlanProgress(false);
});
afterEach(cleanup);
const plan: PlanStep[] = [
{ text: "Read requirements", status: "completed" },
{ text: "Implement changes", status: "in_progress" },
{ text: "Verify result", status: "pending" },
];
describe("optional progress below the collapsed plan", () => {
it("applies the machine preference immediately to every mounted plan, and remembers it on remount", async () => {
const settings = render(<PlanProgressSection />);
const cards = render(<><PlanFold plan={plan} shown /><PlanFold plan={plan} shown /></>);
expect(screen.queryAllByRole("progressbar")).toHaveLength(0);
await userEvent.click(within(settings.container).getByRole("button", { name: "显示" }));
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
expect(screen.getAllByRole("progressbar")).toHaveLength(2);
const progress = screen.getAllByRole("progressbar")[0];
expect(progress.getAttribute("aria-label")).toBe("计划进度");
expect(progress.getAttribute("aria-valuenow")).toBe("1");
expect(progress.getAttribute("aria-valuemax")).toBe("3");
expect(progress.querySelector("i")?.style.width).toBe("33%");
cards.unmount();
render(<PlanFold plan={plan} shown />);
expect(screen.getByRole("progressbar")).toBeTruthy();
await userEvent.click(within(settings.container).getByRole("button", { name: "关闭" }));
expect(screen.queryByRole("progressbar")).toBeNull();
expect(localStorage.getItem("rx-plan-progress")).toBe("off");
});
it("follows live canonical statuses, including resetting the plan and completing all steps", () => {
setShowsPlanProgress(true);
const view = render(<PlanFold plan={plan} shown paused />);
for (const status of ["pending", "in_progress", "completed"] as const) {
view.rerender(<PlanFold plan={plan.map((s) => ({ ...s, status }))} shown paused />);
const progress = screen.getByRole("progressbar");
expect(progress.getAttribute("aria-valuenow")).toBe(status === "completed" ? "3" : "0");
expect(progress.querySelector("i")?.style.width).toBe(status === "completed" ? "100%" : "0%");
}
});
it("keeps expanded progress and all collapse and dismiss interactions unchanged", async () => {
setShowsPlanProgress(true);
render(<><PlanFold plan={plan} shown /><button>Outside</button></>);
const bar = screen.getByRole("button", { expanded: false });
await userEvent.click(bar);
expect(screen.queryByRole("progressbar")).toBeNull();
expect(document.querySelector(".studio-todo-body .prog2 i")?.getAttribute("style")).toContain("33%");
await userEvent.click(screen.getByText("Verify result"));
expect(bar.getAttribute("aria-expanded")).toBe("true");
await userEvent.click(screen.getByRole("button", { name: "收起计划" }));
expect(screen.getByRole("progressbar")).toBeTruthy();
expect(document.activeElement).toBe(bar);
await userEvent.click(bar);
await userEvent.keyboard("{Escape}");
expect(screen.getByRole("progressbar")).toBeTruthy();
expect(document.activeElement).toBe(bar);
await userEvent.click(bar);
await userEvent.click(screen.getByRole("button", { name: "Outside" }));
expect(bar.getAttribute("aria-expanded")).toBe("false");
expect(screen.getByRole("progressbar")).toBeTruthy();
});
it("does not fabricate progress for a goal-only, empty or hidden card", () => {
setShowsPlanProgress(true);
const view = render(<PlanFold plan={[]} goal="Deliver a working feature" shown />);
expect(screen.queryByRole("progressbar")).toBeNull();
expect(screen.getByRole("button", { expanded: false }).textContent).toContain("Deliver a working feature");
view.rerender(<PlanFold plan={[]} shown />);
expect(view.container.innerHTML).toBe("");
view.rerender(<PlanFold plan={plan} shown={false} />);
expect(view.container.innerHTML).toBe("");
});
});
+12 -3
View File
@@ -1,6 +1,7 @@
import { useRef, useState } from "react";
import { useRef, useState, useSyncExternalStore } from "react";
import { t } from "../i18n";
import { currentStep, stepDone, stepLabel, type PlanStep } from "../state/session";
import { onPlanProgressChange, showsPlanProgress } from "../state/prefs";
import { Plan } from "./Plan";
import type { GoalRuntime } from "../port/session";
import { goalStatusText, goalStopText, goalUsageText } from "./goalusage";
@@ -12,13 +13,15 @@ import { useDismiss } from "./dismiss";
* visit to another view. */
export function PlanFold({ plan, goal, goalStatus, goalRuntime, shown, paused }: { plan: PlanStep[]; goal?: string; goalStatus?: string; goalRuntime?: GoalRuntime; shown: boolean; paused?: boolean }) {
const [open, setOpen] = useState(false);
const progress = useSyncExternalStore(onPlanProgressChange, showsPlanProgress, showsPlanProgress);
const bar = useRef<HTMLButtonElement>(null);
const root = useRef<HTMLDivElement>(null);
const put = () => { setOpen(false); bar.current?.focus(); };
useDismiss(open, root, put);
if ((plan.length === 0 && !goal) || !shown) return null;
const done = plan.filter(stepDone).length;
const running = plan.filter((s) => !stepDone(s) && plan.indexOf(s) === currentStep(plan)).length;
const waiting = plan.length - plan.filter(stepDone).length - running;
const waiting = plan.length - done - running;
return (
<div className="studio-todo" data-open={open ? "" : undefined} ref={root}>
{/* One line by default. The composer already carries the run line,
@@ -35,9 +38,15 @@ export function PlanFold({ plan, goal, goalStatus, goalRuntime, shown, paused }:
<StudioIcon name="list" />
<b>{t(plan.length ? "计划" : "目标")}</b>
<span>{plan.length ? stepLabel(plan[currentStep(plan)] ?? plan[0]) : goal}</span>
{plan.length > 0 && <small>{plan.filter(stepDone).length}/{plan.length}</small>}
{plan.length > 0 && <small>{done}/{plan.length}</small>}
<StudioIcon name="down" className="studio-todo-fold" />
</button>
{!open && progress && plan.length > 0 && (
<div className="prog2 studio-todo-progress" role="progressbar" aria-label={t("计划进度")}
aria-valuemin={0} aria-valuemax={plan.length} aria-valuenow={done}>
<span className="track"><i style={{ width: `${Math.round((done / plan.length) * 100)}%` }} /></span>
</div>
)}
{open && (
<div className="studio-todo-body">
<div className="studio-todo-head">
@@ -0,0 +1,16 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "../state/prefs";
import { Group } from "./Group";
export function PlanProgressSection() {
const on = useSyncExternalStore(onPlanProgressChange, showsPlanProgress, showsPlanProgress);
return (
<Group id="plan-progress" title={t("收起时显示计划进度")} hint={t("在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。")}>
<div className="seg" data-text role="group" aria-label={t("收起时显示计划进度")}>
<button data-action="appearance.plan-progress" data-value="off" aria-pressed={!on} onClick={() => setShowsPlanProgress(false)}>{t("关闭")}</button>
<button data-action="appearance.plan-progress" data-value="on" aria-pressed={on} onClick={() => setShowsPlanProgress(true)}>{t("显示")}</button>
</div>
</Group>
);
}
@@ -7,11 +7,12 @@ import { pinToViewport } from "./place";
import { StudioIcon } from "./StudioIcon";
import { workspaceMenuKeys } from "./WorkspaceOrder";
export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onToggleAll, allFolded, canFold, searching }: {
export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onRecent, onToggleAll, allFolded, canFold, searching }: {
adder: Adder;
canSelect: boolean;
selecting: boolean;
onSelect: () => void;
onRecent?: () => void;
onToggleAll?: () => void;
allFolded?: boolean;
canFold?: boolean;
@@ -42,6 +43,10 @@ export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onToggle
</button>}
</span>
<span className="projects-heading-actions">
{onRecent && <button className="projects-add projects-recent" data-action="session.recent"
title={t("最近的会话")} aria-label={t("最近的会话")} onClick={onRecent}>
<span>{t("最近")}</span>
</button>}
<button className="projects-add" data-busy={adder.busy ? "" : undefined} disabled={adder.busy}
title={t("打开项目")} aria-label={t("打开项目")} data-action="workspace.add" onClick={() => adder.add()}>
<StudioIcon name="plus" />
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { HubPort, TreeSession, TreeWorkspace } from "../port/hub";
import type { RemoteHost } from "../port/remote";
import { hidesLegacySessions, onHidesLegacySessionsChange } from "../state/prefs";
import { SessionAge } from "./SessionAge";
import { StudioIcon } from "./StudioIcon";
import { TREE_PAGE } from "./treewindow";
import { useModalFocus } from "./useModalFocus";
import { listenAction } from "./listen";
export function RecentSessions({ hub, hosts, onPick, onClose }: {
hub: HubPort;
hosts: RemoteHost[];
onPick: (host: string, workspace: TreeWorkspace, session: TreeSession) => Promise<void>;
onClose: () => void;
}) {
const sheet = useRef<HTMLDivElement>(null);
useModalFocus(sheet);
const hideLegacy = useSyncExternalStore(onHidesLegacySessionsChange, hidesLegacySessions, hidesLegacySessions);
const [view, setView] = useState({ host: "", q: "", offset: 0 });
const [draft, setDraft] = useState("");
const [tree, setTree] = useState<TreeWorkspace[] | null>(null);
const [busy, setBusy] = useState(true);
const [opening, setOpening] = useState(false);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
const timer = setTimeout(() => setView((current) => current.q === draft.trim() ? current : { ...current, q: draft.trim(), offset: 0 }), 250);
return () => clearTimeout(timer);
}, [draft]);
useEffect(() => { sheet.current?.querySelector<HTMLInputElement>("input")?.focus(); }, []);
useEffect(() => hub.onTreeChanged(() => setRetry((n) => n + 1)), [hub]);
useEffect(() => {
const escape = (event: KeyboardEvent) => { if (event.key === "Escape" && !opening) onClose(); };
return listenAction(window, "keydown", { action: "layer.dismiss", listener: escape as EventListener });
}, [onClose, opening]);
useEffect(() => {
let stale = false;
setBusy(true);
setError("");
const window = { order: "recent" as const, scope: "active" as const, limit: TREE_PAGE, offset: view.offset, q: view.q, hideLegacy };
const read = view.host ? hub.remoteTree(view.host, window) : hub.tree(window);
void read.then((next) => {
if (stale) return;
if (next?.some((ws) => ws.recentOffset !== view.offset)) throw new Error(t("此运行环境不支持最近会话,请更新 Reasonix"));
setTree(next ?? []);
}).catch((e: unknown) => { if (!stale) setError(reason(e)); })
.finally(() => { if (!stale) setBusy(false); });
return () => { stale = true; };
}, [hub, view, hideLegacy, retry]);
const rows = (tree ?? []).flatMap((ws) => ws.sessions.map((session) => ({ ws, session })))
.sort((a, b) => (b.session.lastActiveAt ?? 0) - (a.session.lastActiveAt ?? 0) || (a.session.path < b.session.path ? -1 : a.session.path > b.session.path ? 1 : 0));
const remaining = (tree ?? []).reduce((n, ws) => n + (ws.remaining ?? 0), 0);
const pick = async (ws: TreeWorkspace, session: TreeSession) => {
setOpening(true);
setError("");
try {
await onPick(view.host, ws, session);
onClose();
} catch (e) {
setError(reason(e));
} finally {
setOpening(false);
}
};
return createPortal(
<div className="pickveil" role="dialog" aria-modal="true" aria-label={t("最近的会话")} ref={sheet}>
<div className="pickcard recent-sessions">
<h2>{t("最近的会话")}</h2>
<p className="recent-description">{t("按最近活动排列,包含已完成的会话。选择后定位到所属项目。")}</p>
{hosts.some((host) => !host.disabled) && <label className="recent-machine">{t("运行环境")}
<select data-action="session.recent-environment" aria-label={t("运行环境")} value={view.host} disabled={opening} onChange={(event) => setView({ host: event.target.value, q: view.q, offset: 0 })}>
<option value="">{t("本机")}</option>
{hosts.filter((host) => !host.disabled).map((host) => <option key={host.name} value={host.name}>{host.name}</option>)}
</select>
</label>}
<div className="pickpath"><input data-action="workspace.search" type="search" aria-label={t("搜索最近会话 / 项目")} placeholder={t("搜索最近会话 / 项目")} value={draft} disabled={opening} onChange={(event) => setDraft(event.target.value)} /></div>
<div className="picklist" aria-busy={busy} data-busy={busy ? "" : undefined}>
{!busy && !error && !rows.length && <p className="pickempty">{t("没有匹配的会话")}</p>}
{busy && <p className="picknote" role="status">{t("正在读取会话…")}</p>}
{!busy && !error && rows.map(({ ws, session }) => <button key={session.path} className="pickrow recent-session" disabled={opening}
data-action="session.open" data-target={session.path} aria-label={`${session.title || session.name} · ${ws.name}`}
title={`${session.workspaceRoot || ws.root}\n${session.path}`} onClick={() => void pick(ws, session)}>
<StudioIcon name="chat" />
<span className="recent-session-text"><b>{session.title || session.name}</b><small>{ws.name} · {session.workspaceRoot || ws.root}</small></span>
<SessionAge at={session.lastActiveAt} />
</button>)}
{error && <><p className="picknote" role="alert">{error}</p><button className="pickgo" data-action="session.recent-retry" onClick={() => setRetry((n) => n + 1)}>{t("重试")}</button></>}
</div>
<div className="pickact">
<button data-action="session.recent-page" data-value="previous" disabled={busy || opening || view.offset === 0} onClick={() => setView({ ...view, offset: Math.max(0, view.offset - TREE_PAGE) })}>{t("上一页")}</button>
<span className="recent-page">{t("第 {n} 页", { n: view.offset / TREE_PAGE + 1 })}</span>
<button data-action="session.recent-page" data-value="next" disabled={busy || opening || !!error || remaining === 0} onClick={() => setView({ ...view, offset: view.offset + TREE_PAGE })}>{t("下一页")}</button>
<button data-action="layer.dismiss" disabled={opening} onClick={onClose}>{t("关闭")}</button>
</div>
</div>
</div>, document.body,
);
}
@@ -8,7 +8,6 @@ import { pinToViewport } from "./place";
import { SessionAge } from "./SessionAge";
import { SessionArchiveAction, SessionPinAction } from "./SessionRowActions";
import { StudioIcon } from "./StudioIcon";
import { UnreadDot } from "./UnreadMark";
import { workspaceMenuKeys } from "./WorkspaceOrder";
import { asksDelete } from "./keys";
import type { SessionMutation } from "./useSessionMutation";
@@ -61,8 +60,7 @@ export function RemoteSessionRow({ host, target, session, held, active, busy, pe
<i className="pip" />
<SessionPinAction target={target} title={title} pinned={!!session.pinned} enabled={quick} disabled={!!pending || busy} onPin={() => write("pin")} />
<span className="sesstitle" title={title}><span>{title}</span>{session.isolated && <em className="sessarch" title={session.workspaceRoot}>{t("隔离")}</em>}{session.archived && <em className="sessarch">{t("已归档")}</em>}</span>
{session.unread && <UnreadDot />}
<SessionAge at={session.lastActiveAt} />
<SessionAge at={session.lastActiveAt} unread={session.unread} />
{session.legacy && <span className="sesslegacy" title={t("由 Reasonix 1.x 创建:Studio 只读打开,继续对话会另存为新会话")}
aria-label={t("由 Reasonix 1.x 创建:Studio 只读打开,继续对话会另存为新会话")}>{t("旧")}</span>}
{quick && <SessionArchiveAction target={target} title={title} archived={!!session.archived} disabled={!!pending || busy || live} onArchive={() => write("archive")} />}
@@ -0,0 +1,16 @@
import { t } from "../i18n";
import { setRunningMessageIntent, useRunningMessageIntent } from "../state/prefs";
import { Group } from "./Group";
export function RunningMessageSetting() {
const intent = useRunningMessageIntent();
return (
<Group id="running-message-intent" title={t("运行中发送消息")} hint={t("选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。")}>
<div className="seg" data-text role="radiogroup" aria-label={t("运行中发送消息")}>
<button role="radio" data-action="composer.running-message-intent" data-value="steer" aria-checked={intent === "steer"} onClick={() => setRunningMessageIntent("steer")}>{t("下个工具边界插话")}</button>
<button role="radio" data-action="composer.running-message-intent" data-value="followup" aria-checked={intent === "followup"} onClick={() => setRunningMessageIntent("followup")}>{t("整轮结束后发送")}</button>
</div>
<p className="hint">{t("可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。")}</p>
</Group>
);
}
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { afterEach, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "./testkit";
import { SessionAge } from "./SessionAge";
afterEach(cleanup);
it.each([undefined, 0, NaN, Infinity, 1_700_000_000_000])("renders exactly one unread mark instead of the date for %s", (at) => {
const { container, rerender } = render(<SessionAge at={at} unread />);
expect(screen.getAllByRole("img", { name: "未读" })).toHaveLength(1);
expect(container.querySelector("time")).toBeNull();
rerender(<SessionAge at={at} unread={false} />);
expect(screen.queryByRole("img")).toBeNull();
expect(container.querySelectorAll("time")).toHaveLength(at && Number.isFinite(at) ? 1 : 0);
});
it("restores the original date when the canonical unread field clears", () => {
const at = 1_700_000_000_000;
const { container, rerender } = render(<SessionAge at={at} unread />);
rerender(<SessionAge at={at} />);
expect(container.querySelector("time")?.dateTime).toBe(new Date(at).toISOString());
expect(screen.queryByRole("img")).toBeNull();
});
+3 -1
View File
@@ -1,6 +1,8 @@
import { since } from "./wallet";
import { UnreadDot } from "./UnreadMark";
export function SessionAge({ at }: { at?: number }) {
export function SessionAge({ at, unread }: { at?: number; unread?: boolean }) {
if (unread) return <UnreadDot />;
if (!at) return null;
const date = new Date(at);
if (!Number.isFinite(date.valueOf())) return null;
@@ -75,7 +75,6 @@ import { Storage } from "./Storage";
import { Appearance, SCHEMES } from "./Appearance";
import { ScopeBar } from "./CapabilityScope";
import { savedNotApplied, reason } from "../i18n/kernel";
import { SettingsHeading } from "./SettingsHeading";
import type { SettingsProps as Props } from "./SettingsProps";
import { APPROVALS, approvalName, approvalNote } from "./approvals";
@@ -425,7 +424,6 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
and it is the one thing here whose mount really is the cause. */}
<div className="prefs-col" key={at}>
<SettingsPageStyle section={at} page={pageNow} />
<SettingsHeading section={at} value={nav[at]} danger={danger(at)} />
{/* Every refused write on every page lands in one state, so it is
said in one place. Copied onto the pages somebody remembered, it
was missing from the third that writes it: 创建隔离副本 was
@@ -1,30 +0,0 @@
import { t } from "../i18n";
import { ICON, NAV, SETTINGS, type Section } from "./prefsnav";
interface Props {
section: Section;
value?: string;
danger?: boolean;
}
export function SettingsHeading({ section, value, danger }: Props) {
const group = NAV.find(([, items]) => items.some(([id]) => id === section));
const name = group?.[1].find(([id]) => id === section)?.[1] ?? section;
const count = SETTINGS.filter((entry) => entry.section === section && !entry.hidden).length;
return (
<header className="prefs-intro">
<span className="prefs-intro-mark" aria-hidden="true">
<svg viewBox="0 0 16 16">{ICON[section]}</svg>
</span>
<span className="prefs-intro-copy">
<span className="prefs-intro-overline">{t(group?.[0] ?? "")}</span>
<h2>{t(name)}</h2>
</span>
<span className="prefs-intro-meta" data-danger={danger ? "" : undefined}>
{value && <strong title={value}>{value}</strong>}
<span>{t("设置项:{n}", { n: count })}</span>
</span>
</header>
);
}
+30 -3
View File
@@ -1,9 +1,9 @@
import { FeedbackBadge, feedbackEntryTab } from "./feedbackentry";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { t } from "../i18n";
import logoWordmark from "../assets/logo-wordmark.svg";
import type { AccountState } from "../port/port";
import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub";
import { workspaceContains } from "../port/hub";
import type { RemoteHost } from "../port/remote";
import type { Adder } from "./addws";
@@ -26,6 +26,8 @@ import type { TreePaging, TreeView } from "./treewindow";
import { MASK, useHidesAmounts } from "./wallet";
import { useFileDrop } from "./filedrop";
import type { PaneView } from "../state/paneview";
import { RecentSessions } from "./RecentSessions";
import { sessionRuntime } from "./sessionScope";
interface Props {
hub: HubPort;
@@ -137,6 +139,9 @@ export function Sidebar({
const destinations = useDestinations();
const [palette, setPalette] = useState(false);
const [projectPicker, setProjectPicker] = useState(false);
const [recent, setRecent] = useState(false);
const [located, setLocated] = useState<{ target: string } | null>(null);
const locatedOnce = useRef<typeof located>(null);
const [folded, setFolded] = useSidebarFolds();
const hideAmounts = useHidesAmounts();
const hiddenHosts = useHiddenHosts();
@@ -225,9 +230,30 @@ export function Sidebar({
const toggleAll = () => setFolded(allFolded
? new Set([...folded].filter((key) => !foldable.includes(key))) : new Set([...folded, ...foldable]));
const pickRecent = async (host: string, workspace: TreeWorkspace, session: TreeSession) => {
const held = sessionRuntime(session, runtimes, host);
if (held) onFocusPane(held.id);
else if (host) {
await onOpenRemote(host, session.workspaceRoot || workspace.root, session.path);
await readRemoteTree(host);
} else await onOpen({ root: session.workspaceRoot || workspace.root, sessionPath: session.path });
setRailScope("all");
onFold(host ? remoteProjectKey(host, workspace.root) : workspace.root, false);
setLocated({ target: host ? remoteProjectKey(host, workspace.root) + ":" + session.path : session.path });
};
useEffect(() => {
if (!located || recent || locatedOnce.current === located) return;
const row = [...document.querySelectorAll<HTMLElement>('.rail [role="treeitem"]')]
.find((element) => element.dataset.target === located.target);
if (!row) return;
row.scrollIntoView?.({ block: "nearest" });
row.focus({ preventScroll: true });
locatedOnce.current = located;
}, [located, recent, tree, remoteTrees]);
return (
<>
{recent && <RecentSessions hub={hub} hosts={shownRemotes} onPick={pickRecent} onClose={() => setRecent(false)} />}
{projectPicker && <OpenProject hub={hub} adder={adder} hosts={shownRemotes}
onClose={() => setProjectPicker(false)} onError={onError} onOpen={async (host, root) => {
await onOpenRemote(host, root);
@@ -289,7 +315,7 @@ export function Sidebar({
</div>
</div>
</div>
<RailSearch>
<RailSearch resetKey={located}>
<ContentMatches hub={hub} tree={tree} onOpen={onOpen} onError={onError} onReference={onReferenceSession} />
<Workspaces
hub={hub}
@@ -301,6 +327,7 @@ export function Sidebar({
folded={folded}
onToggleAll={toggleAll}
allFolded={allFolded}
onRecent={() => setRecent(true)}
canFold={foldable.length > 0}
onFold={onFold}
reload={reloadTree}
@@ -0,0 +1,21 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { onThemeScheduleChange, setThemeSchedule, themeSchedule } from "../state/themeSchedule";
const FIELDS = [["start", "深色开始时间"], ["end", "恢复浅色时间"]] as const;
export function ThemeScheduleFields() {
const schedule = useSyncExternalStore(onThemeScheduleChange, themeSchedule);
return <>
<p className="hint" id="theme-schedule-hint">{t("每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。")}</p>
{FIELDS.map(([field, label]) => (
<label className="prow" key={field}>
<span className="tx">{t(label)}</span>
<input type="time" className="fontown" value={schedule[field]} required step={60}
aria-describedby="theme-schedule-hint" data-action="appearance.schedule" data-target={field}
onChange={(e) => setThemeSchedule({ ...schedule, [field]: e.target.value })} />
</label>
))}
{schedule.start === schedule.end && <p className="hint">{t("开始和结束时间相同时全天使用浅色。")}</p>}
</>;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { plural, t } from "../i18n";
export function UnreadDot() {
return <i className="unread-dot" role="img" aria-label={t("未读")} />;
return <i className="unread-dot" role="img" aria-label={t("未读")} title={t("未读")} />;
}
export function UnreadCount({ n }: { n: number }) {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Versions } from "./Versions";
@@ -95,6 +95,16 @@ describe("the version panel", () => {
expect(screen.getByRole("link", { name: "下载完整安装包" })).toBeTruthy();
});
it("opens the full installer page in the system browser, where a file can be saved", async () => {
const openExternal = vi.fn(() => Promise.resolve());
const port = { ...portAt({ version: "2.21.0", phase: "error", received: 0, total: 0, code: "update.not_applied", err: "update: set aside app.exe: access denied" }), openExternal };
render(<Versions port={port} />);
const link = await screen.findByRole("link", { name: "下载完整安装包" });
const notPrevented = fireEvent.click(link);
expect(notPrevented).toBe(false);
expect(openExternal).toHaveBeenCalledWith("https://github.com/esengine/DeepSeek-Reasonix/releases/tag/studio-v2.21.0");
});
it("explains a move that did not land by its code, with the way out", async () => {
const port = portAt({ version: "2.21.0", phase: "error", received: 0, total: 0, code: "update.not_applied", err: "update: set aside app.exe: access denied" });
render(<Versions port={port} />);
+10 -2
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent, type MouseEvent } from "react";
import { bytes } from "../i18n/format";
import { current, t } from "../i18n";
import type { UpdateProgress, VersionHub, VersionNotes } from "../port/port";
@@ -29,6 +29,7 @@ type Port = {
restartToVersion(v: string, force: boolean): Promise<void>;
onUpdateProgress(cb: (p: UpdateProgress) => void): () => void;
versionNotes?(version: string, retry?: boolean): Promise<VersionNotes>;
openExternal?(url: string): Promise<void>;
};
// A document read stays read: it is kept for as long as the panel is mounted,
@@ -84,6 +85,13 @@ function say(p: UpdateProgress): string {
// The newest few older releases stay on the page; the rest are history one click away.
const PAST_SHOWN = 3;
// An installer is a file to save, which the window's own browser does not do.
function openInstallerPage(e: MouseEvent<HTMLAnchorElement>, port: Port, version: string) {
if (!port.openExternal) return;
e.preventDefault();
void port.openExternal(releasePage(version)).catch(() => {});
}
export function Versions({ port }: { port: Port }) {
const [hub, setHub] = useState<VersionHub | null>(null);
const [busy, setBusy] = useState(false);
@@ -349,7 +357,7 @@ export function Versions({ port }: { port: Port }) {
<span className="why">
{failure.why}
{failure.manual && (
<a className="lnk" href={releasePage(progress.version)} target="_blank" rel="noreferrer noopener">
<a className="lnk" href={releasePage(progress.version)} target="_blank" rel="noreferrer noopener" data-action="versions.full-installer" onClick={(e) => openInstallerPage(e, port, progress.version)}>
{t("下载完整安装包")}
</a>
)}
@@ -3,7 +3,7 @@ import type { HubPort, OpenWithTarget, TreeWorkspace } from "../port/hub";
import { host } from "../port/host";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import { openWithName } from "./openwith_label";
import { blockedInProject, blockedNote, openWithName } from "./openwith_label";
export function WorkspaceReveal({ ws, hub, dismiss, onError }: {
ws: TreeWorkspace;
@@ -18,7 +18,7 @@ export function WorkspaceReveal({ ws, hub, dismiss, onError }: {
useEffect(() => {
if (!shell) return;
let alive = true;
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => x.available && x.id !== "file-manager"))).catch(() => {});
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => (x.available || blockedInProject(x)) && x.id !== "file-manager"))).catch(() => {});
return () => { alive = false; };
}, [hub, shell]);
if (!shell) return null;
@@ -44,13 +44,15 @@ export function WorkspaceReveal({ ws, hub, dismiss, onError }: {
data-action="workspace.open-with"
data-target={ws.root}
data-value={target.id}
disabled={ws.missing}
disabled={ws.missing || !target.available}
title={target.available ? undefined : blockedNote(target)}
onClick={() => {
dismiss();
hub.openWith(ws.root, target.id).catch(onError);
}}
>
<StudioIcon name="external" /><span>{t("用 {name} 打开", { name: openWithName(target) })}</span>
{!target.available && <small>{t("位于项目文件夹内")}</small>}
</button>
))}
</div>
+13 -13
View File
@@ -1,4 +1,4 @@
import { inScope } from "./sessionScope";
import { inScope, sessionRuntime } from "./sessionScope";
import { Fragment, type ReactNode, memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { hidesLegacySessions, onHidesLegacySessionsChange } from "../state/prefs";
import { onSessionQuickActionsChange, showsSessionQuickActions } from "../state/sessionQuick";
@@ -28,7 +28,7 @@ import { useSessionMove } from "./useSessionMove";
import { useSessionMoveTo } from "./useSessionMoveTo";
import { pinnedFirst } from "./pinorder";
import { Confirm, removeHint, sessionDeleteHint } from "./WorkspaceConfirm";
import { UnreadCount, UnreadDot } from "./UnreadMark";
import { UnreadCount } from "./UnreadMark";
import { PeerThreadMark } from "./peerthreads";
import { NO_PAGING, atCap, sessionCount, answers, unloaded, type TreePaging, type TreeView } from "./treewindow";
import { SessionBatch, useSelectedRows } from "./SessionBatch";
@@ -57,6 +57,7 @@ interface Props {
paging?: TreePaging;
previewLimit?: number;
onToggleAll?: () => void;
onRecent?: () => void;
allFolded?: boolean;
canFold?: boolean;
// The session path being opened, or empty. The row shows it selected and busy
@@ -91,7 +92,7 @@ interface Props {
const rowLabel = (session: TreeSession) =>
session.title || (session.runtimeId && !session.turns ? t("新会话") : session.name);
function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) {
function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, onRecent, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) {
const [busy, setBusy] = useState("");
const [confirm, setConfirm] = useState("");
const [selection, setSelection] = useState<Set<string> | null>(null);
@@ -182,8 +183,9 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
// The tree and runtime list arrive from separate reads. During a close or
// restore the tree can briefly retain an id that no longer exists; focusing
// that stale id changes no pane and makes the row look unclickable.
if (session.runtimeId && runtimes.some((rt) => rt.id === session.runtimeId)) {
onFocus(session.runtimeId);
const held = sessionRuntime(session, runtimes);
if (held) {
onFocus(held.id);
return;
}
try {
@@ -360,14 +362,14 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
<>
{moveTo.dialog}
<SessionBatch hub={hub} selection={selection} selected={selected} displayed={displayed} onSelect={setSelection}
onExit={() => treeKeys.ref.current?.previousElementSibling?.querySelector<HTMLElement>('[data-action="machine.menu"]')?.focus()}
onExit={() => treeKeys.ref.current?.parentElement?.previousElementSibling?.querySelector<HTMLElement>('[data-action="machine.menu"]')?.focus()}
onBusy={(pending) => { setBusy(pending ? "batch" : ""); if (pending) { setSessionMenu(""); setEditing(""); setConfirm(""); } }}
onClose={onClose} onArchive={onArchive} liveIds={liveIds} reload={reload} onError={onError} />
<ProjectsHeader adder={adder} onRecent={onRecent}
onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle}
canSelect={displayed.length > 0} selecting={selection !== null}
onSelect={() => setSelection(new Set())} />
<div className="scroll">
<ProjectsHeader adder={adder}
onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle}
canSelect={displayed.length > 0} selecting={selection !== null}
onSelect={() => setSelection(new Set())} />
<div role="tree" aria-label={t("项目与会话")} aria-multiselectable={selection !== null} inert={busy === "batch"} data-action-keydown="tree.navigate" ref={treeKeys.ref} onKeyDown={treeKeys.onKeyDown}>
{shownTree.map((ws) => {
// A fold is a resting-state preference; while a query is on it would hide
@@ -616,8 +618,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
</span>
)}
<PeerThreadMark path={session.path} />
{session.unread && <UnreadDot />}
<SessionAge at={session.lastActiveAt} />
<SessionAge at={session.lastActiveAt} unread={session.unread} />
{kept.length > 0 && (
<button
className="sesscopies"
@@ -775,7 +776,6 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
{children}
</div>
</div>
</>
);
}
@@ -25,7 +25,7 @@ it("keeps open a menu opened between the chip's commit and its mount effects", a
});
watch.observe(root, { childList: true, subtree: true });
});
mounted.render(<WorktreeChip port={port} sources={sources} canCreate={false} selection={null} branch="main" busy={false}
mounted.render(<WorktreeChip port={port} workspace={null} sources={sources} canCreate={false} selection={null} branch="main" busy={false}
changeCount={0} onSelect={vi.fn()} refresh={async () => sources} onChanged={vi.fn()} onError={vi.fn()} />);
await clicked;
await new Promise((resolve) => setTimeout(resolve, 100));
+12 -10
View File
@@ -5,6 +5,7 @@ import type { WorkspaceInfo, WorktreeSelection, WorktreeSources } from "../port/
import type { AgentPort, WorkspaceGit } from "../port/port";
import { useWorkspaceBranches } from "./useWorkspaceBranches";
import { StudioIcon } from "./StudioIcon";
import { workspaceDirectoryDescription } from "./workspaceDirectory";
import { WorkspacePath, WorkspaceBranchName, compactWorkspaceValue, workspaceName } from "./WorkspacePath";
import { listenAction } from "./listen";
import { beside, pinToViewport, useFollow, zoom } from "./place";
@@ -16,6 +17,7 @@ interface Props {
onSwitched?: (git: WorkspaceGit) => void;
onError: (error: unknown) => void;
sources: WorktreeSources;
workspace: WorkspaceInfo | null;
canCreate: boolean;
selection: WorktreeSelection | null;
branch: string;
@@ -25,11 +27,10 @@ interface Props {
refresh: () => Promise<WorktreeSources>;
}
export function WorktreeChip({ sources, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
export function WorktreeChip({ sources, workspace, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [sourceError, setSourceError] = useState(false);
const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const search = useRef<HTMLInputElement>(null);
@@ -43,16 +44,14 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated;
const chosen = sources.sources.find((source) => source.ref === selection?.ref);
const label = chosen?.name ?? selection?.ref ?? branch;
const creationDescription = t("发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。");
const directoryDescription = selection ? creationDescription : workspaceDirectoryDescription(isolated);
const scope = useRef({ port, root: sources.sourceRoot });
useEffect(() => {
let live = true;
if (scope.current.port !== port || scope.current.root !== sources.sourceRoot) {
scope.current = { port, root: sources.sourceRoot };
returnFocus.current = false; setOpen(false); setQuery("");
}
setWorkspace(null);
void port.workspaces().then((value) => { if (live) setWorkspace(value); }).catch(() => {});
return () => { live = false; };
}, [port, sources.sourceRoot]);
const put = useCallback(() => {
if (!trigger.current || !panel.current) return;
@@ -96,13 +95,16 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
const failed = selection ? sourceError : reading === "failed";
const loading = !selection && (reading === "idle" || reading === "loading") && locals.length === 0;
return <div className="studio-branch-pop">
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={selection ? label : branch} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立 worktree · {branch}", { branch: compactWorkspaceValue(label) }) : t("当前目录 · {branch}", { branch: compactWorkspaceValue(branch) })}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={`${directoryDescription}\n${selection ? label : branch}\n${sources.sourceRoot}`} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立工作树 · {branch}", { branch: compactWorkspaceValue(label) }) : compactWorkspaceValue(branch)}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
</button>
{open && createPortal(<div ref={panel} className="workspace-palette" role="dialog" aria-labelledby={title}>
<div className="workspace-palette-heading"><span id={title}>{t("配置工作目录")}</span><button data-action="workspace.configure" className="workspace-palette-close" aria-label={t("关闭工作目录配置")} onClick={close}><StudioIcon name="close"/></button></div>
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong><span>{t(selection ? "新建 worktree" : isolated ? "独立 worktree" : "当前目录")}</span></div>
{canCreate && <div className="worktree-modes"><button data-action="workspace.worktree-toggle" aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("使用当前目录")}</button><button data-action="workspace.worktree-toggle" aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("新建 worktree")}</button></div>}
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong>{(selection || isolated) && <span title={directoryDescription}>{t("独立工作树")}</span>}</div>
{canCreate && <div className="worktree-modes">
<button data-action="workspace.worktree-toggle" title={workspaceDirectoryDescription(false)} aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("当前目录")}{!selection && <StudioIcon name="check"/>}</button>
<button data-action="workspace.worktree-toggle" title={creationDescription} aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("独立工作树")}{selection && <StudioIcon name="check"/>}</button>
</div>}
{selection && <div className="workspace-palette-base"><span>{t("起始分支")}</span><strong title={label}>{compactWorkspaceValue(label)}</strong><small>{t("任务分支自动创建")}</small></div>}
<div className="workspace-palette-search"><StudioIcon name="search"/><input data-action-change="workspace.source-search" data-action-keydown="workspace.source-navigate" ref={search} type="search" aria-label={t("搜索分支")} placeholder={t(selection ? "搜索起始分支…" : "搜索本地分支…")} value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector<HTMLElement>('[role="option"]:not(:disabled)')?.focus(); }
@@ -53,7 +53,7 @@ export function WorktreeSetupPage({ stage, task, files, busy, onRetry, onCancel,
const icon = ready || retained ? "check" : failed || uncertain ? "warning" : cancelled ? "close" : "clock";
const phase = error || t(phases[stage.phase] || "正在等待状态确认");
return <section className="worktree-setup" data-phase={stage.phase} aria-label={t("工作目录准备")}>
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立 worktree")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立工作树")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
<div className="worktree-setup-phase"><span>{active ? <span className="worktree-spinner"/> : <StudioIcon name={icon}/>}<span>{phase}</span></span>{active && <span className="worktree-setup-elapsed">{t("已等待 {n} 秒", { n: elapsed })}</span>}</div>
{slow && <p className="worktree-setup-slow" role="status">{t("仍在等待创建完成。你可以继续等待、查看详情,或取消创建。")}</p>}
{stage.phase === "cleanup_failed" && <p className="worktree-setup-slow" role="note">{t("请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。")}</p>}
@@ -92,9 +92,3 @@ describe("what the appearance page says a person is doing", () => {
});
});
describe("the line width choices", () => {
it("offer the composer-too width through the same action as the others", () => {
expect(SOURCE).toContain('["full-all", "连输入框一起铺满"');
expect(idsOf("appearance.measure").length).toBeGreaterThan(0);
});
});
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff. Show more