Compare commits

...
143 Commits
Author SHA1 Message Date
YHH 98570c95c0 Merge pull request #14498 from esengine/c5-notes-2.52.0
docs(release): studio 2.52.0 notes
2026-10-25 17:02:21 -07:00
esengine 5e222f332e docs(release): studio 2.52.0 notes 2026-10-25 16:44:45 -07:00
YHH 1cc6bf327a Merge pull request #14496 from SivanCola/fix/job-panic-result-14025
fix(jobs): keep recovered panics from success / 防止任务 panic 被误报成功
2026-10-25 13:29:13 -07:00
SivanCola c8fcbc138e fix(jobs): keep recovered panics from becoming successful results
Problem: legacy nil-panic recovery can publish a successful job after its
invocation or cleanup panics, and error-valued panics lose their cause identity.

Root cause: two boundaries treat a non-nil recover value as the only panic
signal and format panic errors without wrapping the original error.

Fix: share a private normal-return guard across invocation and finalization,
retain typed panic causes, preserve finalizer error classification and sibling
cleanup, and keep the existing invocation stack message without adding stacks
to finalizers. Move invocation recovery into its cohesive owning file.

Verification: compiling preceding owners fail six test/subtest events with two
controls passing. Actual compiled Manager child tests cover explicit panicnil
modes zero/one, invocation, cleanup and no-launch. Frozen required2/rest237
partitions close all239 packages with23656 passes/215existing skips; complete
jobs/delegation race549 passes and accepted14464 three-owner composition671
passes have zero skips. Independent required command has2349passes/66existing skips.
Vet, complete lint, make check and formatting pass at unchanged2664baseline.
Windows/Linuxamd64 owner builds are compilation only; exact-head hosted/native
and independent review remain separate gates. No Goexit, other-goroutine panic,
process-scope completion or crash recovery is claimed.
2026-10-26 03:40:51 +08:00
YHH abd3e3a62e Merge pull request #14464 from SivanCola/codex/checkpoint-writer-admission-14025
Cancel background writer admission and bind cleanup to registrations / 后台写入者可取消准入与身份清理
2026-10-25 12:29:20 -07:00
SivanCola 80baf7e0c5 Merge studio and preserve job-owned writer token finalization
Problem: accepted job finalization in #14469 conflicts with the pending
cancellable writer registration paths in task and fleet.

Root cause: both sides own background cleanup. Closure-only cleanup misses
proven no-launch, while ID-only cleanup cannot protect replacement writers.

Fix: retain Manager-owned terminal callbacks and pass each returned writer
registration token through the shared jobCleanup option. Remove the obsolete
ID-only helper. Preserve the foreground token lifetime, accepted transcript
and progress finalizers, and every other upstream change in this ordinary merge.

Verification: the original full 239-package root command passes with 23668
test/subtest passes and 215 existing skips. Complete checkpoint/delegation/jobs
race passes 663 events, zero skips (one unchanged package cached); real boot
handoff and no-launch race passes ten events. Independent required builtin/boot
passes 2354 events with 66 existing skips. Compiling preceding callers fail
four no-launch scenarios plus parent; the Manager/token generation regression
passes. Vet, full lint, make check, formatting and diff gates pass, baseline
2664 unchanged. Six Windows/Linux amd64 owner compilations are compilation
only. Exact new-head hosted/native validation and independent review remain.
2026-10-26 02:59:07 +08:00
YHH c1ffa46df2 Merge pull request #14469 from SivanCola/codex/job-resource-finalization-14025
Finalize background resources when job launch is declined / 修复后台任务未启动时的资源清理
2026-10-25 11:29:39 -07:00
YHH 36ecb1868a Merge pull request #14495 from xxoingr/test/workbench-menu-focus-after-effect
test(studio): wait for the reader menu to take focus before reading it
2026-10-25 11:29:27 -07:00
xxoingr 022c4db333 test(studio): wait for the reader menu to take focus before reading it
The reader menu opens from a promise callback, so its commit runs at
default priority and the effect that focuses the first item is a
separate Scheduler task (setImmediate). findByRole resolves from the
MutationObserver that sees the menu, then RTL drains with setTimeout(0);
when a millisecond passes before the next loop turn, the timer wins and
the assertion reads the reader, which still has focus.

Wait for the focus the way the other focus assertions in the suite do.
2026-10-26 02:11:39 +08:00
YHH 91858315ca Merge pull request #14490 from SivanCola/codex/settings-network-draft
Separate saved network settings from drafts / 区分网络草稿与已保存配置
2026-10-25 11:00:18 -07:00
YHH 14323ca29d Merge pull request #14491 from SivanCola/codex/settings-scroll-search
Reset settings category scrolling and await search targets / 修复设置滚动与异步搜索定位
2026-10-25 10:59:50 -07:00
YHH 6458adf7b6 Merge pull request #14492 from SivanCola/codex/settings-backup-route
Register file backups on machine settings routes / 修复本机配置备份接口 404
2026-10-25 10:53:14 -07:00
YHH 5a16e27b59 Merge pull request #14479 from SivanCola/fix/mutation-json-members-14025
fix(mutation): reject ambiguous record members / 拒绝事务记录歧义字段
2026-10-25 10:30:56 -07:00
YHH 70955832c3 Merge pull request #14482 from xxoingr/test/netclient-stream-dialer-proxy-boundary
test(netclient): pin the stream dialer's proxy boundary
2026-10-25 10:30:18 -07:00
SivanCola 6881c9748d fix(studio): register file backups on machine settings routes
Problem: local settings cannot load the file-backup catalog because both
machine settings URL forms return an untyped 404.

Root cause: file-backup handlers are registered only with account backup
routes, while the machine settings router registers a separate route set.

Fix: register the existing file-backup handlers with machine settings.
Normal runtime routes still register that same set once. Preserve host-only
checks, paired-device rejection, local-owner selection and mutation gates;
do not change payloads, encryption or persisted formats.

Verification: new regressions fail before the fix with plain 404 responses
and pass afterward for both machine prefixes. Focused machine settings,
file backup, account backup and mutation-gate tests pass. gofmt, go vet,
make lint, make check and the required builtin/boot tests pass.
2026-10-26 01:12:34 +08:00
SivanCola d11ca0472c fix(studio): reset category scrolling and wait for search targets
Problem: switching settings categories retains an unrelated scroll offset, while a searched setting that loads later never receives navigation feedback.

Root cause: categories share one scrolling element and landing resolves the anchor once before asynchronous content arrives.

Fix: key the panel by category, clear pending landing only when a guarded category change succeeds, and observe target arrival until its feedback lifetime begins. Keep existing categories, layouts, disclosures and unsaved-change guards.

Verification: five regressions fail on the unmodified baseline; all 28 settings test files and 215 tests pass with the fix. TypeScript/Vite and repository checks pass. Browser verification confirms a tools panel at scrollTop 5747 switches to network at 0 and search opens the existing advanced contrast disclosure.
2026-10-26 01:10:04 +08:00
SivanCola 57784a251c fix(studio): separate saved network settings from drafts
Problem: Network settings could be saved without edits, and pending reads,
saves or diagnostics could overwrite the state of a newly selected connection.
Connection tests also appeared to exercise unsaved form values.

Root cause: The form used one settings object for both saved state and edits,
with async completions that were not scoped to the owning connection.

Fix: Keep saved settings and the editable draft separately, disable no-op
saves, freeze submitted drafts, and identify diagnostics as testing saved
settings. Remount the form on connection changes and ignore completions from
unmounted owners. Preserve the existing expanded connection details layout.

Verification: The seven extracted regression cases failed on the baseline.
Network and i18n tests pass (10 files, 181 tests), including edit reversion
and loading/error coverage; tsc -b, make check and git diff --check pass.
2026-10-26 01:08:14 +08:00
YHH 4377886f88 Merge pull request #14478 from EMEEEEMMMM/feat/wallpaper-scene-audio-dxt
feat(studio): loop every scene soundtrack and decode DXT backdrops
2026-10-25 10:00:24 -07:00
YHH 28cab7d14b Merge pull request #14483 from esengine/c5-theme-syntax-tokens
feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables
2026-10-25 09:53:30 -07:00
YHH bd5ea6ddd3 Merge pull request #14485 from esengine/c5-fix-unreachable-port-flake
test(serve): take the unreachable address from a port that was seen to refuse
2026-10-25 09:53:20 -07:00
EMEEEEMMMM 6e2ba1056a fix(studio): bound DXT image and LZ4 sizes 2026-10-26 00:38:39 +08:00
YHH b458854377 Merge pull request #14472 from SivanCola/codex/isolation-runner-panic-14025
fix(isolation): retain work after runner panic / 保留 runner panic 后的隔离成果
2026-10-25 09:31:16 -07:00
esengine b29f8bf16b test(serve): take the unreachable address from a port that was seen to refuse 2026-10-25 09:30:56 -07:00
esengine d1f852953a test(theme): reach the syntax guard's hooks through the harness bundle, as the built page serves them 2026-10-25 09:19:28 -07:00
SivanCola 6287044a20 merge studio to retain the accepted feedback assertion fix
Problem: the ordinary test synchronization append conflicts with upstream's
independently accepted fix for the same passive draft-persistence boundary.

Root cause: both sides replace the immediate heldDraft assertion with waitFor;
upstream additionally explains why the effect may follow the thumbnail commit.

Fix: resolve the sole conflict to the exact accepted upstream test including
its comment. Preserve all incoming dependency/UI changes and every original
runner-panic implementation Go file. This merge is required by a verified
content conflict, not unrelated base drift; no rebase or force-push.

Verification: final inventory of 239 root packages is reconciled from disjoint
required2 and rest237 commands, both exit0, with frozen Go/module inputs.
Isolation race covers all 51 top-level tests through the original completed
39 trees plus the remaining12 under the same5m budget; original full owner
command hit its cumulative alarm and is retained as exit1, not called pass.
Five affected frontend files have99 tests pass; production typecheck/build,
formatting, root vet, full lint and make check pass without baseline widening.
Exact merge-head hosted/native checks and independent review remain pending.
2026-10-26 00:06:20 +08:00
YHH 52e9abe311 Merge pull request #14481 from esengine/c5-deps-vitest
build(deps): bump vitest to 5.0.5
2026-10-25 09:01:44 -07:00
esengine d0bff6bcf5 feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables 2026-10-25 08:58:37 -07:00
xxoingr 8c42340ea4 test(netclient): pin the stream dialer's proxy boundary
NewStreamDialer is how SSH reaches a remote host under the user's proxy
(remote/attach), and none of dialer.go was exercised: netclient sat at
47% against a 47.2% floor on a declared-sensitive path.

Four tests drive it against fake proxies on loopback:
- an http proxy gets CONNECT to the target with Proxy-Authorization, and
  the credentials never also travel as Authorization;
- a proxy that answers 407 yields an error, not an open stream;
- an https proxy sees a TLS handshake first, so CONNECT and credentials
  never cross in cleartext;
- a SOCKS5 proxy gets username/password auth and the target by name.

Each fails when the matching guard in dialer.go is removed. The
netclient floor rises from 47.2 to 65.8.
2026-10-25 23:54:17 +08:00
YHH f45fb56f62 Merge pull request #14476 from esengine/c5-fix-feedback-flake
test(studio): wait for the held feedback draft before asserting it
2026-10-25 08:44:36 -07:00
SivanCola 127ee9755b test(studio): await feedback draft persistence after paste
Problem: Studio job 121205496051 fails after the pasted image preview appears,
because the retained draft still contains no image.

Root cause: findByAltText observes the DOM commit, while holdDraft runs in a
passive effect. The test assumes those distinct boundaries complete together.
The unchanged merge passed a later external rerun, which does not repair the
scheduling assumption.

Fix: keep the exact single-image assertion for both heading and tab targets,
but await the draft condition with waitFor. Production UI and Go source stay
unchanged.

Verification: full Feedback interaction file 65 pass, restored focused cases
2 pass, typecheck pass. Two independent no-op-save controls each fail both
targets, proving persistence is still checked. Root vet, full lint, make check
and required builtin/boot pass at unchanged baseline 2664. The preceding
native/hosted evidence remains attached to af958f; the new head needs fresh CI.
2026-10-25 23:43:47 +08:00
esengine 0c9d5d1fd1 build(deps): bump vitest to 5.0.5 2026-10-25 08:40:03 -07:00
SivanCola 50a5186930 fix(mutation): reject ambiguous coordination record members
Problem: ambiguous private registry records can be admitted or scavenged as
known dead-owner state.

Root cause: struct JSON decoding merges repeated members and folds case;
unknown-field rejection alone does not preserve the original declarations.

Fix: validate unique decoded names and exact schema spelling for records and
capacity objects before typed decoding and liveness checks. Keep writer
encoding, legal spacing/order, escaped unique names and existing resources.
Join a killed process before closing cooperative input in death fixtures.

Verification: complete 239-package root inventory and required builtin/boot
commands exit 0; mutation race 88 pass, pending wait/opaque composition 116
pass. The compiling preceding decoder fails 22 behavior test/subtest events,
with two controls passing. Formatting, vet, full lint and make check pass
without changing the 2664 baseline. Windows/Linux amd64 compile only; decode
microbenchmark overhead is measured and disclosed separately from product
performance. No runtime or default activation.
2026-10-25 23:34:40 +08:00
YHH d51fb03f6b Merge pull request #14475 from lancy69/fix/13512-prompt-customization
docs: explain supported prompt customization
2026-10-25 08:33:23 -07:00
YHH 7d65acd714 Merge pull request #14474 from lancy69/fix/13919-project-persona-guidance
docs: explain project-specific persona guidance
2026-10-25 08:32:52 -07:00
esengine 0fd8d526e5 test(studio): wait for the held feedback draft before asserting it 2026-10-25 08:29:37 -07:00
YHH 751f0b13ca Merge pull request #14473 from esengine/c5-deps-npm
build(deps): bump mantine, lucide-react, maplibre-gl and vite
2026-10-25 08:27:21 -07:00
EMEEEEMMMM 7273f04d81 feat(studio): decode DXT scene textures 2026-10-25 23:18:33 +08:00
EMEEEEMMMM def94177cd feat(studio): play every scene soundtrack 2026-10-25 23:18:27 +08:00
Lancy e6337ef356 docs: explain supported prompt customization 2026-10-25 23:15:15 +08:00
Lancy d5e7c53401 docs: explain project-specific persona guidance 2026-10-25 23:14:21 +08:00
esengine 9609216f74 build(deps): bump mantine, lucide-react, maplibre-gl and vite to their latest patch and minor releases 2026-10-25 08:05:47 -07:00
YHH 8add01aacc Merge pull request #14468 from imcreazy/feat/rail-opens-a-folded-run
Let the rail say when history is unloaded, and open a folded run
2026-10-25 07:53:27 -07:00
YHH bdf3784f96 Merge pull request #14471 from esengine/c5-deps-go
build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19
2026-10-25 07:40:48 -07:00
imcreazy 186583996d Let the two new rail surfaces take the pointer
`.railhost` sets pointer-events: none, and only `.srail` opted back in. The
unloaded-history marker and the opened run inherited the none, so presses fell
through to the transcript. A style test now holds the rule.
2026-10-25 22:38:44 +08:00
SivanCola af958fdae8 fix(isolation): retain uninspected work after a runner panic
Problem: an isolated task could write files, then lose its discoverable result
identity when a host runner or provider callback panicked.

Root cause: Store.Execute called Finish only after an ordinary runner return;
panic unwind bypassed both completed-result and inspection publication.

Fix: recover at the runner invocation boundary, retain an error-valued panic
as a cause, and publish the existing inspection identity under the result
operation owner. Do not infer physical completion, inspect for emptiness, or
delete work on a panic. Preserve ordinary outcomes and truthful live-only
recovery when bookkeeping cannot be admitted or persisted.

Verification: final 239-package inventory closes using 238 unchanged broad
packages and the final isolation race package; 111 owner race and 3 real boot
race cases pass without skips. The compiling preceding owner fails 11 cases
with 3 ordinary-error controls passing. Required builtin/boot, root vet, full
lint and make check pass at unchanged baseline 2664. Linux/Windows amd64 are
compile-only; hosted/native and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 22:25:13 +08:00
esengine 148a3eabe7 build(deps): tidy the desktop module for the same bumps 2026-10-25 07:05:07 -07:00
YHH 82997a2d8f Merge pull request #14463 from esengine/c5-remove-reading-grips
feat(studio): remove the reading column's drag handles
2026-10-25 06:59:35 -07:00
esengine 682ecbe392 build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19 2026-10-25 06:56:32 -07:00
YHH 40f3156907 Merge pull request #14457 from esengine/dependabot/github_actions/actions-5a66b01c52
build(deps): bump the actions group with 3 updates
2026-10-25 06:40:28 -07:00
SivanCola 966f6ece9b test(ci): qualify background resource owners on native Windows
Problem: Windows PR smoke did not run jobs/delegation unit tests, and pure
delegation changes could skip native platform legs. Linux PR race coverage
also omitted the delegation owner tests.

Root cause: package routing predates the shared background resource lifecycle.
Compiling these owners as boot dependencies does not execute their tests.

Fix: run both complete packages on Windows with a retained JSON inventory,
require all15 portable lifecycle cases to pass, route pure delegation changes
to native legs and include delegation in the existing Linux race sweep.

Verification: platform-scope tests and pinned actionlint pass; the inventory
gate accepts actual local owner output and rejects missing-case, skipped-case
and missing-owner controls. Make check passes at unchanged baseline2664.
All6699 Go files match the qualified implementation commit; no product Go
source or deadline changed. Exact-head native/hosted qualification is pending.
2026-10-25 21:36:48 +08:00
YHH a3473882ce Merge pull request #14467 from esengine/c5-hub-auto-workspace
fix(serve): a new session with no project opens in the app's own workspace
2026-10-25 06:35:57 -07:00
SivanCola 9260e62870 fix(jobs): finalize resources when managed launch is declined
Problem: background task/fleet can strand checkpoint writers, transcript locks
and progress resources when the Manager never invokes the supplied closure.

Root cause: resource ownership moves to closure-only defers before the Manager
checks invalid identifiers or its closed root context. Those branches bypass
the closure and its cleanup, while the caller suppresses its own ending.

Fix: register optional host finalizers at the Manager's managed-execution
boundary. Run all callbacks once before completion after actual closure return
or proven no-launch, preserve cleanup failures/panic and transfer the actual
task/fleet resources and uninvoked terminal projections to that owner.

Verification: final239-package inventory uses238 broad packages plus the final
jobs package after a deterministic liveness-test-only change:23596 test/subtest
passes/215 existing skips. Full owner race648 and real boot race5 pass without
skips; required builtin/boot2346 passes/66 skips. Original public regressions
and compiling predecessor controls fail. Selected token-adapter composition
passes40 race cases. Vet/lint/check pass at unchanged baseline2664.

The liveness pair uses a controlled clock; production thresholds are unchanged.
Managed-closure completion is not descendant scope or durable crash recovery.
Refs #14025
2026-10-25 21:28:02 +08:00
esengine 8b4dea758d fix(serve): a new session with no project opens in the app's own workspace 2026-10-25 06:15:17 -07:00
SivanCola 3858f0d51f fix(checkpoint): separate writer admission from registry locking
Problem: cancelled background task/fleet calls can remain behind checkpoint
admission while the shared writer registry also blocks queries and cleanup.

Root cause: registration holds the registry mutex across the barrier, durable
undo retirement, notification and Store persistence. Callers discard their
admission context and defer cleanup by reusable string ID alone.

Fix: reserve identified pending registrations, prepare outside the registry
mutex and serialize latest snapshot publication separately. Pass context at
all task/fleet sites and transfer token-bound cleanup to admitted jobs. Preserve
legacy APIs, completed undo retirement and active work across parent turns.

Verification: original root239 passes with23590 test/subtest passes/215 existing
conditional skips; required builtin/boot2346 passes/66 skips. Complete owning
race487 passes/zero skips; real boot handoff race5 passes/zero skips. Compiling
preceding-owner/caller controls fail the intended assertions. Vet/lint/check
pass without raising baseline2664. Linux/Windows compilation is not native QA.

The jobs-owner no-launch finalization gap remains explicitly separate.
Refs #14025
2026-10-25 20:43:12 +08:00
esengine 1d723d2a28 feat(studio): remove the reading column's drag handles 2026-10-25 05:40:54 -07:00
YHH a43ba964fd Merge pull request #14458 from esengine/c5-grips-need-room
fix(studio): draw the reading column's handles only when there is a range to move in
2026-10-25 05:26:56 -07:00
YHH f7c2506cf2 Merge pull request #14450 from esengine/c5-drop-topbar-sandbox
fix(studio): drop the top bar's second route to Tools and permissions
2026-10-25 05:26:40 -07:00
esengine 4db40e881c fix(studio): draw the reading column's handles only when there is a range to move in 2026-10-25 04:59:49 -07:00
dependabot[bot] 6c4f00ce42 build(deps): bump the actions group with 3 updates
Bumps the actions group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [actions/setup-python](https://github.com/actions/setup-python).


Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-25 11:56:24 +00:00
YHH a76cbec375 Merge pull request #14447 from esengine/c5-tool-blurbs
fix(studio): give the config-gated tools a blurb in the settings tool list
2026-10-25 04:53:06 -07:00
esengine 3a67b488a2 test(studio): the narrow-window guard no longer expects the removed top-bar button 2026-10-25 04:52:55 -07:00
esengine 553ed22a50 fix(studio): drop the top bar's second route to Tools and permissions 2026-10-25 04:39:26 -07:00
esengine 889211c785 fix(studio): give the config-gated tools a blurb in the settings tool list 2026-10-25 04:31:31 -07:00
YHH 540c14998b Merge pull request #14443 from esengine/c5-pane-wide-drop
feat(studio): a file dropped anywhere on the conversation pane is taken
2026-10-25 04:26:35 -07:00
YHH 9fce63aa5a Merge pull request #14442 from esengine/c5-price-currency-select
fix(studio): style the model-price currency select like the inputs beside it
2026-10-25 04:03:58 -07:00
esengine c41306961a feat(studio): a file dropped anywhere on the conversation pane is taken 2026-10-25 03:52:53 -07:00
esengine 0865bc1071 fix(studio): style the model-price currency select like the inputs beside it 2026-10-25 03:46:57 -07:00
YHH 46d63cf414 Merge pull request #14440 from SivanCola/codex/mutation-record-validation-14025
fix(mutation): retain unsupported registry records / 保留未知事务记录并拒绝准入
2026-10-25 03:30:33 -07:00
SivanCola 619c5b243d fix(mutation): retain unsupported coordination records
Problem: unfamiliar coordination records can be ignored during admission and
deleted after their OS owner exits.

Root cause: registry decoding discarded unknown fields, and version-1 resource
validation accepted every effect except capacity.

Fix: reject unfamiliar object fields and unsupported path effects before
liveness/scavenging, retain original bytes, and include record identity in the
typed protocol error. Preserve known version-1/2 records and omitted-effect
legacy semantics without changing writers or activating the runtime protocol.

Verification: complete 239-package root inventory, full mutation race,
compiling preceding-owner counterexamples, actual owner-process death,
known-record controls, selected waiting-observation composition, required
builtin/boot and static gates. Windows/Linux builds are compile-only local
evidence; exact-head hosted/native review remains separate.
2026-10-25 17:56:13 +08:00
YHH 1c330cbd9e Merge pull request #14438 from SivanCola/codex/isolation-settlement-fence-14025
fix: fence ordinary isolated-result writers / 隔离结果操作识别持久恢复屏障
2026-10-25 02:54:14 -07:00
YHH 6343cc7970 Merge pull request #14432 from xxoingr/fix/pane-tab-close-stays-inside
fix(studio): a pane tab's close button stays inside the tab
2026-10-25 02:53:50 -07:00
YHH ef0874a111 Merge pull request #14436 from esengine/c5-fix-14434-retention
fix(agent): cap verbatim retention by the compaction trigger
2026-10-25 02:21:47 -07:00
SivanCola adcc4b4d80 fix(isolation): fence ordinary result operations before recovery
Problem: A durable future settlement record must stop ordinary result
writers, including stale queued operations and destructive siblings.

Root cause: The per-ID mutex serializes operations but carries no durable
knowledge that a recovery owner has published a settlement fence.

Fix: Check host-rooted external fence metadata after operation ownership,
refuse any existing entry without interpreting its bytes, recheck context
and release only this attempt on refusal. Cover tools, Finish/Abort, empty
inspection, sweep, a real child process and actual boot/controller rebuild.
No journal writer, schema or complete recovery is introduced. Preceding
writers ignore this fence; future activation needs a cooperating protocol.

Verification: Full isolation race100 passes, zero skips; real boot race3
passes. Preceding owner compiles and fails15 test/subtests, including a
child process; predecessor boot owner actually writes source in both cases.
Final root inventory/required and static qualification recorded in PR.
Linux/Windows amd64 compilation is separate from native evidence.

Refs #14025
Refs #14133
2026-10-25 17:20:38 +08:00
xxoingr caf5c2d45e docs(release-notes): keep the pane tab note's English line within 200 columns 2026-10-25 17:19:50 +08:00
YHH bb273fec87 Merge branch 'studio' into fix/pane-tab-close-stays-inside 2026-10-25 01:56:24 -07:00
esengine e1c38d3036 fix(agent): cap verbatim retention by the compaction trigger, not only the window
User turns, retained failures and [[keep]] turns were budgeted as shares of
the window. On a 1M window under the 100K cost trigger those shares plus the
tail passed the trigger, so every fold was refused as still at or above it.
Each budget is now also held to a share of the trigger.
2026-10-25 01:55:42 -07:00
YHH 463e2f76f0 Merge pull request #14435 from esengine/c5-css-budget
style(studio): hold the stylesheet to its line budget
2026-10-25 01:55:32 -07:00
esengine 172ae8b80f style(studio): hold the stylesheet to its line budget after two merges landed together 2026-10-25 01:42:56 -07:00
YHH d03be98bcb Merge pull request #14425 from SivanCola/codex/worktree-literal-paths-14025
fix(worktree): keep integration paths literal / 修复特殊文件名导致隔离整合越范围写入
2026-10-25 01:22:31 -07:00
xxoingr decba9d9eb fix(studio): a pane tab's close button stays inside the tab
The folder and host labels on a pane tab were flex: none, so a long one
could not shrink: the title, the only part that could, went to zero width
and the close button was pushed past the tab's edge onto the next tab,
where a press activates that tab instead.

Both labels now shrink to an ellipsis and take at most 40% of the tab, and
the title keeps at least 4em. perf/tab-close-inside.mjs checks, in the
real stylesheet, that the close button stays inside its tab and is what a
press at its centre reaches, with a long title, a long folder, and both a
long folder and a long host.
2026-10-25 16:21:25 +08:00
YHH 3af39d9d0a Merge pull request #14407 from EMEEEEMMMM/feat/wallpaper-scene-mvp
Feat/wallpaper scene mvp
2026-10-25 01:00:15 -07:00
YHH 4775113397 Merge pull request #14415 from SivanCola/codex/isolation-native-ci-14025
ci: run complete mutation and isolation owners on Windows / 补齐事务与隔离机制原生及竞态验收
2026-10-25 00:57:35 -07:00
YHH 21a4569b39 Merge pull request #14406 from lancy69/feat/14139-thinking-preview
feat(studio): separate collapsed thinking previews from folding
2026-10-25 00:56:22 -07:00
SivanCola 479c8e7b16 merge: adopt reviewed studio owners for literal path review 2026-10-25 15:44:19 +08:00
SivanCola c79c5192ee test(mutation): pin legacy wait admission and adopt reviewed Windows fixes 2026-10-25 15:30:48 +08:00
Lancy d42cdbb434 feat(studio): separate collapsed thinking previews from folding 2026-10-25 15:26:36 +08:00
YHH 42c3c9f195 Merge pull request #14420 from esengine/c5-prefs-centered
fix(studio): centre the enlarged settings window's content column
2026-10-25 00:25:14 -07:00
YHH 526908630a Merge pull request #14429 from esengine/c5-serve-bind-first
fix(cli): serve binds its address before it reports anything
2026-10-25 00:24:55 -07:00
EMEEEEMMMM 7d9e9b7b6c fix(studio): bound scene image decoding 2026-10-25 15:14:56 +08:00
YHH 2dce532bf5 Merge pull request #14419 from chuiwenwei/fix/record-the-tokens-the-price-came-from
fix(stats): record the tokens the price came from
2026-10-25 00:14:02 -07:00
YHH 083d30bec8 Merge pull request #14416 from chuiwenwei/fix/lease-probe-asks-the-lock
fix(sessionstore): the lease probe asks the lock, not the sidecar
2026-10-25 00:13:21 -07:00
YHH d4d8876e92 Merge pull request #14423 from chuiwenwei/fix/history-keeps-the-arguments-it-restored
fix(tui): keep the arguments a restored tool call actually had
2026-10-25 00:13:01 -07:00
YHH 4af626e714 Merge pull request #14424 from esengine/c5-within-exact
fix(mutation): a prefix is exact only when its spelling agrees
2026-10-25 00:12:29 -07:00
YHH ffe06c2db8 Merge pull request #14422 from esengine/c5-gitin-stdout
test(worktree): gitIn returns standard output only
2026-10-25 00:12:07 -07:00
SivanCola 5671e00f3f ci: require mutation owner coverage and probe capacity without a latency alarm 2026-10-25 15:08:12 +08:00
esengine c3d192dc4b fix(cli): serve binds its address before it reports anything
Only a serve with --port-file or --open listened first; a plain serve printed the
banner, share link, token path and the non-loopback warning and bound afterwards, so
an address that can never work still looked like a running server. Every command now
binds first, the report uses the bound address, and the now-unreachable
bind-inside-RunGraceful branch is gone.
2026-10-25 00:04:33 -07:00
esengine 5aa737bb95 fix(studio): centre the enlarged settings window's content column
.prefs-col had a max-width and no inline margin, so once the settings window was
enlarged the column hugged the navigation and every extra pixel piled up on its
right. In the enlarged mode the column is now centred in the room beside the nav.
2026-10-24 23:53:26 -07:00
SivanCola 24a775a94c fix(worktree): preserve literal paths through Git application
Problem: a candidate filename could act as a Git wildcard or magic pathspec,
writing an undeclared source file or dropping the actual literal result.
Newline-delimited hashing could also reinterpret a single filename.

Root cause: option separation was mistaken for literal path selection, and
ambient Git pathspec flags could override the host's intended selection.

Fix: centralize literal pathspecs for tree/index inspection and the shared
restore owner; C-quote hashing input by bytes; remove inherited selection
flags in the common host Git environment while preserving explicit flags.
Whole-workspace application reuses the shared writer. Keep the portable
stdout/stderr Git fixture repair aligned with the native CI follow-up.

Verification: complete 239-package root inventory, three-owner race, real boot
race, uncached required builtin/boot, compiling predecessor controls, six
Windows/Linux owner cross-compiles, formatting, root vet, lint and make check.
Native hosted checks and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 14:51:01 +08:00
esengine b26eddec18 fix(mutation): a prefix is exact only when its spelling agrees
withinExact used filepath.Rel, which ignores case on Windows, so a different
spelling of an absent file was reported as held there while heldWithin's comment
and TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings require the filesystem to
prove two spellings are one file. Require the prefix bytes to agree as well.
2026-10-24 23:43:39 -07:00
Will aeeff97700 fix(tui): keep the arguments a restored tool call actually had
The terminal's copy of /history's tool call had no argumentNormalization
field, so a restored transcript rebuilt the card from Arguments alone —
the proxy's effective text — and drew it as what the model wrote. The
original was not merely hidden; it never left the decoder.

Carry the host's normalization through HistoryToolCall into the
eventwire.Tool the restored transcript draws and inspects, following the
serve payload and the desktop copy that already do. The card still draws
only the effective arguments: showing the original is a UI decision for a
later change, and changing the card would also change a live call.

Register the pair as a declared subset of the serve historyToolCall in
wireparity, naming the six fields the terminal decides from. The exact
table is the desktop's and is read as a TypeScript interface, so a Go
reader belongs in the partial table, which compares both ways over the
declared names.
2026-10-25 14:41:21 +08:00
esengine 15f72be36f test(worktree): gitIn returns standard output only
The helper used CombinedOutput, so on Windows, where core.autocrlf is true, git's
'LF will be replaced by CRLF' warning on stderr was returned with the blob id and
TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type passed the pair to
update-index --cacheinfo. Standard output is what a caller reads; standard error
now only joins a failure message.
2026-10-24 23:38:00 -07:00
YHH 8bd18a5db4 Merge pull request #14233 from lancy69/fix/14220-execution-typography
fix(studio): execution typography follows body preferences
2026-10-24 23:36:49 -07:00
SivanCola eab502bd8e test(worktree): keep Git diagnostics out of object IDs
Problem: the newly executed native Windows owner inventory failed while
constructing its candidate-type fixture, before product application ran.

Root cause: gitIn returned CombinedOutput, so a successful hash-object CRLF
warning on stderr was concatenated with its object ID and fed to update-index.

Fix: keep stdout and stderr separate at the shared fixture owner, retaining
both in failure diagnostics. Pin autocrlf=true in the compatibility regression
so this contract is exercised on every host. No production or gate changes.

Verification: the preceding helper compiles and reproduces the same diagnostic
pollution locally; the complete worktree race, Windows compilation, required
builtin/boot, vet, lint and make check pass. Hosted native revalidation remains
separate from local and earlier full-root evidence.
2026-10-25 14:35:10 +08:00
YHH 1aa101e6a9 Merge pull request #14412 from esengine/c5-measure-forgets-drag
fix(studio): a line-width preset wins over a width the column was dragged to
2026-10-24 23:31:33 -07:00
Will 495f8ede4c fix(stats): record the tokens the price came from 2026-10-25 14:16:21 +08:00
Will de2256dfc4 fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its `.lease.json`
sidecar can be deleted by a person or a file scanner while the process still
owns the session. The probe answered free on a missing sidecar without looking
at the lock, so session list said idle, task list flipped a running item to
interrupted, and redact-sessions would rewrite a live transcript. It now asks
the lock whenever a lock file exists, and still creates nothing on the common
uncontended path.
2026-10-25 14:06:30 +08:00
Will 0e63e353fe fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its
`.lease.json` sidecar can be deleted by a person or a file scanner while the
process still owns the session. The probe answered free on a missing sidecar
without looking at the lock, so session list said idle, task list flipped a
running item to interrupted, and redact-sessions would rewrite a live
transcript. It now asks the lock whenever a lock file exists, and still
creates nothing on the common uncontended path.
2026-10-25 14:04:37 +08:00
SivanCola 3fa1ea460c ci: execute complete isolation owners on native Windows
Problem: green PR checks could omit complete isolation/worktree owner tests on
Windows and under Linux race; pure isolation changes could skip native legs.

Root cause: the smoke and race package allowlists lacked both owners, while
the platform classifier treated untagged isolation Go as neutral.

Fix: classify isolation paths as platform-relevant; execute complete Windows
owners with an uploaded JSON inventory, require both package terminals and
portable boundary tests, and include both packages in PR race. Replace the
redundant rollback subset. Preserve existing permissions, deadlines and cache
rules; do not alter product code.

Verification: compiling old-workflow/classifier regressions, exact inventory
positive/negative controls, final classifier/testenv suites, complete 238-package
root inventory, three-owner race, required builtin/boot, vet, lint and check.
Local execution is macOS; native hosted outcomes remain a separate gate.

Refs #14025
Refs #14133
2026-10-25 14:02:42 +08:00
YHH bf472862ca Merge pull request #14368 from SivanCola/codex/mutation-primitive-14025
feat(state): add mutation transaction primitive / 写入事务基础机制与测试
2026-10-24 22:59:58 -07:00
esengine d28a6089fc fix(studio): a line-width preset wins over a width the column was dragged to
The width a hand set is kept in --flow-w and outranks the stylesheet, so once the
reading column had been dragged, choosing the full-width preset changed --doc but
not the column and did nothing. Choosing a preset now drops the dragged width.
2026-10-24 22:54:22 -07:00
YHH 8c669ebe61 Merge pull request #14410 from SivanCola/codex/worktree-entry-modes-14025
fix(worktree): compare file modes during integration / 修复隔离整合丢失文件模式与类型
2026-10-24 22:35:06 -07:00
YHH df14e2f085 Merge pull request #14409 from SivanCola/codex/isolation-operation-leases-14025
fix(isolation): serialize result operations through cleanup / 隔离结果操作统一跨进程租约
2026-10-24 22:33:54 -07:00
SivanCola 600b4b8cb4 fix(worktree): compare entry modes during isolated application
Problem: executable-only changes and equal-byte regular/link changes could be
discarded as already applied, and source metadata edits could be overwritten.

Root cause: baseline, candidate and live file versions retained only blob IDs,
collapsing distinct Git modes and file kinds into one identity.

Fix: compare Git mode and blob together, follow the checkout's trusted/index
mode rules, hash real links and emulated links with their actual Git semantics,
and reject unsupported transitions before any source write. Keep the user's
index and configuration unchanged.

Verification: complete 238-package root inventory, worktree/isolation race,
real boot race, required builtin/boot, compiling predecessor controls and
operation-lease composition. Formatting, root vet, lint and make check pass.
Cross-compilation is recorded separately from pending hosted/native review.

Refs #14025
Refs #14133
2026-10-25 13:08:35 +08:00
EMEEEEMMMM e01aae97fd fix(studio): wrap wallpaper scene parse errors 2026-10-25 13:02:47 +08:00
SivanCola 36cb5e6c7b test(filelock): mirror relative-root rename admission
Problem: the rooted-lock rename regression failed on Windows before reaching
the lock operation.

Root cause: Go's direct os.OpenRoot uses a Windows handle without delete
sharing, while production opens the lock directory relative to its managed
Root using an Openat handle with delete sharing. The fixture conflated them.

Fix: open the test directory through its parent Root, matching the production
constructor. Retain the real rename/replacement and physical lock-location
assertions on every platform. Document the direct-root Windows limitation.
No production code, timeout or platform skip changes.

Verification: complete filelock race (25 passes), Windows amd64 compilation,
required builtin/boot, root vet, full lint and make check. The prior full root
qualification is retained separately; new hosted Windows evidence is pending.
2026-10-25 13:00:44 +08:00
EMEEEEMMMM aaaaf4d2ef fix(studio): address wallpaper scene review 2026-10-25 12:42:39 +08:00
SivanCola d1fe589bf8 fix(isolation): serialize result inspection and settlement
Problem: concurrent Stores could inspect one retained result and then write
or remove it while another operation was still using its candidate.

Root cause: reinspection checked authority without retaining a common owner.
Paths, first Finish, Abort and expiry cleanup could bypass Apply/Discard too.

Fix: add directory-handle OS file locks and one persistent per-result mutex
namespace. Hold admission through reinspection, mutation and synchronous
cleanup; preserve lock identity, active owners and live-only Finish retries.
Forward path-query cancellation and keep legacy writer limitations explicit.

Verification: complete 238-package root inventory, owner and real boot race,
compiled predecessor regressions, actual prior reader/sweeper compatibility,
Windows/Linux owner cross-compilation, formatting, vet, lint and make check.
No new native-platform or independent-review result is claimed here.

Refs #14025
Refs #14133
2026-10-25 12:33:34 +08:00
EMEEEEMMMM 89935ce452 fix(studio): keep scene controls out of the composer layout 2026-10-25 12:27:43 +08:00
EMEEEEMMMM db7a840a3f docs(release-notes): name the Wallpaper Engine scene import 2026-10-25 12:27:43 +08:00
EMEEEEMMMM b242c67680 feat(studio): import Wallpaper Engine scenes with sound and volume 2026-10-25 12:27:43 +08:00
YHH 8ebd327b12 Merge pull request #14408 from esengine/c5-actions-split
refactor(studio): move the feedback actions out of the full actions file
2026-10-24 21:21:18 -07:00
Lancy 04bdd5702b fix(studio): execution typography follows body preferences 2026-10-25 12:15:49 +08:00
esengine 811e335a89 refactor(studio): move the feedback actions out of the full actions file
actions.ts sat exactly at the 800-line ceiling, so any pull request that added a
single action id failed repolint. The 44 feedback.* ids are one concern and move
to actions_feedback.ts, the way the market, schedule and document groups already
live in their own files; the registry content is unchanged.
2026-10-24 20:55:34 -07:00
YHH 7a530b37ee Merge pull request #14405 from SivanCola/codex/checkpoint-cancellable-admission-14025
fix(checkpoint): cancel admission and preserve other writers / 取消检查点等待并保护其他写入者
2026-10-24 20:51:30 -07:00
SivanCola ee9f75e286 fix(checkpoint): cancel admission before invocation entry
Problem: cancelled calls remain parked behind checkpoint exclusivity, delay
their provisional resource cleanup, and may later enter hooks or execution.

Root cause: condition waits do not consume the invocation context. Observer
cleanup also exposes a shared writer-count decrement that can run twice.

Fix: centralize context-aware barrier admission, recheck cancellation after
durable preparation, return idempotent permits and bind the Agent caller.
Preserve completed undo retirement and every other active owner's admission.

Verification: deterministic cancellation and owner-preservation schedules,
compiling predecessor behavior failures, real boot direct/deferred coverage,
durable undo reloads, full root inventory, owner/boot race and required suites;
gofmt, vet, full lint and make check with unchanged gate baseline.
2026-10-25 11:14:07 +08:00
YHH e29812d38f Merge pull request #14404 from esengine/c5-edit-crlf
fix(edit): a literal edit match keeps the line ending of the line it lands in
2026-10-24 19:53:30 -07:00
YHH e4779b9f95 Merge pull request #14402 from SivanCola/codex/isolation-cached-result-14025
fix(isolation): reject settled result caches / 拒绝已处理成果的过期缓存
2026-10-24 19:53:13 -07:00
esengine 772b2f1de6 fix(edit): a literal edit match keeps the line ending of the line it lands in
The literal branches of applyOldStringEdit wrote the replacement verbatim, so a
multi-line new_string inserted bare LF into CRLF files while only the fuzzy paths
converted it. Each literal replacement now takes the ending of its own line, which
also keeps an LF region of a mixed file free of stray CR.
2026-10-24 19:28:14 -07:00
SivanCola 749e691863 fix(isolation): reinspect published results before settlement
Problem: a Store could apply an old cached Git tree after another Store had already applied or discarded that result, resurrecting its bytes in the source workspace.

Root cause: cache hits skipped durable publication and candidate admission; Git objects survive removal of the worktree. Refreshing without comparing the previous observation would also silently widen an earlier path declaration.

Fix: always reuse durable recovery/inspection for lookups, keep finished caches out of Pending, and refuse changed baseline/candidate/target trees until fresh path admission. Preserve first-Finish live-only and unsupported-version checks. No persisted, permission, tool-definition or default changes. Cross-process settlement locking and journals remain separate.

Verification: final complete 238-package root inventory passes (217 pass/21 no tests;23310 test/subtest passes,215 existing skips,194 valid cached packages). Full isolation race52 passes and real boot focus31 passes with no skips. The accepted predecessor Store compiles and fails9 behavior events. Gofmt, vet, full lint, make check and diff checks pass. Additional required builtin/boot command is running; new hosted/native evidence remains pending.
2026-10-25 10:20:29 +08:00
YHH e59262c519 Merge pull request #14401 from SivanCola/codex/isolation-runtime-options-14025
fix(isolation): honor task runtime options / 隔离任务遵守模型与步数参数
2026-10-24 19:06:13 -07:00
YHH 35003e6628 Merge pull request #14400 from SivanCola/codex/isolation-first-finish-14025
fix(isolation): retain first-Finish failures / 首次成果检查失败保留隔离结果
2026-10-24 19:05:34 -07:00
SivanCola cbac083536 fix(isolation): preserve task runtime selection and step caps
Problem: worktree task dispatch silently discarded effort, step limits and configured subagent models; parent model aliases also lost live session effort overrides.

Root cause: isolation branched before ordinary task runtime resolution, and alias resolution reloaded config instead of retaining the canonical parent runtime entry.

Fix: reuse shared task/model/effort/budget resolution, pass ephemeral options to the unattended Build, and canonicalize parent aliases to their runtime entry. Existing unsupported isolation restrictions remain refused; best-of keeps its prior policy. No persisted, wire, tool-definition or default changes.

Verification: complete 238-package root inventory passes (217 pass/21 no tests; 23289 test/subtest passes, 215 existing conditional skips, 198 cached packages). Owning delegation/isolation race has 400 passes; real boot focus has 39 passes, no skips. Compiling old owners fail 18 runtime scenarios plus parent, with two automatic-budget controls passing. First-Finish composition race has 32 passes. Gofmt, vet, full lint, make check and diff checks pass. Supplemental required builtin/boot is still running; hosted/native results remain pending.
2026-10-25 09:46:07 +08:00
SivanCola cf5fedae64 test(isolation): cover CRLF checkout during first-Finish recovery
Problem: Windows CI rejected successfully recovered isolated output because the new boot assertion required LF bytes.

Root cause: Git checkout may apply core.autocrlf conversion; recovery preserves the expected text with CRLF.

Fix: explicitly enable autocrlf in the disposable repository and normalize CRLF for the content assertion, retaining same-ID recovery and source-before-apply checks.

Verification: the old assertion compiles and deterministically fails with CRLF on macOS; the repaired four boot regressions pass under race (6 test/subtest events). Required builtin/boot pass (2291 passes, 66 existing conditional skips); vet, lint, make check and formatting pass. Native Windows replacement CI remains pending.
2026-10-25 09:36:50 +08:00
SivanCola d816d53347 fix(isolation): retain results after first finalization failure
Problem: isolated work could lose its recovery ID or be removed when the
first candidate inspection or completed-result publication failed.

Root cause: Finish treated inspector/persistence errors like successful
empty results, and Execute hid Finish errors behind a runner failure.

Fix: retain the candidate and original ID, publish a separate versioned
inspection marker without overwriting existing records, reuse rooted
admission and same-ID inspection, and report live-only retry explicitly
when recovery publication is unavailable. Join both errors. Keep normal
completed records unchanged and refuse unsupported inspection versions.

Verification: complete 238-package root inventory, isolation and real boot
race tests, compiling preceding-owner behavior failures, actual previous
reader/sweeper compatibility controls, formatting, vet, lint and check.
Local evidence is macOS only; hosted checks and independent review remain.
2026-10-25 09:01:57 +08:00
YHH 5b2b297e6e Merge pull request #14399 from SivanCola/codex/hook-checkpoint-admission-14025
fix(agent): admit reader hook writes through checkpoints / 只读调用 hook 纳入检查点写入准入
2026-10-24 17:49:06 -07:00
SivanCola ab68ff145f fix(agent): admit reader hook effects through checkpoint barrier
Problem: A reader's hooks can change files without participating in rewind
exclusion, retiring undo, or recording the checkpoint's coverage gap.

Root cause: The invocation caller selects checkpoint admission using only the
concrete tool's read-only flag, even though it already knows that its hook set
may write. The checkpoint owner is correct but this caller bypasses it.

Fix: Include possible tool-hook writes in the two existing admission/coverage
conditions. Reuse the existing writer permit and deferred cleanup. Keep empty
hook readers unchanged and keep tool execution, coverage, and effects separate.

Verification: Compiling original owners fail the actual callback/undo and four
boot regressions; fixed owner/checkpoint and boot race tests pass. Full root
inventory, required builtin/boot, formatting, vet, lint and make check are
qualified separately in the PR. Asynchronous hook process lifetime and the
barrier's existing cancellation limits remain explicit follow-up work.
2026-10-25 08:14:28 +08:00
YHH ffccf6877c Merge pull request #14397 from SivanCola/codex/provisional-write-admission-14025
fix(lease): release provisional write claims / 撤销未执行写入的临时占用
2026-10-24 17:03:54 -07:00
YHH b786e30b7a Merge pull request #14398 from SivanCola/codex/hook-workspace-invalidation-14025
fix(agent): preserve hook workspace invalidation / 保留 hook 工作区失效通知
2026-10-24 16:48:39 -07:00
SivanCola 72b725862f fix(agent): preserve workspace invalidation for hook effects
Problem: A hook can change files before refusing a tool or outside a reader's
declared paths without notifying an opted-in workspace sink. Nested writers
also omit publication until an outer writer happens to complete.

Root cause: Invalidation is gated by concrete tool execution and classification,
and publication belongs to the batch caller rather than the invocation owner.

Fix: Track possible tool-hook effects separately from Executed, publish through
the shared invocation exit after cleanup, and share result metadata projection
between batch and nested calls. Unknown hook scope invalidates content, tree and
Git metadata without claiming a confirmed write or widening authorization.

Verification: Full root 238-package inventory passed; complete agent/event race,
eight boot direct/capability hook cases under race, original-owner compiling
negative controls, and provisional-lease composition passed. Formatting, vet,
full lint and make check passed without changing the baseline. Local evidence
is macOS only; optional sink wiring and evidence accounting remain separate.
2026-10-25 07:29:49 +08:00
SivanCola bc330925c0 fix(lease): separate run and yielded-claim lifetimes
Problem:
Aborting provisional admission can expire an unrelated live user wait or
retake paths deliberately yielded to a user. A pending wait is also lost
when the last writer or an earlier restoration finishes afterward.

Root cause:
Physical release reset run state, and one retained extent represented both
active and parked ownership. Restoration dropped its temporary protection
before reholding the original caller. Expired queued grants and retention
callbacks could contribute resources to the next run.

Fix:
Separate active and parked retention, advance generations at the final
participating run end, and reconcile outstanding yields whenever writing
holds drain. Keep restoration protection through rehold, fence late
admission and retention, and clean expired grants without dropping current
writers. Actual in-flight writes stay protected after their run ends.

Verification:
The submitted owner compiles and fails all 14 selected test/subtest failure
events across the new public-API schedules. Corrected full workspacelease
and agent race pass, as do actual boot race, all 238 packages in disjoint
required/remaining partitions, required builtin/boot, vet, lint and check.
Local evidence is macOS only; hosted checks and review apply to the new head.

Refs #14025
Refs #14133
2026-10-25 06:50:34 +08:00
SivanCola affb71f9dd fix(lease): release provisional paths before mutation entry
Problem:
A refused write can pin a never-executed path through later model requests.
Repeated failed restoration can also report success to an expired caller.

Root cause:
Admission merges every request into the run-retained extent before local
preparation succeeds. Call cleanup only decrements in-flight accounting.
Resume keeps its once-only result in per-invocation locals, and refused
upgrades omit the terminal wait notice after registrar contention.

Fix:
Track identified provisional calls separately from retained extents and
publish their remaining union through the existing claim registrar. Retain
before possibly writing hooks or execution. Preserve earlier writes and
parallel claims, report failed cleanup conservatively, validate lifetimes
before and after reacquisition, and cache terminal callback outcomes.
Close registrar waits on refusal and clear completed cleanup's wait state.

Verification:
Complete 238-package inventory and required builtin/boot pass on the final
source; full workspacelease/agent race and focused real boot race pass.
Compiling original execution, resume and upgrade-wait owners fail the
selected deterministic regressions. Formatting, vet, lint and check pass.
Local platform evidence is macOS only; hosted checks and review are separate.

Refs #14025
Refs #14133
2026-10-25 05:36:29 +08:00
SivanCola 7639c30135 Merge studio and drop obsolete design-document lint repair
Problem: Both extracted PRs became modify/delete conflicted while their local
qualification was running.

Root cause: Upstream removed FIRST_RUN_WORKSPACE.md in #14367 after the slices
had repaired its inherited lint metadata and paragraph violations.

Fix: Accept the upstream deletion and remove the now-obsolete prerequisite
repair. Retain every feature, regression and security-document change.

Verification: make check passes. The staged merge changes no Go file relative
to the independently qualified slice heads; their full package/race evidence
remains explicitly scoped to those source trees. Hosted checks must run on
the new merge heads. No unrelated production change or baseline relaxation.

Refs #14025
Refs #14133
2026-10-24 22:53:20 +08:00
SivanCola db465ec26f feat(state): introduce independently reviewable mutation transactions
Problem: Task-length write claims cannot independently represent permission,
assignment and the physical mutation lifetime across cooperating runtimes.

Root cause: Shared admission, input preconditions, cancellation and release
have no common short-transaction owner that callers can adopt independently.

Fix: Extract the mutation resource/queue/capacity owner and content versions
from the preserved concurrency implementation. Add minimal non-blocking legacy
claim compatibility and exception-safe publication cleanup. Keep all runtime,
isolation, hook, controller and default activation outside this review slice.
Document authority, actual sinks, compatibility and unenforced boundaries,
and map the remaining source into dependent review units. Normalize an
inherited design-document lint prerequisite without relaxing its baseline.

Verification: make check, full lint and root vet pass. Owning-package race
passes, including real subprocess death and a strengthened cleanup regression
that fails without the cleanup via compiler overlay and passes with it.
Builtin passes. The full boot invocation reaches its 600-second package alarm;
all 811 top-level tests close in four inventory-verified groups (808 pass,
3 conditional skips, no missing/unexpected/failing selected tests). Linux and
Windows amd64 test binaries cross-compile; native platform and hosted CI
evidence are separate gates. The remaining root-package sweep is in progress.

Refs #14025
Refs #14133
2026-10-24 22:23:12 +08:00
287 changed files with 18960 additions and 1715 deletions

No files matched your search

+72 -6
View File
@@ -198,7 +198,8 @@ jobs:
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, the startup/checkpoint packages with portable
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
# filesystem contracts, plus cmd/. Isolation/worktree/mutation have a
# complete JSON inventory below. The full ./... sweep stays on pushes
# to main-v2; the Unix legs always run the full suite. Keep the job-level
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
@@ -222,11 +223,76 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
- name: test (Windows isolation owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
(["TestResultOperationUsesCrossProcessOwnership",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/content",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type",
"TestApplyPathsEmulatedLinksUseSnapshotFilters",
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
' "$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'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: isolation-owners-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/isolation-owners.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows background resource owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 2 -timeout=18m -json ./internal/tools/jobs/... ./internal/runtime/delegation/... | tee "$RUNNER_TEMP/background-resources.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/tools/jobs", "reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
(["TestJobFinalizationCoversInvokedAndUninvoked/success",
"TestJobFinalizationCoversInvokedAndUninvoked/error",
"TestJobFinalizationCoversInvokedAndUninvoked/panic",
"TestJobFinalizationCoversInvokedAndUninvoked/closed",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_session",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_kind",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/invoked",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/uninvoked",
"TestJobFinalizationPrecedesCompletionPublication",
"TestJobFinalizationCloseBetweenPublicationAndLaunch",
"TestJobFinalizationWaitsForClosureAfterLogicalCancellation",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/invalid_session",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/invalid_session"] - $tests | length) == 0
' "$RUNNER_TEMP/background-resources.jsonl"
- name: upload Windows background resource inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: background-resources-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/background-resources.jsonl
if-no-files-found: ignore
retention-days: 7
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -315,7 +381,7 @@ jobs:
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.12'
+3 -2
View File
@@ -159,9 +159,10 @@ jobs:
node perf/composer-toolbar-row.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/flowgrip-quiet.mjs
node perf/flow-narrow.mjs
node perf/prefs-wide-centered.mjs
node perf/tab-pip.mjs
node perf/syntax-tokens.mjs
node perf/tab-close-inside.mjs
node perf/context-card-hover.mjs
node perf/sessquick.mjs
node perf/sessionrows.mjs
+7 -7
View File
@@ -32,15 +32,15 @@
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.14",
"@lezer/highlight": "^1.2.5",
"@mantine/charts": "9.7.2",
"@mantine/core": "9.7.2",
"@mantine/hooks": "9.7.2",
"@mantine/charts": "9.7.3",
"@mantine/core": "9.7.3",
"@mantine/hooks": "9.7.3",
"@noble/curves": "2.4.0",
"codemirror": "^6.0.2",
"dompurify": "^3.4.17",
"katex": "^0.19.0",
"lucide-react": "^1.60.0",
"maplibre-gl": "6.14.0",
"lucide-react": "^1.61.0",
"maplibre-gl": "6.15.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"papaparse": "^5.7.0",
@@ -68,7 +68,7 @@
"jsdom": "^30.1.3",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.4",
"vitest": "^5.0.4"
"vite": "^8.3.5",
"vitest": "^5.0.5"
}
}
@@ -0,0 +1,127 @@
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail) => {
console.log(`${ok ? " ok" : "FAIL"} ${name}: ${JSON.stringify(detail)}`);
if (!ok) fails.push(name);
};
const textKeys = ["title", "count", "name", "arg", "normalization", "originalInput"];
const weightKeys = textKeys.filter(key => key !== "title" && key !== "count");
const browser = await chromium.launch();
for (const lang of ["zh", "en"]) {
for (const fold of [false, true]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push(e.message));
await page.goto(`${BASE}?pref=${lang}${fold ? "&fold" : ""}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(async () => {
const frame = () => new Promise((r) => requestAnimationFrame(r));
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Inspect the file" });
window.__feed({ kind: "text", text: "I will inspect it first." });
window.__feed({ kind: "message", text: "I will inspect it first." });
const tool = {
id: "typography-read", name: "read_file", args: '{"path":"src/main.go"}',
argumentNormalization: { reason: "json_string_unwrapped", original: '"{\\"path\\":\\"src/main.go\\"}"', effective: '{"path":"src/main.go"}' },
};
window.__feed({ kind: "tool_dispatch", tool });
window.__feed({ kind: "tool_result", tool: { ...tool, output: "package main", durationMs: 120 } });
await frame();
window.__feed({ kind: "text", text: "The file declares package main." });
window.__feed({ kind: "message", text: "The file declares package main." });
window.__feed({ kind: "turn_done" });
});
await page.waitForSelector(".activity-title");
while (await page.locator(".activity-group:not([open]) > summary").count()) {
await page.locator(".activity-group:not([open]) > summary").first().click();
}
await page.waitForSelector(".activity-body .hl .nm", { state: "visible" });
await page.locator(".activity-body .tool-disclosure > summary .nm").first().click();
await page.waitForSelector(".activity-call-input > details > summary", { state: "visible" });
check(`${lang}/${fold}: original input starts folded`, await page.locator(".activity-call-input > details").evaluate((el) => !el.open));
await page.locator(".activity-call-input > details > summary").click();
check(`${lang}/${fold}: original input opens with its source`, await page.locator(".activity-call-input > details pre").textContent() === '"{\\"path\\":\\"src/main.go\\"}"');
const measure = async () => {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
return page.evaluate(() => {
const style = (selector) => {
const s = getComputedStyle(document.querySelector(selector));
return { size: parseFloat(s.fontSize), weight: Number(s.fontWeight), family: s.fontFamily };
};
return {
title: style(".activity-title"), count: style(".activity-count"),
name: style(".activity-body .hl .nm"), arg: style(".activity-body .hl .arg"),
normalization: style(".activity-call-input > p"), originalInput: style(".activity-call-input > details > summary"),
sidebar: style(".sesstitle"), answer: style('.call[data-k="say"] .out .txt'),
};
});
};
const base = await measure();
check(`${lang}/${fold}: activity labels preserve the 11px/500 default`, base.title.size === 11 && base.count.size === 11 && base.title.weight === 500 && base.count.weight === 500, { title: base.title, count: base.count });
const pick = async (action, index) => {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]').click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => document.getElementById("prefs-appearance").click());
const variable = action === "appearance.reading-size" ? "--read" : "--fs-scale";
const expected = action === "appearance.reading-size"
? `${(lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[index]}px` : "1.3";
await page.locator(`[data-action="${action}"]`).nth(index).click();
await page.waitForFunction(([name, value]) => document.documentElement.style.getPropertyValue(name) === value, [variable, expected]);
await page.keyboard.press("Escape");
};
await pick("appearance.reading-size", 3);
const large = await measure();
const ratio = large.answer.size / base.answer.size;
const tag = `${lang}/${fold ? "folded turn" : "activity"}`;
for (const key of textKeys) {
check(`${tag} ${key} follows body size`, Math.abs(large[key].size / base[key].size - ratio) < .02, { before: base[key].size, after: large[key].size, ratio });
}
check(`${tag} body size leaves sidebar alone`, large.sidebar.size === base.sidebar.size, large.sidebar);
await pick("appearance.text-size", 3);
const uiLarge = await measure();
for (const key of textKeys) {
const expectedRatio = key === "title" || key === "count" ? 1.3 : 1;
check(`${tag} ${key} ${expectedRatio === 1 ? "independent of" : "scales with"} interface size`,
Math.abs(uiLarge[key].size / large[key].size - expectedRatio) < .02, uiLarge[key]);
}
check(`${tag} interface size still enlarges sidebar`, uiLarge.sidebar.size > large.sidebar.size, uiLarge.sidebar);
await page.evaluate(() => { document.documentElement.dataset.weight = "light"; });
const light = await measure();
await page.evaluate(() => { document.documentElement.dataset.weight = "heavy"; });
const heavy = await measure();
for (const key of weightKeys) {
check(`${tag} ${key} follows weight preference`, heavy[key].weight > light[key].weight, { light: light[key].weight, heavy: heavy[key].weight });
}
check(`${tag} activity labels retain one 500 weight`, heavy.title.weight === 500 && heavy.count.weight === 500, { title: heavy.title, count: heavy.count });
await page.evaluate(() => {
document.documentElement.style.setProperty("--ui", "Georgia, serif");
document.documentElement.style.setProperty("--mono", "Menlo, monospace");
});
const fonts = await measure();
check(`${tag} labels use prose family; commands use mono`, fonts.title.family.startsWith("Georgia") && fonts.name.family.startsWith("Menlo") && fonts.arg.family.startsWith("Menlo"), fonts);
await page.evaluate(() => {
window.__feed({ kind: "turn_started", authoredTurn: 2, msgIndex: 1, text: "Inspect failure and running labels" });
const failed = { id: "scale-failure", name: "bash", args: '{"command":"false"}' };
window.__feed({ kind: "tool_dispatch", tool: failed });
window.__feed({ kind: "tool_result", tool: { ...failed, err: "exit status 1", output: "failed", durationMs: 120 } });
window.__feed({ kind: "tool_dispatch", tool: { id: "scale-running", name: "bash", args: '{"command":"sleep 10"}' } });
});
await page.waitForSelector(".activity-running");
for (const step of [0, 1, 2, 3]) {
await pick("appearance.reading-size", step);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const labels = await page.locator(".activity-group[data-running] > summary").evaluate(el =>
[...el.querySelectorAll(".activity-title, .activity-running, .activity-errors, .activity-count")].map(span => {
const css = getComputedStyle(span);
return { label: span.className, size: parseFloat(css.fontSize), weight: Number(css.fontWeight), lineHeight: css.lineHeight };
}));
const read = (lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[step];
const expected = 11 * 1.3 * read / (lang === "zh" ? 13 : 12.5);
check(`${tag} body step ${step}: title/running/failed/count compose text and reading scales at weight 500`,
labels.length === 4 && labels.every(label => Math.abs(label.size - expected) < .01 && label.weight === 500 && label.lineHeight === labels[0].lineHeight), labels);
}
await page.close();
}
}
await browser.close();
if (fails.length) throw new Error(`${fails.length} checks failed: ${fails.join(", ")}`);
@@ -1,37 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=0";
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1600, height: 860 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(base, { waitUntil: "networkidle" });
await page.waitForSelector(".flowgrip-l");
const width = () => page.locator(".compose").evaluate((el) => Math.round(el.getBoundingClientRect().width));
const start = await width();
const drag = async (dx) => {
const box = await page.locator(".flowgrip-l").boundingBox();
await page.mouse.move(box.x + 3, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + 3 + dx, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(200);
return width();
};
const mid = await drag(40);
assert.ok(mid < start && mid >= 640, `a drag inward goes under the readable measure (${start} -> ${mid})`);
const floor = await drag(400);
assert.equal(floor, 640, `the column stops at 640 (${floor})`);
const slider = page.locator(".flowgrip-l");
assert.equal(await slider.getAttribute("aria-valuemin"), "640");
await slider.focus();
await page.keyboard.press("Home");
await page.waitForTimeout(200);
assert.equal(await width(), start, "Home returns to the readable measure");
assert.deepEqual(errors, []);
console.log(`PASS the column is continuous from 640 up: ${start} -> ${mid} -> ${floor} -> ${start}`);
} finally {
await browser.close();
}
@@ -1,39 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=0";
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1600, height: 860 }, colorScheme: scheme });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(base, { waitUntil: "networkidle" });
await page.waitForSelector(".flowgrip-l");
const paint = (selector) => page.locator(selector).evaluate((el) => getComputedStyle(el, "::before").backgroundColor);
const alpha = (css) => (css.startsWith("rgba") ? Number(css.split(",")[3].replace(")", "")) : css === "transparent" ? 0 : 1);
await page.mouse.move(800, 400);
for (const side of ["l", "r"]) {
assert.equal(alpha(await paint(`.flowgrip-${side}`)), 0, `${scheme}: the ${side} grip draws nothing at rest`);
}
const box = await page.locator(".flowgrip-l").boundingBox();
await page.mouse.move(box.x + 3, box.y + box.height / 2);
await page.waitForTimeout(400);
assert.ok(alpha(await paint(".flowgrip-l")) > 0, `${scheme}: the grip appears under the pointer`);
const before = await page.evaluate(() => Math.round(document.querySelector(".compose").getBoundingClientRect().width));
await page.mouse.down();
await page.mouse.move(box.x - 60, box.y + box.height / 2, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(200);
const after = await page.evaluate(() => Math.round(document.querySelector(".compose").getBoundingClientRect().width));
assert.ok(after > before, `${scheme}: a hidden grip still resizes the column (${before} -> ${after})`);
await page.mouse.move(800, 400);
await page.waitForTimeout(400);
assert.equal(alpha(await paint(".flowgrip-l")), 0, `${scheme}: it hides again once the pointer leaves`);
assert.deepEqual(errors, []);
console.log(`PASS ${scheme}: grips are invisible at rest, appear under the pointer, and still resize`);
await page.close();
}
} finally {
await browser.close();
}
+4 -1
View File
@@ -2,11 +2,12 @@ import { createRoot } from "react-dom/client";
import { boot as bootLang } from "../src/i18n";
import { track as trackWidth } from "../src/ui/viewport";
import { pushToast } from "../src/state/toasts";
import { apply as applyPack } from "../src/ui/theme";
import { mount as mountEditor } from "./syntax-mount";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/dock.css";
import "../src/styles/studio.css";
import "../src/styles/flowgrip.css";
import "../src/styles/changedpill.css";
import "../src/styles/askskip.css";
import "../src/styles/pastekeep.css";
@@ -607,9 +608,11 @@ declare global {
__refuse: (method: string, message: string) => void;
// Put a notice in the window's corner the way a failed setting does.
__toast: (text: string) => void;
__syntax: { apply: typeof applyPack; mount: typeof mountEditor };
}
}
window.__toast = pushToast;
window.__syntax = { apply: applyPack, mount: mountEditor };
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
window.__panes = () => hub.feeds.size;
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
+1 -1
View File
@@ -61,7 +61,7 @@ try {
assert.equal(await page.locator(".user-image button").evaluate(el => el === document.activeElement), true);
await page.locator(".activity-group > summary").click();
const tool = page.locator('[data-call="image-shot"] .tool-disclosure');
await tool.locator("summary").click();
await tool.locator("summary .nm").click();
await page.getByRole("button", { name: "查看图片 工具截图 1", exact: true }).click();
assert.equal(await page.getByRole("dialog").locator("img").getAttribute("src"), data);
await page.getByRole("button", { name: "关闭图片预览" }).click();
+1 -1
View File
@@ -65,7 +65,7 @@ const probe = (page) =>
};
});
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".view-action"];
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".view-action"];
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
async function reachable(page) {
@@ -0,0 +1,31 @@
// An enlarged settings window centres its content column between the nav and
// the window edge: the room left over is shared, not piled up on the right.
import assert from "node:assert/strict";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const browser = await chromium.launch();
try {
for (const width of [1920, 2560]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.getByRole("button", { name: "放大设置窗口" }).click();
await page.waitForTimeout(400);
const room = await page.evaluate(() => {
const main = document.querySelector(".prefs-main").getBoundingClientRect();
const col = document.querySelector(".prefs-main .prefs-col").getBoundingClientRect();
return { left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width) };
});
assert.ok(room.width <= 1200, `the column keeps its cap (${room.width})`);
assert.ok(Math.abs(room.left - room.right) <= 2, `${width}px: the room is shared evenly (left ${room.left}, right ${room.right})`);
assert.deepEqual(errors, []);
console.log(`PASS ${width}px: column ${room.width}px with ${room.left}px each side`);
await page.close();
}
} finally {
await browser.close();
}
@@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import CodeEditor from "../src/ui/CodeEditor";
// A real editor on a given text, mounted into a given element, for the guard
// that reads what a pack's syntax colours do to it.
export function mount(host: HTMLElement, path: string, value: string, dark: boolean) {
createRoot(host).render(<CodeEditor path={path} value={value} dark={dark} onChange={() => {}} onSave={() => {}} readOnly />);
}
@@ -0,0 +1,102 @@
// A pack's nine syntax colours must reach both places code is drawn — the file
// editor and a rendered code block — and a pack that names none must leave both
// exactly as they are. Only the browser resolves the variables, so only the
// browser can say so.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const NAMES = ["keyword", "string", "number", "function", "comment", "type", "operator", "punctuation", "variable"];
const TOKEN = { keyword: "synKeyword", string: "synString", number: "synNumber", function: "synFunction", comment: "synComment", type: "synType", operator: "synOperator", punctuation: "synPunctuation", variable: "synVariable" };
const HEX = ["#C01010", "#10A010", "#1010C0", "#C0A010", "#10A0A0", "#A010A0", "#808010", "#108060", "#6040C0"];
const SAMPLE = 'class Box {}\n// note\nfunction add(a) { return a + 1 }\nconst s = "x"; run(3);\n';
const HLJS = {
keyword: "hljs-keyword", string: "hljs-string", number: "hljs-number", function: "hljs-title", comment: "hljs-comment",
type: "hljs-type", operator: "hljs-operator", punctuation: "hljs-punctuation", variable: "hljs-variable",
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, colorScheme: scheme });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const out = await page.evaluate(async ({ scheme, sample, hljs, names, token, hex }) => {
const theme = window.__syntax;
const { mount } = theme;
const host = document.createElement("div");
host.style.cssText = "position:fixed;left:0;top:0;width:700px;height:260px;z-index:2147483647";
document.body.appendChild(host);
mount(host, "probe.js", sample, scheme === "dark");
const block = document.createElement("div");
block.className = "md";
block.style.cssText = "position:fixed;left:0;top:300px;width:700px;z-index:2147483647";
block.innerHTML = `<pre class="term"><code>${names.map((n) => `<span class="${hljs[n]}" data-k="${n}">${n}</span>`).join(" ")}</code></pre>`;
document.body.appendChild(block);
for (let i = 0; i < 100 && !host.querySelector(".cm-line span"); i++) await new Promise((r) => setTimeout(r, 100));
const rgb = (el) => getComputedStyle(el).color;
const spanOf = (text, nth = 0) => [...host.querySelectorAll(".cm-line span")].filter((s) => s.textContent === text)[nth];
const probe = {
keyword: spanOf("class"), string: spanOf('"x"'), number: spanOf("3"), function: spanOf("run"), comment: spanOf("// note"),
type: spanOf("Box"), operator: spanOf("+"), punctuation: spanOf("{"), variable: spanOf("a", 1) ?? spanOf("a"),
};
const read = () => ({
editor: Object.fromEntries(names.map((n) => [n, probe[n] ? rgb(probe[n]) : null])),
block: Object.fromEntries(names.map((n) => [n, rgb(block.querySelector(`[data-k="${n}"]`))])),
inherited: rgb(block.querySelector("code")),
});
const tokens = (all) => Object.fromEntries(names.filter((n) => all || ["keyword", "string", "number", "function"].includes(n)).map((n) => [token[n], hex[names.indexOf(n)]]));
const base = { bg: "#FFFFFF", bgSoft: "#F4F4F4", fg: "#1A1A1A", fgDim: "#5A5A5A", fgFaint: "#767676", accent: "#0066CC" };
const pack = (all) => ({ id: "syn", name: "Syn", tokens: { light: { ...base, ...tokens(all) }, dark: { ...base, ...tokens(all) } } });
const root = getComputedStyle(document.documentElement);
const answer = {};
theme.apply(null, scheme);
answer.plain = read();
answer.defaults = { faint: "", text: "", muted: "", fn: "" };
const swatch = document.createElement("i");
document.body.appendChild(swatch);
for (const [k, v] of [["faint", "--faint"], ["text", "--text"], ["muted", "--muted"], ["fn", "--syn-fn"]]) { swatch.style.color = `var(${v})`; answer.defaults[k] = getComputedStyle(swatch).color; }
theme.apply(pack(false), scheme);
answer.four = read();
answer.fourDefaults = {};
swatch.style.color = "var(--syn-fn)"; answer.fourDefaults.fn = getComputedStyle(swatch).color;
swatch.style.color = "var(--text)"; answer.fourDefaults.text = getComputedStyle(swatch).color;
swatch.style.color = "var(--muted)"; answer.fourDefaults.muted = getComputedStyle(swatch).color;
swatch.style.color = "var(--faint)"; answer.fourDefaults.faint = getComputedStyle(swatch).color;
theme.apply(pack(true), scheme);
answer.nine = read();
theme.apply(null, scheme);
void root;
return answer;
}, { scheme, sample: SAMPLE, hljs: HLJS, names: NAMES, token: TOKEN, hex: HEX });
const asRgb = (h) => { const n = parseInt(h.slice(1), 16); return `rgb(${n >> 16}, ${(n >> 8) & 255}, ${n & 255})`; };
for (const surface of ["editor", "block"]) {
const seen = NAMES.map((n) => out.nine[surface][n]);
check(`${scheme} ${surface}: nine syntax tokens give nine distinct colours`, new Set(seen).size === 9, seen.join(" "));
for (const n of NAMES) check(`${scheme} ${surface}: ${n} takes the pack's colour`, out.nine[surface][n] === asRgb(HEX[NAMES.indexOf(n)]), `${out.nine[surface][n]} wanted ${asRgb(HEX[NAMES.indexOf(n)])}`);
}
// A pack that names only the four old tokens leaves each new one on what that surface drew before.
const d = out.fourDefaults;
check(`${scheme} editor: comment keeps the faint ink`, out.four.editor.comment === d.faint);
check(`${scheme} editor: type keeps the function colour`, out.four.editor.type === d.fn);
for (const n of ["operator", "punctuation", "variable"]) check(`${scheme} editor: ${n} keeps the text ink`, out.four.editor[n] === d.text, `${out.four.editor[n]} vs ${d.text}`);
check(`${scheme} block: comment keeps the faint ink`, out.four.block.comment === d.faint);
check(`${scheme} block: type keeps the function colour`, out.four.block.type === d.fn);
for (const n of ["operator", "punctuation"]) check(`${scheme} block: ${n} keeps the muted ink`, out.four.block[n] === d.muted, `${out.four.block[n]} vs ${d.muted}`);
check(`${scheme} block: a variable keeps the ink it inherits`, out.four.block.variable === out.four.inherited, `${out.four.block.variable} vs ${out.four.inherited}`);
await page.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,59 @@
// A pane tab's close button has to stay inside its own tab whatever the tab
// carries: past the tab's edge it sits on the next tab, and a press closes
// nothing. The folder and host labels share the row with the title, so a long
// one is what pushes it out. Only the browser lays this out.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const LONG = "一个很长很长很长很长很长很长很长很长很长的会话标题";
const cases = [
["a long title", { title: LONG }],
["a long title in a long folder", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace" }],
["a long folder and a long host", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace", host: "build-runner-eu-west-long-hostname" }],
];
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1200, height: 760 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
for (const [label, tab] of cases) {
const got = await page.evaluate(({ title, ws, host }) => {
document.querySelector(".probe-tabs")?.remove();
const bar = document.createElement("div");
bar.className = "panetabs probe-tabs";
bar.style.cssText = "position:fixed;left:0;top:0;right:0;z-index:2147483647";
const one = (t) => `<div class="ptab" aria-selected="${t === title}"><i class="pip"></i><span class="ptab-nm">${t}</span>`
+ (ws ? `<span class="ptab-ws">${ws}</span>` : "") + (host ? `<span class="ptab-host">${host}</span>` : "")
+ `<button class="ptab-x">×</button></div>`;
bar.innerHTML = one(title) + one("hi");
document.body.appendChild(bar);
const first = bar.querySelector(".ptab");
const box = first.getBoundingClientRect();
const x = first.querySelector(".ptab-x").getBoundingClientRect();
const hit = document.elementFromPoint(x.left + x.width / 2, x.top + x.height / 2);
return {
inside: x.left >= box.left - 0.5 && x.right <= box.right + 0.5,
reached: hit === first.querySelector(".ptab-x"),
title: Math.round(first.querySelector(".ptab-nm").getBoundingClientRect().width),
detail: `tab ${Math.round(box.left)}–${Math.round(box.right)}, close ${Math.round(x.left)}–${Math.round(x.right)}`,
};
}, tab);
check(`${label}: the close button stays inside its tab`, got.inside, got.detail);
check(`${label}: a press on the close button reaches it`, got.reached);
check(`${label}: the title keeps room to be read`, got.title >= 48, `${got.title}px`);
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+138 -138
View File
@@ -69,14 +69,14 @@ importers:
specifier: ^1.2.5
version: 1.2.5
'@mantine/charts':
specifier: 9.7.2
version: 9.7.2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
specifier: 9.7.3
version: 9.7.3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
'@mantine/core':
specifier: 9.7.2
version: 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
specifier: 9.7.3
version: 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@mantine/hooks':
specifier: 9.7.2
version: 9.7.2([email protected])
specifier: 9.7.3
version: 9.7.3([email protected])
'@noble/curves':
specifier: 2.4.0
version: 2.4.0
@@ -90,11 +90,11 @@ importers:
specifier: ^0.19.0
version: 0.19.0
lucide-react:
specifier: ^1.60.0
version: 1.60.0(@types/[email protected])([email protected])
specifier: ^1.61.0
version: 1.61.0(@types/[email protected])([email protected])
maplibre-gl:
specifier: 6.14.0
version: 6.14.0
specifier: 6.15.0
version: 6.15.0
papaparse:
specifier: ^5.7.0
version: 5.7.0
@@ -109,7 +109,7 @@ importers:
version: 19.3.1([email protected])
react-map-gl:
specifier: 8.1.3
version: 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
version: 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/[email protected])([email protected])
@@ -161,7 +161,7 @@ importers:
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.2
version: 6.1.2([email protected].4(@types/[email protected])([email protected]))
version: 6.1.2([email protected].5(@types/[email protected])([email protected]))
jsdom:
specifier: ^30.1.3
version: 30.1.3(@noble/[email protected])
@@ -172,11 +172,11 @@ importers:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: ^8.3.4
version: 8.3.4(@types/[email protected])([email protected])
specifier: ^8.3.5
version: 8.3.5(@types/[email protected])([email protected])
vitest:
specifier: ^5.0.4
version: 5.0.4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected]))
specifier: ^5.0.5
version: 5.0.5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected]))
packages:
@@ -580,24 +580,24 @@ packages:
'@lit/[email protected]':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@mantine/[email protected].2':
resolution: {integrity: sha512-biwAZ4RDSmhy2dqeyqBK1XlFg3+eZGYyt3bEDuTBu/at/fbAguhIsmFTbqYjqMcTpWNyX9BNxJ+mhMFslee1eQ==}
'@mantine/[email protected].3':
resolution: {integrity: sha512-p8SMOVEmQATcHIszSYdKdU6MBq8Ozdm+QJeFz+Zrtpquv0+ZVHIb262emZV544a7UmwOGdMN5DxmhQ8lewrbvg==}
peerDependencies:
'@mantine/core': 9.7.2
'@mantine/hooks': 9.7.2
'@mantine/core': 9.7.3
'@mantine/hooks': 9.7.3
react: ^19.2.0
react-dom: ^19.2.0
recharts: '>=3.2.1'
'@mantine/[email protected].2':
resolution: {integrity: sha512-+cggJDD0PO057SwZU/dKlWPjp0LaAn1nEM9W1m/Ia4Cjb/Hy9DjZ4I7R7xirp/eZ6xnV7OqeGmPFSJ8zeGa5kQ==}
'@mantine/[email protected].3':
resolution: {integrity: sha512-wYJ9Bd82a7HqwJ+hubFqUIocBGMuw6txzdPnnleHvv1h0NlnkESevK/WHTRh4FtOw33B5N2ADT34s3x1/8d8jg==}
peerDependencies:
'@mantine/hooks': 9.7.2
'@mantine/hooks': 9.7.3
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/[email protected].2':
resolution: {integrity: sha512-11emsRp/Mg+vfaKVyoMwiMdCHmO/15XOL6JG9fACkclVZnLPBVvRIaDdRBudsR3ALGZ5iTzrArPJDjjoeO6X4w==}
'@mantine/[email protected].3':
resolution: {integrity: sha512-GMHoMS1qul1dBLIHd/N9ef2Pr8UKYYHfuM+g2ej3gYqU0kH4sTiR9FNHksoTQi0PzATRoaNlUYjjMe9rbDRw5A==}
peerDependencies:
react: ^19.2.0
@@ -620,8 +620,8 @@ packages:
'@mapbox/[email protected]':
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
'@maplibre/geojson-vt@6.1.2':
resolution: {integrity: sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==}
'@maplibre/geojson-vt@6.3.0':
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
'@maplibre/[email protected]':
resolution: {integrity: sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==}
@@ -723,8 +723,8 @@ packages:
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@oxc-project/[email protected]2.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
'@oxc-project/[email protected]5.0':
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
'@preact/[email protected]':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
@@ -740,98 +740,98 @@ packages:
react-redux:
optional: true
'@rolldown/[email protected]2':
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1115,8 +1115,8 @@ packages:
oxc-transform-react:
optional: true
'@vitest/[email protected].4':
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
'@vitest/[email protected].5':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
peerDependencies:
msw: ^2.4.9 || ^3.0.0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -1126,8 +1126,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].4':
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
'@vitest/[email protected].5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -1613,8 +1613,8 @@ packages:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
[email protected]0.0:
resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==}
[email protected]1.0:
resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -1629,8 +1629,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
[email protected]4.0:
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
[email protected]5.0:
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
[email protected]:
@@ -2008,8 +2008,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
[email protected]2:
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
[email protected]5:
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -2211,13 +2211,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
[email protected].4:
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
[email protected].5:
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.7.1
'@vitejs/devtools': ^0.7.1 || ^0.8.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
@@ -2254,20 +2254,20 @@ packages:
yaml:
optional: true
[email protected].4:
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
[email protected].5:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.4
'@vitest/browser-preview': 5.0.4
'@vitest/browser-playwright': 5.0.5
'@vitest/browser-preview': 5.0.5
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.4
'@vitest/coverage-v8': 5.0.4
'@vitest/ui': 5.0.4
'@vitest/coverage-istanbul': 5.0.5
'@vitest/coverage-v8': 5.0.5
'@vitest/ui': 5.0.5
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -2783,18 +2783,18 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@mantine/[email protected].2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
'@mantine/[email protected].3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
dependencies:
'@mantine/core': 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@mantine/hooks': 9.7.2([email protected])
'@mantine/core': 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@mantine/hooks': 9.7.3([email protected])
react: 19.3.1
react-dom: 19.3.1([email protected])
recharts: 3.10.1(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
'@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
'@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@floating-ui/react': 0.27.20([email protected]([email protected]))([email protected])
'@mantine/hooks': 9.7.2([email protected])
'@mantine/hooks': 9.7.3([email protected])
clsx: 2.1.1
react: 19.3.1
react-dom: 19.3.1([email protected])
@@ -2804,7 +2804,7 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@mantine/[email protected].2([email protected])':
'@mantine/[email protected].3([email protected])':
dependencies:
react: 19.3.1
@@ -2824,7 +2824,7 @@ snapshots:
'@types/geojson': 7946.0.16
pbf: 5.1.2
'@maplibre/geojson-vt@6.1.2':
'@maplibre/geojson-vt@6.3.0':
dependencies:
kdbush: 4.1.0
@@ -2912,7 +2912,7 @@ snapshots:
'@noble/[email protected]': {}
'@oxc-project/[email protected]2.0': {}
'@oxc-project/[email protected]5.0': {}
'@preact/[email protected]': {}
@@ -2928,49 +2928,49 @@ snapshots:
react: 19.3.1
react-redux: 9.3.0(@types/[email protected])([email protected])([email protected])
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]': {}
@@ -3152,29 +3152,29 @@ snapshots:
react: 19.3.1
react-dom: 19.3.1([email protected])
'@vis.gl/[email protected]([email protected]4.0)([email protected]([email protected]))([email protected])':
'@vis.gl/[email protected]([email protected]5.0)([email protected]([email protected]))([email protected])':
dependencies:
'@maplibre/maplibre-gl-style-spec': 19.3.3
react: 19.3.1
react-dom: 19.3.1([email protected])
optionalDependencies:
maplibre-gl: 6.14.0
maplibre-gl: 6.15.0
'@vitejs/[email protected]([email protected].4(@types/[email protected])([email protected]))':
'@vitejs/[email protected]([email protected].5(@types/[email protected])([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.4(@types/[email protected])([email protected])
vite: 8.3.5(@types/[email protected])([email protected])
'@vitest/[email protected].4([email protected].4(@types/[email protected])([email protected]))':
'@vitest/[email protected].5([email protected].5(@types/[email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.4
'@vitest/spy': 5.0.5
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
vite: 8.3.4(@types/[email protected])([email protected])
vite: 8.3.5(@types/[email protected])([email protected])
'@vitest/[email protected].4': {}
'@vitest/[email protected].5': {}
[email protected]: {}
@@ -3682,7 +3682,7 @@ snapshots:
[email protected]: {}
[email protected]0.0(@types/[email protected])([email protected]):
[email protected]1.0(@types/[email protected])([email protected]):
dependencies:
react: 19.3.1
optionalDependencies:
@@ -3694,13 +3694,13 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
[email protected]4.0:
[email protected]5.0:
dependencies:
'@mapbox/point-geometry': 1.1.0
'@mapbox/tiny-sdf': 2.3.0
'@mapbox/unitbezier': 1.0.0
'@mapbox/vector-tile': 3.0.0
'@maplibre/geojson-vt': 6.1.2
'@maplibre/geojson-vt': 6.3.0
'@maplibre/maplibre-gl-style-spec': 26.4.4
'@maplibre/mlt': 1.3.0
'@maplibre/vt-pbf': 4.3.2
@@ -4175,14 +4175,14 @@ snapshots:
[email protected]: {}
[email protected]([email protected]4.0)([email protected]([email protected]))([email protected]):
[email protected]([email protected]5.0)([email protected]([email protected]))([email protected]):
dependencies:
'@vis.gl/react-mapbox': 8.1.3([email protected]([email protected]))([email protected])
'@vis.gl/react-maplibre': 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
'@vis.gl/react-maplibre': 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
react: 19.3.1
react-dom: 19.3.1([email protected])
optionalDependencies:
maplibre-gl: 6.14.0
maplibre-gl: 6.15.0
[email protected](@types/[email protected])([email protected]):
dependencies:
@@ -4365,26 +4365,26 @@ snapshots:
dependencies:
protocol-buffers-schema: 3.6.1
[email protected]2:
[email protected]5:
dependencies:
'@oxc-project/types': 0.152.0
'@oxc-project/types': 0.155.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.12
'@rolldown/binding-android-arm64': 1.2.12
'@rolldown/binding-darwin-arm64': 1.2.12
'@rolldown/binding-darwin-x64': 1.2.12
'@rolldown/binding-freebsd-x64': 1.2.12
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
'@rolldown/binding-linux-arm64-gnu': 1.2.12
'@rolldown/binding-linux-arm64-musl': 1.2.12
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
'@rolldown/binding-linux-s390x-gnu': 1.2.12
'@rolldown/binding-linux-x64-gnu': 1.2.12
'@rolldown/binding-linux-x64-musl': 1.2.12
'@rolldown/binding-openharmony-arm64': 1.2.12
'@rolldown/binding-win32-arm64-msvc': 1.2.12
'@rolldown/binding-win32-x64-msvc': 1.2.12
'@rolldown/binding-android-arm-eabi': 1.2.15
'@rolldown/binding-android-arm64': 1.2.15
'@rolldown/binding-darwin-arm64': 1.2.15
'@rolldown/binding-darwin-x64': 1.2.15
'@rolldown/binding-freebsd-x64': 1.2.15
'@rolldown/binding-linux-arm-gnueabihf': 1.2.15
'@rolldown/binding-linux-arm64-gnu': 1.2.15
'@rolldown/binding-linux-arm64-musl': 1.2.15
'@rolldown/binding-linux-ppc64-gnu': 1.2.15
'@rolldown/binding-linux-s390x-gnu': 1.2.15
'@rolldown/binding-linux-x64-gnu': 1.2.15
'@rolldown/binding-linux-x64-musl': 1.2.15
'@rolldown/binding-openharmony-arm64': 1.2.15
'@rolldown/binding-win32-arm64-msvc': 1.2.15
'@rolldown/binding-win32-x64-msvc': 1.2.15
[email protected]: {}
@@ -4620,22 +4620,22 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
[email protected].4(@types/[email protected])([email protected]):
[email protected].5(@types/[email protected])([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.30
rolldown: 1.2.12
rolldown: 1.2.15
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.5
esbuild: 0.28.2
fsevents: 2.3.3
[email protected].4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected])):
[email protected].5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.4([email protected].4(@types/[email protected])([email protected]))
'@vitest/mocker': 5.0.5([email protected].5(@types/[email protected])([email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -4646,7 +4646,7 @@ snapshots:
tinybench: 6.2.2
tinyexec: 1.3.1
tinyglobby: 0.2.17
vite: 8.3.4(@types/[email protected])([email protected])
vite: 8.3.5(@types/[email protected])([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.5
+8 -45
View File
@@ -1,6 +1,7 @@
import { MARKET_ACTIONS } from "./actions_market";
import { SCHEDULE_ACTIONS } from "./actions_schedule";
import { DOCUMENT_ACTIONS } from "./actions_document";
import { FEEDBACK_ACTIONS } from "./actions_feedback";
// The census oracle is independent of production literals so disagreements fail.
// An id names the intent; data-target and data-value name its entity and choice.
@@ -149,6 +150,7 @@ export const ACTIONS: UIAction[] = [
{ 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" },
{ id: "transcript.thinking-preview", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.account", kind: "navigation", target: "none", proof: "interaction" },
{ id: "metrics.details", kind: "view", target: "optional", proof: "interaction" },
{ id: "metrics.hide-amounts", kind: "view", target: "none", proof: "interaction" },
@@ -283,15 +285,7 @@ export const ACTIONS: UIAction[] = [
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
{ id: "sponsor.paypal", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "sponsor.qr", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.preview", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
...FEEDBACK_ACTIONS,
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
@@ -304,42 +298,7 @@ export const ACTIONS: UIAction[] = [
{ id: "toast.dismiss", kind: "view", target: "entity", proof: "interaction" },
{ id: "model.group", kind: "view", target: "entity", proof: "interaction" },
{ id: "model.group-clear", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.account-section", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
@@ -577,6 +536,10 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.scene", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.volume", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.parallax", kind: "view", target: "none", proof: "interaction" },
{ id: "theme.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
@@ -619,7 +582,6 @@ export const ACTIONS: UIAction[] = [
// Dragging the column's edge is a view adjustment held on this machine,
// not a setting written to the kernel: it changes what is drawn, not what
// the transcript says.
{ id: "appearance.reading-width", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
// Kept where the window keeps them: these are this browser's, and no kernel
@@ -790,6 +752,7 @@ export const ACTIONS: UIAction[] = [
// ── The two side panels. Reached from the keyboard here, and from each
// gutter's own grip, which this pass has not annotated yet.
{ id: "rail.star", kind: "view", target: "none", proof: "interaction" },
{ id: "rail.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
...SCHEDULE_ACTIONS,
...DOCUMENT_ACTIONS,
@@ -0,0 +1,48 @@
import type { UIAction } from "./actions";
export const FEEDBACK_ACTIONS: UIAction[] = [
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.preview", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.account-section", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
];
+13 -1
View File
@@ -39,6 +39,7 @@ export const EN: Record<string, string> = {
"正在读取历史消息…": "Reading history messages…",
"历史加载失败 · 重试": "History could not be loaded · Retry",
"加载更早的消息": "Load earlier messages",
"更早还有 {n} 条消息,点击加载": "{n} more messages before these — click to load",
"正在查找较早消息…": "Searching earlier messages…",
"部分历史尚未读取": "Some history has not been read",
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
@@ -283,7 +284,6 @@ export const EN: Record<string, string> = {
"收起度量栏": "Collapse the metrics rail",
"展开度量栏": "Show the metrics rail",
"调整工作区栏宽度": "Resize the workspace rail",
"调整正文字宽": "Resize the reading column",
"调整度量栏宽度": "Resize the metrics rail",
"从列表移除": "Remove from the list",
"改为输入路径": "Type a path instead",
@@ -703,6 +703,14 @@ export const EN: Record<string, string> = {
"字体预览 · 中文 Aa Bb 0123": "Typeface preview · Aa Bb 0123",
"壁纸": "Wallpaper",
"选择图片或视频…": "Choose an image or video…",
"音量": "Volume",
"导入 Wallpaper Engine…": "Import Wallpaper Engine…",
"文件夹里需要 project.json 和 scene.pkg": "The folder needs project.json and scene.pkg",
"Wallpaper Engine 场景": "Wallpaper Engine scene",
"未命名场景": "Untitled scene",
"打开声音": "Unmute sound",
"关闭声音": "Mute sound",
"场景以主图和声音播放;粒子和着色器效果暂未再现。": "The scene plays its backdrop and sound; particles and shader effects are not reproduced yet.",
"换一张…": "Choose another…",
"浓度": "Strength",
"压暗": "Dim",
@@ -998,6 +1006,10 @@ export const EN: Record<string, string> = {
"这些地址直连": "Reach these addresses directly",
"测试目标": "Test target",
"无法读取网络配置。": "Could not read the network config.",
"正在读取网络配置…": "Loading network settings…",
"测试已保存配置": "Test saved settings",
"有未保存的改动;连接测试使用已保存的配置。": "There are unsaved changes. Connection tests use the saved settings.",
"已保存配置的测试结果": "Test results for saved settings",
// ── 命令解释器 ───────────────────────────────────────────────────
"当前生效": "In effect",
@@ -270,6 +270,11 @@ export const EN_KERNEL: Record<string, string> = {
"不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)":
"That format is not supported; use an image (PNG, JPEG, WebP, AVIF, GIF) or a video (MP4, WebM)",
"图片内容为空": "The image is empty",
"场景包过大,请压缩至 {limit} MB 以内": "That scene package is too large — bring it under {limit} MB",
"无法读取这个 Wallpaper Engine 场景": "This Wallpaper Engine scene could not be read",
"只支持 Scene 类型的 Wallpaper Engine 壁纸": "Only Scene wallpapers from Wallpaper Engine are supported",
"这个场景里没有可用的主图": "This scene has no backdrop to show",
"不支持该音频格式": "That audio format is not supported",
"压缩阈值未能保存:{detail}": "The compaction threshold was not saved: {detail}",
"本次请求未包含阈值,未做任何修改": "The request carried no threshold, so nothing changed",
"{host} 上的内核没有应答,连接可能断了": "The kernel on {host} is not answering — the connection may be down",
@@ -1133,7 +1133,9 @@ export const EN_SETTINGS: Record<string, string> = {
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
"压缩简报": "Compaction brief",
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
"收起并预览首句": "Folded, with first sentence",
"收起时预览思考开头": "Preview the beginning when collapsed",
"在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。": "Show one line of opening text below the thinking header, including while thinking is in progress. Hidden when expanded.",
"先检查配置,再决定改哪里。": "Check the configuration first, then decide what to change.",
"进行中展开": "Open while running",
"失败时展开": "Open on failure",
"改动时展开": "Open on file change",
@@ -46,4 +46,9 @@ export const EN_TOOL_BLURBS: Record<string, string> = {
"派出一个子代理处理一项专项子任务,只返回它的最终答案。": "Start a sub-agent for a focused sub-task and return only its final answer.",
"获取一个网址并返回文本内容;HTML 会被精简为可读文本。": "Fetch a URL and return its text; HTML is reduced to readable text.",
"搜索网页获取最新信息,返回摘要和来源。": "Search the web for current information and return a summary with sources.",
"向更强的模型求助:它读完到目前为止的对话后,用方案、诊断或审查回答你的问题;它自己不执行任何东西。": "Ask a stronger model for advice: it reads the conversation so far and answers with a plan, a diagnosis or a review; it runs nothing itself.",
"把一个明确的编码任务并行做 2–4 遍,每遍在独立的 git 工作树里;再由评审比较,只把最好的一份写回工作区。": "Run one well-specified coding task 2–4 times in parallel, each in its own git worktree, then have a judge compare them and write only the best back into the workspace.",
"把已有的最终文件以可点击的卡片展示给你,包括命令行生成的文档;路径须在工作区或已授权的文件夹内,打开的是当前文件而不是快照,也不保证内容正确。": "Present existing final files to you as clickable cards, including shell-created documents; paths must be in the workspace or authorized folders, it opens the current files rather than snapshots, and it does not certify correctness.",
"读取已连接的 MCP 服务器自己列出的一份资源(文档、记录、文件);服务器没列出的地址会被拒绝,返回的是服务器的数据,不是你的指令。": "Read one resource (a document, a record, a file) that a connected MCP server lists itself; an address the server does not list is refused, and what comes back is the server's data, not your instructions.",
"运行一小段 Starlark(类 Python)脚本来调用多个工具,几步相互依赖的操作只需一次往返,只返回脚本打印的内容;每次调用都和模型直接调用一样受检查和审批。": "Run a short Starlark (Python-like) script that calls several tools, so dependent steps take one round trip and only what the script prints comes back; every call is checked and approved as if the model made it directly.",
};
+5
View File
@@ -448,6 +448,11 @@ const SAID: Record<string, string> = {
// ── 壁纸 ─────────────────────────────────────────────────────────
"wallpaper.unsupported_type": "不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)",
"wallpaper.empty": "图片内容为空",
"wallpaper.scene_too_large": "场景包过大,请压缩至 {limit} MB 以内",
"wallpaper.scene_invalid": "无法读取这个 Wallpaper Engine 场景",
"wallpaper.scene_unsupported": "只支持 Scene 类型的 Wallpaper Engine 壁纸",
"wallpaper.scene_no_image": "这个场景里没有可用的主图",
"wallpaper.audio_unsupported": "不支持该音频格式",
// ── 来不及了 ──────────────────────────────────────
"steer.already_applied": "该条已发送给模型,无法撤回",
+1 -1
View File
@@ -8,7 +8,6 @@ import "./styles/app.css";
import "./styles/composition-picker.css";
import "./styles/dock.css";
import "./styles/studio.css";
import "./styles/flowgrip.css";
import "./styles/dialogcenter.css";
import "./styles/stophint.css";
import "./styles/phonecomposer.css";
@@ -204,6 +203,7 @@ pick().then(
// drop: a window with no drop target mounted still has to refuse a file, or
// the webview navigates to it and the app is replaced by what was dropped.
installFileDrop();
try { localStorage.removeItem("rx-flow-w"); } catch { /* storage is a convenience */ }
root.render(
<StrictMode>
<RemoteAwareApp hub={hub} remote={new URLSearchParams(location.search).get("device")?.trim() || null} />
+16 -2
View File
@@ -91,10 +91,24 @@ export interface Wallpaper {
// Content-addressed, so the bytes at this address never change.
url: string;
// A picture is drawn as the window's background, a video as an element
// behind it; the kernel decides which from the type it was uploaded as.
kind: "image" | "video";
// behind it; a scene adds its soundtrack and pointer parallax. The kernel
// decides which from the type it was uploaded as.
kind: "image" | "video" | "scene";
// A Wallpaper Engine scene's title and soundtrack, when it has one.
title?: string;
audio?: string;
audios?: string[];
parallax?: number;
muted?: boolean;
volume?: number;
opacity: number;
dim: number;
focusX: number;
focusY: number;
}
export interface SceneFolder {
project: File;
scene: File;
preview?: File;
}
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { MockLook } from "./mock_look";
afterEach(() => vi.restoreAllMocks());
describe("the fixture's scene import", () => {
it("reads the project title and keeps the scene shape", async () => {
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:scene") });
const look = new MockLook();
const got = await look.uploadScene({
project: { size: 20, text: async () => '{"title":"花海"}' } as unknown as File,
scene: { size: 10 } as unknown as File,
});
expect(got.wallpaper).toMatchObject({
kind: "scene",
title: "花海",
parallax: 0.02,
muted: true,
});
});
it("refuses a folder without the two files", async () => {
const look = new MockLook();
await expect(
look.uploadScene({ project: { size: 0 } as File, scene: { size: 1 } as File }),
).rejects.toMatchObject({ reason: { code: "wallpaper.scene_invalid" } });
});
});
+29 -1
View File
@@ -1,5 +1,5 @@
import { HttpError } from "./port";
import type { Appearance } from "./port";
import type { Appearance, SceneFolder } from "./port";
import { STORAGE as LANG } from "../i18n";
// The appearance half of the fixture. It is held in memory rather than written
@@ -49,4 +49,32 @@ export class MockLook {
async clearWallpaper(): Promise<void> {
this.look = { ...this.look, wallpaper: undefined };
}
async uploadScene(folder: SceneFolder): Promise<Appearance> {
if (!folder.project.size || !folder.scene.size) {
throw new HttpError(422, "the folder is incomplete", { code: "wallpaper.scene_invalid" });
}
let title = "";
try {
title = (JSON.parse(await folder.project.text()) as { title?: string }).title?.trim() ?? "";
} catch {
// A malformed project still leaves a scene the fixture can show.
}
this.look = {
...this.look,
wallpaper: {
url: URL.createObjectURL(folder.preview ?? folder.scene),
kind: "scene",
title: title || "Wallpaper Engine scene",
parallax: 0.02,
muted: true,
volume: 1,
opacity: 0.5,
dim: 0.55,
focusX: 0.5,
focusY: 0.5,
},
};
return this.appearance();
}
}
+2 -2
View File
@@ -12,7 +12,6 @@ import type { Attachment, DroppedRef, KeptText } from "./attachment";
export { DeliveryError, HttpError };
export type { DeliveryFault };
export type { Attachment, DroppedRef, KeptText };
import type { AccountState, AccountUser, DeviceGrant } from "./account";
import type { Adjudications } from "./adjudication";
import type { BackupApplyResult, BackupCatalog, BackupCategory, BackupCreated, BackupCreateRequest, BackupFileCatalog, BackupFileExport, BackupPlan } from "./backup";
@@ -49,7 +48,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { Appearance, SceneFolder, ThemeImport, ThemePack } from "./look";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule";
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
@@ -759,6 +758,7 @@ export interface AgentPort extends WorkspacePreviewPort {
appearance(): Promise<Appearance>;
saveAppearance(look: Appearance): Promise<Appearance>;
uploadWallpaper(blob: Blob): Promise<Appearance>;
uploadScene(folder: SceneFolder): Promise<Appearance>;
clearWallpaper(): Promise<void>;
// Where the user put each extension surface, keyed "<pluginId>:<surfaceId>".
// It outranks what the extension asked for; an empty slot hands the decision
+16 -1
View File
@@ -1,5 +1,6 @@
import { SseHttp } from "./sse_http";
import type { Appearance } from "./port";
import { base64Of, guardUpload, tooLarge, UPLOAD_LIMIT_MB } from "./upload";
import type { Appearance, SceneFolder } from "./port";
// The appearance half of the port. Chained the way the fixture's faces are, so
// SsePort satisfies AgentPort in one declaration and each subject keeps its own
@@ -21,6 +22,20 @@ export class SseLook extends SseHttp {
dim: look.wallpaper?.dim ?? 0,
focusX: look.wallpaper?.focusX ?? 0.5,
focusY: look.wallpaper?.focusY ?? 0.5,
muted: look.wallpaper?.muted ?? false,
volume: look.wallpaper?.volume ?? 1,
});
}
async uploadScene(folder: SceneFolder) {
guardUpload(folder.scene.size, UPLOAD_LIMIT_MB.scene, tooLarge("wallpaper.scene_too_large", "limit"));
if (folder.preview) {
guardUpload(folder.preview.size, UPLOAD_LIMIT_MB.scene, tooLarge("wallpaper.scene_too_large", "limit"));
}
return this.post0<Appearance>("/appearance/wallpaper/scene", {
project: await base64Of(folder.project),
scene: await base64Of(folder.scene),
preview: folder.preview ? await base64Of(folder.preview) : "",
});
}
@@ -55,6 +55,17 @@ describe("an upload past the kernel's cap is refused before it is read", () => {
expect(calls[0].init.body).toBe(big);
});
it("scene folders, before the package is read", async () => {
const fetched = noNetwork();
const read = vi.fn(async () => new ArrayBuffer(0));
const err = await new SsePort("", "r1")
.uploadScene({ project: sized(1, read), scene: sized(200, read) })
.catch((e) => e);
expect(err.reason).toMatchObject({ code: "wallpaper.scene_too_large", params: { limit: 32 } });
expect(read).not.toHaveBeenCalled();
expect(fetched).not.toHaveBeenCalled();
});
it("theme packs, counting every file picked", async () => {
const fetched = noNetwork();
const read = vi.fn(async () => new ArrayBuffer(0));
+1 -1
View File
@@ -5,7 +5,7 @@ import { HttpError } from "./http_error";
// before the file is read: turning one into base64 is several copies of it in
// the renderer's memory, and the kernel would refuse it anyway. A Go test pins
// these numbers to the kernel's.
export const UPLOAD_LIMIT_MB = { attachment: 25, image: 10, theme: 48 } as const;
export const UPLOAD_LIMIT_MB = { attachment: 25, image: 10, scene: 32, theme: 48 } as const;
const MB = 1024 * 1024;
+5 -1
View File
@@ -1,12 +1,16 @@
import { createContext, useContext, useRef, useSyncExternalStore } from "react";
import type { Item } from "./session_types";
import { foldModes, onFoldModesChange, type Fold, type FoldMode, type FoldModes } from "./prefs";
import { foldModes, onFoldModesChange, showsThinkingPreview, type Fold, type FoldMode, type FoldModes } from "./prefs";
import { receiptPrefOf, type ReceiptPref, type ReceiptState } from "./receiptpref";
export function useFoldModes(): FoldModes {
return useSyncExternalStore(onFoldModesChange, foldModes, foldModes);
}
export function useThinkingPreview(): boolean {
return useSyncExternalStore(onFoldModesChange, showsThinkingPreview, showsThinkingPreview);
}
/** The receipt preference of the kernel this pane talks to; a transcript drawn
* outside a pane reads the standalone one. */
export const ReceiptPrefContext = createContext<ReceiptPref>(receiptPrefOf());
+25 -11
View File
@@ -195,16 +195,13 @@ export function setLiveGraceMinutes(minutes: number): void {
liveGraceListeners.forEach((fn) => fn());
}
// How each foldable part of the transcript starts. "live" opens while the part
// is still being written and folds once it is done; "failed" opens only a step
// that failed; "changed" opens only what wrote a file. A block the reader opened
// or closed keeps that choice.
// Expansion defaults apply until the reader opens or closes a block.
export type Fold = "thinking" | "activity" | "steps" | "output" | "compaction";
export type FoldMode = "folded" | "peek" | "live" | "failed" | "changed" | "open";
export type FoldMode = "folded" | "live" | "failed" | "changed" | "open";
export type FoldModes = Readonly<Record<Fold, FoldMode>>;
export const FOLD_CHOICES: Readonly<Record<Fold, readonly FoldMode[]>> = {
thinking: ["folded", "peek", "live", "open"],
thinking: ["folded", "live", "open"],
activity: ["folded", "live", "changed", "open"],
steps: ["folded", "failed", "changed", "open"],
output: ["folded", "open"],
@@ -220,8 +217,9 @@ export const FOLD_DEFAULTS: FoldModes = {
};
const FOLD_KEY = "rx-transcript-fold";
type FoldPreferences = { modes: FoldModes; thinkingPreview: boolean };
function readFolds(): FoldModes {
function readFolds(): FoldPreferences {
let saved: Record<string, unknown> = {};
try {
const raw = JSON.parse(localStorage.getItem(FOLD_KEY) ?? "{}");
@@ -234,17 +232,25 @@ function readFolds(): FoldModes {
const v = saved[kind];
if (typeof v === "string" && (FOLD_CHOICES[kind] as readonly string[]).includes(v)) out[kind] = v as FoldMode;
}
return out;
return { modes: out, thinkingPreview: saved.thinkingPreview === true || saved.thinking === "peek" };
}
let folds: FoldModes | null = null;
let folds: FoldPreferences | null = null;
const foldListeners = new Set<() => void>();
export function foldModes(): FoldModes {
function foldPreferences(): FoldPreferences {
folds ??= readFolds();
return folds;
}
export function foldModes(): FoldModes {
return foldPreferences().modes;
}
export function showsThinkingPreview(): boolean {
return foldPreferences().thinkingPreview;
}
export function onFoldModesChange(fn: () => void): () => void {
foldListeners.add(fn);
return () => {
@@ -257,9 +263,17 @@ export function setFoldModes(patch: Partial<FoldModes>): void {
for (const kind of Object.keys(patch) as Fold[]) {
if (!FOLD_CHOICES[kind].includes(next[kind])) next[kind] = FOLD_DEFAULTS[kind];
}
saveFolds({ ...foldPreferences(), modes: next });
}
export function setShowsThinkingPreview(on: boolean): void {
saveFolds({ ...foldPreferences(), thinkingPreview: on });
}
function saveFolds(next: FoldPreferences): void {
folds = next;
try {
localStorage.setItem(FOLD_KEY, JSON.stringify(next));
localStorage.setItem(FOLD_KEY, JSON.stringify({ ...next.modes, thinkingPreview: next.thinkingPreview }));
} catch {
/* the choice holds for this window and is forgotten on the next */
}
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
afterEach(() => vi.restoreAllMocks());
// Dynamic imports exercise a fresh preference cache after a window reload.
describe("thinking preview persistence", () => {
it("migrates a saved peek into collapsed thinking with previews, preserving sibling modes", async () => {
localStorage.setItem("rx-transcript-fold", JSON.stringify({ thinking: "peek", activity: "open", steps: "changed" }));
const prefs = await import("./prefs");
expect(prefs.foldModes()).toMatchObject({ thinking: "folded", activity: "open", steps: "changed" });
expect(prefs.showsThinkingPreview()).toBe(true);
prefs.setShowsThinkingPreview(false);
vi.resetModules();
const reloaded = await import("./prefs");
expect(reloaded.showsThinkingPreview()).toBe(false);
expect(reloaded.foldModes()).toMatchObject({ thinking: "folded", activity: "open", steps: "changed" });
});
it("persists the preview independently of later folding choices", async () => {
const prefs = await import("./prefs");
prefs.setShowsThinkingPreview(true);
prefs.setFoldModes({ thinking: "live", activity: "folded" });
vi.resetModules();
const reloaded = await import("./prefs");
expect(reloaded.showsThinkingPreview()).toBe(true);
expect(reloaded.foldModes()).toMatchObject({ thinking: "live", activity: "folded" });
});
it.each(["not-json", "null", "[]", '{"thinking":"unknown","thinkingPreview":"true"}'])("handles unreadable or invalid preferences: %s", async (raw) => {
localStorage.setItem("rx-transcript-fold", raw);
const prefs = await import("./prefs");
expect(prefs.foldModes()).toEqual(prefs.FOLD_DEFAULTS);
expect(prefs.showsThinkingPreview()).toBe(false);
});
it("keeps a migrated choice usable when storage cannot be written", async () => {
localStorage.setItem("rx-transcript-fold", '{"thinking":"peek"}');
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Unavailable", "SecurityError"); });
const prefs = await import("./prefs");
expect(prefs.showsThinkingPreview()).toBe(true);
prefs.setShowsThinkingPreview(false);
prefs.setFoldModes({ thinking: "live" });
expect(prefs.showsThinkingPreview()).toBe(false);
expect(prefs.foldModes().thinking).toBe("live");
});
});
@@ -1690,3 +1690,9 @@ A sub-agent row, by the list's own width:
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
- The mounted content keeps its draft, quotes, answers and pending request while collapsed. Errors, quotes and replies share the scroll region; the input stays outside it.
## Collapsed thinking
- 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.
@@ -276,6 +276,18 @@ pack states a shape, never a number the layout has to agree with.
- `--read` is the conversation body's own size, separate from interface zoom, so
"I cannot read this" adjusts one thing. The value here is the first-frame
fallback; the real default is `look.ts`'s `readDefault()`.
- Execution content derives size from `--read`. Appearance also projects
`--read-scale` as the selected body size divided by the language's default.
- The root's `--activity-scale` multiplies that ratio by the interface text scale.
Activity groups inherit the product as their local `--fs-scale`, including
work folded at turn completion.
- Composing at the root avoids self-reference and prevents nested activity
groups from multiplying the scale again.
- Activity title, running, failed and action counts retain one 11px/500 scale,
1.5 line-height and the running chip's 2px vertical padding. Interface and
body-size changes scale all four equally; the common 500 weight stays fixed.
Tool rows and input details retain reading/weight preferences.
- Labels use `--ui`; tool names, paths and input use `--mono`.
- `--column` is the reading column's width. The composer is laid at it in
every measure; the transcript takes it through `--doc`.
- `--doc` is the column the transcript is laid in: `--column`, or the whole
+14 -12
View File
@@ -705,7 +705,7 @@
.scroll[data-pane="browser"] { display: flex; flex-direction: column; overflow: hidden }
.scroll[data-pane="browser"][hidden] { display: none }
.pbody { flex: 1; min-height: 0; display: flex; flex-direction: column }
.pbody { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative }
.pviews { flex: 1; min-height: 0; display: flex; flex-direction: column }
.pane > .scroll[data-pane="browser"] { flex: 1; min-height: 0 }
/* The pages full width: the column holding the conversation is empty then,
@@ -1080,8 +1080,8 @@
@keyframes bob { 0%, 100% { opacity: .22; transform: translateY(0) } 50% { opacity: 1; transform: translateY(-3px) } }
.think { margin-top: 5px }
.think > summary { display: inline-flex; max-width: 100%; align-items: baseline; gap: 8px }
.think .peek { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--faint) }
.think > summary { display: flex; flex-direction: column; max-width: 100%; gap: 0 }
.thinking-preview { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 5px 0 0 14px; padding-left: 10px; border-left: 1px solid var(--hair); font: var(--w-reg) var(--fs-ui)/1.68 var(--ui); color: var(--faint) }
.think .tk {
margin-top: 5px; padding-left: 14px;
font: var(--w-reg) var(--fs-ui)/1.68 var(--ui); color: var(--faint); white-space: pre-wrap;
@@ -2356,7 +2356,7 @@
animation: sheetin var(--t-panel) var(--ease-spring) both;
}
.prefs-sheet[data-wide] { width: 100%; height: 100% }
.prefs-sheet[data-wide] .prefs-main .prefs-col { max-width: min(1200px, 100%) }
.prefs-sheet[data-wide] .prefs-main .prefs-col { max-width: min(1200px, 100%); margin-inline: auto }
@keyframes prefsin { from { opacity: 0 } to { opacity: 1 } }
@keyframes sheetin { from { transform: translateY(14px) scale(.985) } to { transform: none } }
@@ -3132,7 +3132,7 @@
.mp-source-line { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
.mp-host { font-family: var(--mono); color: var(--text) }
.mp-foreign { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px }
.mp-row select { padding: 6px 8px; font-size: var(--fs-meta) }
.mp-row select { padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-meta) var(--mono) } .mp-row[data-own] select { border-color: var(--accent) }
.ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
.ml-row input::placeholder { color: var(--ghost) }
.ml-row[data-own] .unit-field:has(input:not(:placeholder-shown)) { border-color: var(--accent) }
@@ -3893,12 +3893,12 @@
.code-more .studio-icon { width: 13px; height: 13px; transform: rotate(90deg); transition: transform var(--m-line) }
.code-more[aria-expanded="true"] .studio-icon { transform: rotate(-90deg) }
.md pre.term code { font: inherit; color: inherit; white-space: pre }
.md pre.term .hljs-comment, .md pre.term .hljs-quote { color: var(--faint); font-style: italic }
.md pre.term :is(.hljs-punctuation, .hljs-operator, .hljs-meta, .hljs-params, .hljs-subst) { color: var(--muted) }
.md pre.term .hljs-comment, .md pre.term .hljs-quote { color: var(--syn-comment, var(--faint)); font-style: italic }
.md pre.term :is(.hljs-meta, .hljs-params, .hljs-subst) { color: var(--muted) } .md pre.term .hljs-punctuation { color: var(--syn-punc, var(--muted)) } .md pre.term .hljs-operator { color: var(--syn-op, var(--muted)) } .md pre.term .hljs-variable { color: var(--syn-var, inherit) }
.md pre.term :is(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag, .hljs-variable.language_) { color: var(--syn-key) }
.md pre.term :is(.hljs-string, .hljs-regexp, .hljs-char.escape_, .hljs-addition, .hljs-attribute, .hljs-meta .hljs-string) { color: var(--syn-str) }
.md pre.term :is(.hljs-number, .hljs-symbol, .hljs-bullet, .hljs-link, .hljs-selector-attr, .hljs-template-variable) { color: var(--syn-num) }
.md pre.term :is(.hljs-title, .hljs-name, .hljs-section, .hljs-built_in, .hljs-type, .hljs-class, .hljs-attr, .hljs-property, .hljs-tag, .hljs-selector-class, .hljs-selector-id) { color: var(--syn-fn) }
.md pre.term :is(.hljs-title, .hljs-name, .hljs-section, .hljs-built_in, .hljs-attr, .hljs-property, .hljs-tag, .hljs-selector-class, .hljs-selector-id) { color: var(--syn-fn) } .md pre.term :is(.hljs-type, .hljs-class) { color: var(--syn-type, var(--syn-fn)) }
.md pre.term .hljs-deletion { color: var(--del) }
.md pre.term :is(.hljs-strong, .hljs-title.function_, .hljs-title.class_) { font-weight: var(--w-med) }
.md pre.term .hljs-emphasis { font-style: italic }
@@ -4413,11 +4413,11 @@
.ptab:hover { color: var(--text); background: var(--overlay) }
.ptab[aria-selected="true"] { color: var(--text); background: var(--surface); box-shadow: inset 0 1px 0 var(--border) }
.ptab:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
.ptab-nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis }
.ptab-nm { flex: 1; min-width: 4em; overflow: hidden; text-overflow: ellipsis }
.ptab-x { margin-left: auto }
.ptab-ws { flex: none; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
.ptab-host { flex: none; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--net) }
.ptab-ws, .ptab-host { flex: 0 1 auto; min-width: 0; max-width: 40%; overflow: hidden; text-overflow: ellipsis }
.ptab-ws { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
.ptab-host { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--net) }
.ptab-host::before { content: "◦"; margin-right: 2px; opacity: .7 }
.ptab .pip { flex: none; background: var(--ghost) }
@@ -5614,6 +5614,8 @@
.compose .row { --m-step: 30ms }
}
.wvol-pop { max-height: 0; opacity: 0; overflow: hidden; transition: max-height var(--t-panel) var(--ease), opacity var(--t-panel) var(--ease) }
.wvol:hover .wvol-pop, .wvol:focus-within .wvol-pop { max-height: 108px; opacity: 1 }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-delay: 0s !important;
@@ -1,25 +0,0 @@
.pbody { position: relative }
.flowgrips { position: absolute; inset: 0; z-index: 3; pointer-events: none }
.flowgrip {
position: absolute; top: 0; bottom: 0; width: 7px;
cursor: col-resize; touch-action: none; pointer-events: auto;
}
.flowgrip-l { left: max(0px, calc(var(--grip-left, 0px) - 18px)) }
.flowgrip-r { right: max(0px, calc(var(--grip-right, 0px) - 18px)) }
.flowgrip::before {
content: ""; position: absolute; top: 50%; left: 3px; width: 1px; height: 38px;
translate: 0 -50%;
border-radius: 1px;
background: transparent;
transition: background var(--m-card) var(--ease), height var(--m-card) var(--ease), transform var(--m-card) var(--ease);
}
.flowgrip:hover::before { background: var(--border); height: 64px; transform: scaleX(2) }
.flowgrip:focus-visible { outline: none }
.flowgrip:focus-visible::before { background: var(--focus); height: 64px; transform: scaleX(2) }
body[data-flowdrag] .flowgrip::before { background: var(--accent); height: 64px; transform: scaleX(2) }
body[data-flowdrag], body[data-flowdrag] * { cursor: col-resize }
body[data-flowdrag] { user-select: none }
@media (prefers-reduced-motion: reduce) {
.flowgrip::before { transition: none }
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
// `.railhost` is a zero-height sticky layer over the transcript, so it turns
// pointer events off and every child that a person can press has to turn them
// back on. Forgetting that is invisible to jsdom — it does no hit testing — and
// shows up only as "the rows cannot be clicked" in a real browser.
const SHEETS = import.meta.glob("./{app,studio}.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
interface Rule {
selector: string;
decls: Map<string, string>;
}
function rules(css: string): Rule[] {
const out: Rule[] = [];
for (const m of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const decls = new Map<string, string>();
for (const part of m[2].split(";")) {
const at = part.indexOf(":");
if (at > 0) decls.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
}
out.push({ selector: m[1].trim(), decls });
}
return out;
}
/** The declarations that apply to `className`, across every sheet, in source order. */
function declsOf(className: string): Map<string, string> {
const merged = new Map<string, string>();
for (const css of Object.values(SHEETS)) {
for (const rule of rules(css)) {
if (!rule.selector.split(",").some((s) => s.trim() === `.${className}`)) continue;
for (const [k, v] of rule.decls) merged.set(k, v);
}
}
return merged;
}
describe("the conversation rail can be pressed", () => {
it("turns pointer events off on the layer that floats over the transcript", () => {
expect(declsOf("railhost").get("pointer-events")).toBe("none");
});
// Anything a person can press inside that layer has to opt back in. The rail
// itself always did; the two surfaces added with it — the unloaded-history
// marker and the opened run — did not, and their rows fell through to the
// transcript underneath.
for (const surface of ["srail", "srail-earlier", "srail-sub"]) {
it(`lets .${surface} take the pointer`, () => {
expect(declsOf(surface).get("pointer-events"), `.${surface} sits inside pointer-events: none`).toBe("auto");
});
}
});
+19 -19
View File
@@ -219,7 +219,7 @@ body { background: var(--page); }
/* Composer */
.compose {
width: min(var(--flow-w, var(--column)), calc(100% - var(--srail-w) - 42px));
width: min(var(--column), calc(100% - var(--srail-w) - 42px));
max-width: none;
margin: 0 auto 14px;
padding: 14px 15px 11px;
@@ -714,9 +714,9 @@ body { background: var(--page) !important; }
background: color-mix(in srgb, var(--text) 38%, transparent);
}
.srail-m[data-on] > i { background: #91a7df; }
.srail-peek { right: 42px; width: min(300px, calc(100vw - var(--rail-w) - 78px)); }
.srail-peek { right: 42px; width: min(300px, calc(100vw - var(--rail-w) - 78px)); } .srail-earlier { right: 12px; width: 3px; height: 10px; margin-top: -5px; z-index: 2; padding: 0; border: 0; border-radius: 2px; background: color-mix(in srgb, var(--text) 26%, transparent); cursor: pointer; pointer-events: auto; } .srail-earlier:hover { background: var(--accent-ink) } .srail-sub { position: absolute; right: 42px; z-index: 6; width: 220px; max-height: 260px; overflow-y: auto; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--float); box-shadow: var(--shadow-float); transform: translateY(-50%); pointer-events: auto; } .srail-subhd { padding: 2px 6px 4px; color: var(--faint); font: 500 calc(10px * var(--fs-scale, 1))/1.4 var(--ui); } .srail-subrow { display: block; width: 100%; padding: 4px 6px; border: 0; border-radius: 6px; background: none; color: var(--text); font: 450 calc(11px * var(--fs-scale, 1))/1.45 var(--ui); text-align: start; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .srail-subrow:hover { background: var(--overlay) }
.flow {
width: min(var(--flow-w, var(--doc)), calc(100% - var(--srail-w) - 44px));
width: min(var(--doc), calc(100% - var(--srail-w) - 44px));
margin-inline: auto;
position: relative;
left: 0;
@@ -725,7 +725,7 @@ body { background: var(--page) !important; }
.flow[data-empty] { display: flex; align-items: center; justify-content: center; }
.flow:not([data-empty]) {
flex: 0 0 auto;
width: min(var(--flow-w, var(--doc)), calc(100% - var(--srail-w) - 34px));
width: min(var(--doc), calc(100% - var(--srail-w) - 34px));
margin: 0 auto;
padding: 28px 0 36px;
box-sizing: border-box;
@@ -868,7 +868,7 @@ body { background: var(--page) !important; }
color: var(--text); font-family: var(--ui); font-size: var(--read); font-weight: var(--w-reg); line-height: 1.72;
letter-spacing: 0; -webkit-font-smoothing: antialiased;
}
.activity-group { margin: var(--sp-4) 0 0; overflow: clip; }
.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;
@@ -920,17 +920,17 @@ body { background: var(--page) !important; }
}
.activity-body .hl .nm {
order: -9; flex: 0 1 auto; min-width: 3em; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: #d1d3cd; font: 520 calc(10.5px * var(--fs-scale, 1))/1.3 var(--mono) !important;
color: #d1d3cd; font: var(--w-med) calc(var(--read) * .85)/1.3 var(--mono) !important;
}
.activity-body .hl .arg {
order: -8; flex: 1 8 auto; color: var(--faint); font: 450 calc(10.5px * var(--fs-scale, 1))/1.3 var(--mono) !important;
order: -8; flex: 1 8 auto; color: var(--faint); font: var(--w-read, var(--w-reg)) calc(var(--read) * .85)/1.3 var(--mono) !important;
}
.activity-body .hl :is(.src, .who, .tag) { order: -7; flex: 0 3 auto; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(8.5px * var(--fs-scale, 1)); }
.activity-body .cost { order: -6; margin-left: auto; gap: 5px; color: var(--ghost); font-size: calc(9px * var(--fs-scale, 1)); }
.activity-body .hl :is(.src, .who, .tag) { order: -7; flex: 0 3 auto; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(var(--read) * .7); }
.activity-body .cost { order: -6; margin-left: auto; gap: 5px; color: var(--ghost); font-size: calc(var(--read) * .75); }
.activity-body .cost .took { display: none; }
.activity-note {
margin: 0; padding: 3px 12px 7px; border-top: 0;
color: var(--faint); font: 450 calc(9px * var(--fs-scale, 1))/1.5 var(--ui);
color: var(--faint); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.5 var(--ui);
}
.flow { --out-peek: 4rem; }
.flow .call:not([data-k="me"]):not([data-k="say"]) { margin-block: 0 2px; }
@@ -984,16 +984,16 @@ body { background: var(--page) !important; }
.activity-call-result { display: flex; align-items: center; gap: 8px; }
.activity-call-meta {
min-height: 25px; justify-content: space-between; padding-right: 5px;
color: var(--ghost); font: 450 calc(9px * var(--fs-scale, 1))/1.4 var(--ui);
color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.4 var(--ui);
}
.activity-call-input { padding: 5px 5px 8px 0; border-top: 1px solid var(--hair); }
.activity-call-input > header,
.activity-call-result { min-height: 23px; color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.3 var(--ui); }
.activity-call-result { min-height: 23px; color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.3 var(--ui); }
.activity-call-input > header b,
.activity-call-result b { color: var(--muted); font-weight: 560; }
.activity-call-input > header span { margin-left: auto; color: var(--ghost); font: 500 calc(8px * var(--fs-scale, 1))/1 var(--mono); }
.activity-call-result b { color: var(--muted); font-weight: var(--w-med); }
.activity-call-input > header span { margin-left: auto; color: var(--ghost); font: var(--w-med) calc(var(--read) * .65)/1 var(--mono); }
.activity-call-input > p,
.activity-call-input > details > summary { display: flex; align-items: center; min-height: 23px; margin: 0; color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.3 var(--ui); }
.activity-call-input > details > summary { display: flex; align-items: center; min-height: 23px; margin: 0; color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.3 var(--ui); }
.activity-call-input > details > summary { gap: 7px; list-style: none; cursor: pointer; user-select: none; }
.activity-call-input > details > summary::-webkit-details-marker { display: none; }
.activity-call-input > details > summary::before { content: ""; width: 5px; height: 5px; flex: none;
@@ -1003,12 +1003,12 @@ body { background: var(--page) !important; }
.activity-call-input pre {
max-height: 170px; margin: 2px 0 0; padding: 8px 10px; overflow: auto;
border: 1px solid var(--hair); border-radius: var(--r-md); background: var(--face-code);
color: var(--muted); font: var(--w-reg) calc(11.5px * var(--fs-scale, 1))/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere;
color: var(--muted); font: var(--w-reg) calc(var(--read) * .9)/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere;
}
.activity-call-result { padding: 6px 5px 3px 0; border-top: 1px solid var(--hair); }
.activity-call-result button {
margin-left: auto; padding: 3px 5px; border: 0; border-radius: 4px;
background: transparent; color: var(--ghost); cursor: pointer; font: 500 calc(8.5px * var(--fs-scale, 1))/1 var(--ui);
background: transparent; color: var(--ghost); cursor: pointer; font: var(--w-med) calc(var(--read) * .7)/1 var(--ui);
}
.activity-call-result button:hover { color: #cfd1ca; background: var(--float); }
.tool-disclosure[open] > summary .tool-fold { transform: rotate(225deg) translate(-1px, 1px); }
@@ -1074,7 +1074,7 @@ body { background: var(--page) !important; }
.flow .copy:hover { color: var(--muted); background: transparent; }
.compose {
width: min(var(--flow-w, var(--column)), calc(100% - var(--srail-w) - 42px));
width: min(var(--column), calc(100% - var(--srail-w) - 42px));
margin-bottom: 35px;
padding: 15px 16px 10px;
border-radius: 15px;
@@ -1174,7 +1174,7 @@ body { background: var(--page) !important; }
.btn.send:hover { background: var(--accent); transform: translateY(-1px); }
.btn.send:disabled { background: var(--hair); color: var(--ghost); }
.composeaux,
.composenotes { width: min(var(--flow-w, var(--column)), calc(100% - var(--srail-w) - 42px)); margin-inline: auto; }
.composenotes { width: min(var(--column), calc(100% - var(--srail-w) - 42px)); margin-inline: auto; }
.composenotes { color: var(--ghost); }
.studio-meterrail {
position: absolute; z-index: 12; left: 16px; right: 16px; bottom: -32px; min-height: 27px;
@@ -129,6 +129,7 @@
--fs-lead: calc(15px * var(--fs-scale, 1)); --fs-title: calc(17px * var(--fs-scale, 1)); --fs-display: calc(22px * var(--fs-scale, 1));
--read: 16px;
--activity-scale: calc(var(--fs-scale, 1) * var(--read-scale, 1));
--column: 760px; --doc: min(100%, var(--column)); --wide: min(100%, 1120px);
+4 -11
View File
@@ -26,7 +26,6 @@ import { useLinkRouting } from "./links";
import { Pane, type PaneReport } from "./Pane";
import { clearDraftForSession } from "./drafts";
import { DOCK, Gutter, RAIL, dockMax, keepWidth, widthOf } from "./Gutter";
import { useColumn } from "./FlowResizer";
import { useWindowKeys, type Shortcut } from "./windowkeys";
import { useEscStop } from "../state/escstop";
import { startUpdateWatch } from "../state/updatewatch";
@@ -50,7 +49,7 @@ import { useZoomKeys } from "./zoomkeys";
import { rememberActivePane, savedActivePane } from "./activepane";
import { Sidebar } from "./Sidebar";
import { PeerThreadsProvider } from "./peerthreads";
import { Backdrop } from "./Backdrop";import { useAddWorkspace } from "./addws";
import { WallpaperStage } from "./WallpaperStage";import { useAddWorkspace } from "./addws";
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
import { PaneTabs } from "./PaneTabs";
import { paneReader } from "../state/paneread";
@@ -398,10 +397,6 @@ export function App({ hub }: { hub: HubPort }) {
useEffect(() => onRoomWidth((width) => setDockLimit(dockMax(width))), []);
const shownDockW = Math.min(dockW, dockLimit);
// The reading column owns its width and what it takes from the sidebars; the
// window only says how much room there is.
const col = useColumn(railW, shownDockW, RAIL.min, DOCK.min);
const onRailW = useCallback((w: number) => { setRailW(w); keepWidth(RAIL, w); }, []);
const onDockW = useCallback((w: number) => { setDockW(w); keepWidth(DOCK, w); }, []);
@@ -563,7 +558,7 @@ export function App({ hub }: { hub: HubPort }) {
data-apv={report.status?.toolApprovalMode ?? "ask"}
data-prefs={settings ? "" : undefined}
data-tabs={runtimes.length > 1 ? "" : undefined}
style={{ "--rail-open": `${col.rail}px`, "--dock-open": `${col.dock}px`, ...(col.dragged > 0 ? { "--flow-w": `${col.width}px` } : null) } as CSSProperties}
style={{ "--rail-open": `${railW}px`, "--dock-open": `${shownDockW}px` } as CSSProperties}
>
{ask && <RemoteAsk ask={ask} onAnswer={answerRemote} />}
<BrowserLogin />
@@ -574,7 +569,6 @@ export function App({ hub }: { hub: HubPort }) {
status={report.status}
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
steer={report.steer}
onSettings={showPrefs}
account={account}
theme={scheme}
onTheme={() => setTheme(scheme === "dark" ? "light" : "dark")}
@@ -583,7 +577,7 @@ export function App({ hub }: { hub: HubPort }) {
hub={hub} onError={fail}
/>
<Backdrop sky={!!pack?.sky && effects !== "reduced"} wallpaper={look?.wallpaper} />
<WallpaperStage sky={!!pack?.sky && effects !== "reduced"} look={look} onLook={onLook} />
<div className="cols">
{navRail.drawn && (
@@ -710,10 +704,9 @@ export function App({ hub }: { hub: HubPort }) {
onFeedback={openFeedback}
onOpenSession={openSessionFor(rt.id)}
theme={scheme}
dockW={col.dock}
dockW={shownDockW}
dockMax={dockLimit}
onDockW={onDockW}
column={col}
manualBrowser={rt.id === active && browser}
onManualBrowser={setBrowser}
/>
@@ -29,6 +29,8 @@ import { EscStopSetting } from "./EscStopSetting";
import { PageSleepSetting } from "./PageSleepSetting";
import { ThemeImport } from "./ThemeImport";
import { ThemeRemove } from "./ThemeRemove";
import { WallpaperSceneImport } from "./WallpaperSceneImport";
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
import { useThemeInventory } from "./useThemeInventory";
// "" follows the machine; the rest are explicit, the same shape the light/dark
@@ -413,6 +415,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</span>
{t(look.wallpaper ? "换一张…" : "选择图片或视频…")}
</button>
<WallpaperSceneImport port={port} look={look} onLook={onLook} setPaper={setPaper} onError={setFailed} />
{/* Composited the way the window is: the picture, then page colour
over it. The scrim fades out with the strength, or a picture
turned down to nothing would still be sitting under a dark wash. */}
@@ -507,6 +510,9 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
/>
<span className="now">{pct(look.wallpaper.focusY)}</span>
</label>
{(look.wallpaper.audios?.length || look.wallpaper.audio) && (
<WallpaperVolumeSlider volume={look.wallpaper.volume} onChange={setPaper} />
)}
{crop.known && !(crop.x && crop.y) && (
<p className="note">
{crop.x
+1 -5
View File
@@ -29,7 +29,6 @@ interface Props {
status: SessionStatus | null;
title?: string;
steer: number;
onSettings: (section?: string) => void;
account: AccountState | null;
theme: string;
onTheme: () => void;
@@ -44,7 +43,7 @@ interface Props {
onError?: (e: unknown) => void;
}
export function Chrome({ port, status, title, steer, onSettings, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
export function Chrome({ port, status, 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
@@ -149,9 +148,6 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="2.7" /><path d="M8 1.7v1.4M8 12.9v1.4M1.7 8h1.4M12.9 8h1.4M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1" /></svg>
)}
</TooltipButton>
<TooltipButton className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m8 2.5 5 2.7v5.6l-5 2.7-5-2.7V5.2Z" /><path d="m3 5.2 5 2.7 5-2.7M8 7.9v5.6" /></svg><span>{t("沙盒")}</span>
</TooltipButton>
{port && <ViewSwitch view={view} rows={rows} surfaces={surfaces} onView={onView} />}
</div>
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
+6 -5
View File
@@ -55,11 +55,12 @@ const highlight = HighlightStyle.define([
{ tag: [tags.keyword, tags.tagName, tags.modifier, tags.operatorKeyword], color: "var(--syn-key)" },
{ tag: [tags.string, tags.regexp, tags.special(tags.string), tags.attributeValue], color: "var(--syn-str)" },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--syn-num)" },
{
tag: [tags.function(tags.variableName), tags.function(tags.propertyName), tags.typeName, tags.className],
color: "var(--syn-fn)",
},
{ tag: [tags.comment, tags.meta], color: "var(--faint)", fontStyle: "italic" },
{ tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], color: "var(--syn-fn)" },
{ tag: [tags.typeName, tags.className], color: "var(--syn-type, var(--syn-fn))" },
{ tag: [tags.comment, tags.meta], color: "var(--syn-comment, var(--faint))", fontStyle: "italic" },
{ tag: [tags.operator, tags.compareOperator, tags.arithmeticOperator, tags.logicOperator, tags.bitwiseOperator, tags.updateOperator, tags.definitionOperator, tags.derefOperator, tags.controlOperator], color: "var(--syn-op, var(--text))" },
{ tag: [tags.punctuation, tags.separator, tags.bracket, tags.angleBracket, tags.squareBracket, tags.paren, tags.brace], color: "var(--syn-punc, var(--text))" },
{ tag: [tags.variableName, tags.propertyName, tags.definition(tags.variableName)], color: "var(--syn-var, var(--text))" },
{ tag: tags.heading, fontWeight: "bold" },
{ tag: tags.link, textDecoration: "underline" },
]);
@@ -247,7 +247,8 @@ describe("screenshots", () => {
const node = target === "heading" ? document.querySelector("#fbk-title")! : document.activeElement!;
fireEvent.paste(node, { clipboardData: { files: [png("dialog.png")] } });
expect(await screen.findByAltText("dialog.png")).toBeTruthy();
expect(heldDraft().shots.map((s) => s.name)).toEqual(["dialog.png"]);
// The draft is held from an effect, which can run after the thumbnail is on screen.
await waitFor(() => expect(heldDraft().shots.map((s) => s.name)).toEqual(["dialog.png"]));
});
it("does not route a paste on My feedback into the hidden report", async () => {
@@ -1,221 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
import "./testkit";
import { barsFit, flowCeiling, flowFloor, flowMin, FlowGrips, useColumn, type Column } from "./FlowResizer";
const RAIL_MIN = 232;
const DOCK_MIN = 320;
afterEach(cleanup);
describe("the reading column's two ends", () => {
it("holds the readable measure as the floor and the window as the ceiling", () => {
// 1600px window, both sidebars at their floors: 1048px is left for the text,
// which is more than the readable measure, so 760 holds the floor.
expect(flowCeiling(1600, RAIL_MIN, DOCK_MIN)).toBe(1048);
expect(flowFloor(1600, RAIL_MIN, DOCK_MIN)).toBe(760);
// A wide window leaves more still; the ceiling follows it, the floor does not.
expect(flowCeiling(2400, RAIL_MIN, DOCK_MIN)).toBe(1848);
expect(flowFloor(2400, RAIL_MIN, DOCK_MIN)).toBe(760);
// On a window too small to hold the readable measure, the window becomes the
// floor rather than a promise of room the layout cannot hand over.
expect(flowFloor(1200, RAIL_MIN, DOCK_MIN)).toBe(648);
});
it("never reports a floor above its ceiling, however narrow the window", () => {
for (const room of [900, 1200, 1600, 2400, 3440]) {
expect(flowFloor(room, RAIL_MIN, DOCK_MIN)).toBeLessThanOrEqual(flowCeiling(room, RAIL_MIN, DOCK_MIN));
}
});
it("lets a hand go narrower than the readable measure, down to 640", () => {
expect(flowMin(1600, RAIL_MIN, DOCK_MIN)).toBe(640);
expect(flowMin(2400, RAIL_MIN, DOCK_MIN)).toBe(640);
// On a window whose floor is already below 640, the floor wins.
expect(flowMin(1100, RAIL_MIN, DOCK_MIN)).toBe(flowFloor(1100, RAIL_MIN, DOCK_MIN));
for (const room of [400, 900, 1200, 1600, 2400]) {
expect(flowMin(room, RAIL_MIN, DOCK_MIN)).toBeLessThanOrEqual(flowFloor(room, RAIL_MIN, DOCK_MIN));
}
});
it("never reports a ceiling below zero", () => {
expect(flowCeiling(400, RAIL_MIN, DOCK_MIN)).toBe(0);
expect(flowFloor(400, RAIL_MIN, DOCK_MIN)).toBe(0);
});
});
describe("the two sidebars making room for the column", () => {
it("leaves them alone while there is room for both", () => {
expect(barsFit(2400, 760, 316, 316, RAIL_MIN, DOCK_MIN)).toEqual({ rail: 316, dock: 316 });
});
it("pins the rail at its floor rather than growing the other past its setting", () => {
// 1300 - 760 leaves 540, under the 552 the two floors need. The rail is
// pinned at its own floor; the workbench keeps the 316 it was set to rather
// than growing into the room the rail gave up.
const { rail, dock } = barsFit(1300, 760, 316, 316, RAIL_MIN, DOCK_MIN);
expect(rail).toBe(RAIL_MIN);
expect(dock).toBe(316);
expect(rail + dock).toBeLessThan(316 + 316);
});
it("never narrows a sidebar past its own floor, and never past its setting", () => {
for (const room of [1200, 1300, 1600, 2000, 2400]) {
for (const ask of [760, 1000, 1400, 1800]) {
const { rail, dock } = barsFit(room, ask, 400, 340, RAIL_MIN, DOCK_MIN);
expect(rail).toBeGreaterThanOrEqual(RAIL_MIN);
expect(dock).toBeGreaterThanOrEqual(DOCK_MIN);
expect(rail).toBeLessThanOrEqual(400);
expect(dock).toBeLessThanOrEqual(340);
}
}
});
it("recomputes from the settings, so narrowing hands the same room back", () => {
// Derived from the settings on every call, never from the previous result:
// that is what makes this reversible and keeps either sidebar from creeping
// wider than the width it was set to.
const squeezed = barsFit(1350, 760, 316, 316, RAIL_MIN, DOCK_MIN);
const released = barsFit(2400, 760, 316, 316, RAIL_MIN, DOCK_MIN);
expect(squeezed.rail + squeezed.dock).toBeLessThan(632);
expect(released).toEqual({ rail: 316, dock: 316 });
expect(barsFit(2400, 760, 316, 316, RAIL_MIN, DOCK_MIN)).toEqual(released);
});
it("answers for a width a hand is still holding, not only a committed one", () => {
// A drag asks this on every frame. Committing on release only is what let
// the column grow under sidebars that had not moved yet, so the answer has
// to exist before anything is stored.
localStorage.clear();
const { result } = renderHook(() => useColumn(316, 400, RAIL_MIN, DOCK_MIN));
const bars = result.current.fit(result.current.ceiling);
expect(bars.rail).toBeGreaterThanOrEqual(RAIL_MIN);
expect(bars.dock).toBeGreaterThanOrEqual(DOCK_MIN);
expect(bars.rail + bars.dock).toBeLessThan(316 + 400);
// The same rule the committed numbers obey, so committing cannot jump.
expect(bars).toEqual(barsFit(document.body.clientWidth, result.current.ceiling, 316, 400, RAIL_MIN, DOCK_MIN));
});
});
describe("the column's remembered width", () => {
it("starts at the floor when nothing has been dragged", () => {
localStorage.clear();
const { result } = renderHook(() => useColumn(316, 316, RAIL_MIN, DOCK_MIN));
expect(result.current.dragged).toBe(0);
expect(result.current.width).toBe(result.current.floor);
// Untouched means untouched: no inline variable outranks the measure setting.
expect(result.current.rail).toBe(316);
expect(result.current.dock).toBe(316);
});
it("remembers what a hand set, and holds it between the two ends", () => {
localStorage.clear();
const { result } = renderHook(() => useColumn(316, 316, RAIL_MIN, DOCK_MIN));
act(() => result.current.commit(5000));
// The number is kept as it was asked for...
expect(result.current.dragged).toBe(5000);
expect(localStorage.getItem("rx-flow-w")).toBe("5000");
// ...while what is drawn stays inside the range this window allows.
expect(result.current.width).toBeGreaterThanOrEqual(result.current.floor);
expect(result.current.width).toBeLessThanOrEqual(result.current.ceiling);
});
it("keeps a remembered width below the narrowest from shrinking the column", () => {
localStorage.clear();
localStorage.setItem("rx-flow-w", "100");
const { result } = renderHook(() => useColumn(316, 316, RAIL_MIN, DOCK_MIN));
expect(result.current.width).toBe(result.current.min);
// Asked for, still remembered — the clamp is about what is drawn.
expect(result.current.dragged).toBe(100);
});
});
describe("a drag that ends without a release", () => {
const into = (over: Partial<Column> = {}) => {
const box = document.createElement("div");
box.className = "app";
document.body.appendChild(box);
const column: Column = {
width: 760, floor: 760, min: 640, ceiling: 1048, rail: 316, dock: 316, dragged: 0,
commit: () => {},
fit: (w) => barsFit(2400, w, 316, 316, RAIL_MIN, DOCK_MIN),
...over,
};
render(<FlowGrips column={column} />, { container: box });
return box;
};
const grip = (box: HTMLElement) => {
const el = box.querySelector(".flowgrip-l") as HTMLElement;
el.setPointerCapture = () => {};
fireEvent.pointerDown(el, { button: 0, clientX: 400, pointerId: 1 });
return el;
};
it("leaves no width behind when a first drag is cancelled", () => {
// `App` writes `--flow-w` on `.app` only once a width has been committed.
// A cancelled first drag must therefore take the value away again: setting
// it back would leave a pixel width nobody owns, and the column would stay
// pinned to it while the window changed around it.
const box = into({ dragged: 0 });
const el = grip(box);
box.style.setProperty("--flow-w", "900px");
fireEvent.pointerCancel(el, { pointerId: 1 });
expect(box.style.getPropertyValue("--flow-w")).toBe("");
// The sidebars are owned by `App` from the start, so those come back.
expect(box.style.getPropertyValue("--rail-open")).toBe("316px");
});
it("puts a committed width back when a later drag is cancelled", () => {
const box = into({ dragged: 900, width: 900 });
const el = grip(box);
box.style.setProperty("--flow-w", "980px");
fireEvent.pointerCancel(el, { pointerId: 1 });
expect(box.style.getPropertyValue("--flow-w")).toBe("900px");
});
});
describe("the keys walk the same path the hand does", () => {
const column = (over: Partial<Column> = {}): Column => ({
width: 900, floor: 760, min: 640, ceiling: 1048, rail: 300, dock: 400, dragged: 900,
commit: () => {},
fit: (w) => barsFit(2400, w, 300, 400, RAIL_MIN, DOCK_MIN),
...over,
});
const press = (edge: "l" | "r", key: string, shiftKey = false, from = 900) => {
const seen: number[] = [];
const { container, unmount } = render(
<FlowGrips column={column({ width: from, commit: (w) => seen.push(w) })} />,
);
fireEvent.keyDown(container.querySelector(`.flowgrip-${edge}`)!, { key, shiftKey });
unmount();
return seen;
};
it("widens towards its own side on both handles", () => {
// Left handle, ArrowLeft: the edge travels away from the text, so it grows.
expect(press("l", "ArrowLeft")).toEqual([912]);
expect(press("l", "ArrowRight")).toEqual([888]);
// The right handle mirrors it — the same outward direction, the same result.
expect(press("r", "ArrowRight")).toEqual([912]);
expect(press("r", "ArrowLeft")).toEqual([888]);
});
it("takes a longer step with Shift and jumps to the floor with Home", () => {
expect(press("l", "ArrowLeft", true)).toEqual([948]);
expect(press("l", "Home")).toEqual([760]);
});
it("asks for nothing at either end rather than committing what it cannot reach", () => {
// Already as wide as the window allows: widening again commits nothing.
expect(press("l", "ArrowLeft", false, 1048)).toEqual([]);
// Already at the narrowest: narrowing again commits nothing.
expect(press("l", "ArrowRight", false, 640)).toEqual([]);
// The readable measure is no longer the end of the road.
expect(press("l", "ArrowRight", false, 760)).toEqual([748]);
});
});
@@ -1,324 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { t } from "../i18n";
import { onRoomWidth } from "./viewport";
/** The width the "readable" measure names, in pixels.
*
* The stylesheet spells the same number as `--column`, which `--doc` reads
* through `min(100%, …)`: on a window too narrow for it the window wins, which
* is why the floor below is computed against the ceiling rather than used on
* its own. */
const STANDARD = 760;
/** The narrowest a hand may draw the column. It sits below the readable measure
* so the width is continuous rather than two presets with a gap between them;
* the readable measure stays the default and what Home returns to. */
const NARROWEST = 640;
/** Where the dragged width is remembered. It belongs to this machine's screen,
* like the two sidebars' own widths, so it is stored beside them rather than in
* the kernel's config. */
export const FLOW_KEY = "rx-flow-w";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** What the column may grow to: the window, less the room the two sidebars need
* at their narrowest. Past that there is nothing left to give, and on a window
* too small to hold even that, the ceiling falls with it rather than promising
* room the layout cannot hand over. */
export const flowCeiling = (room: number, railMin: number, dockMin: number) =>
Math.max(0, Math.round(room - railMin - dockMin));
/** What the column may shrink to: the readable measure, or the whole window when
* the window is the smaller of the two. A drag runs between this and the
* ceiling, and neither end depends on where the column currently sits — so a
* column drawn at full width can still be pulled back to the readable measure,
* and one at the readable measure can still be pulled out. */
export const flowFloor = (room: number, railMin: number, dockMin: number) =>
Math.min(STANDARD, flowCeiling(room, railMin, dockMin));
/** What a hand may shrink the column to: `NARROWEST`, or the floor when the
* window is already narrower than that. */
export const flowMin = (room: number, railMin: number, dockMin: number) =>
Math.min(NARROWEST, flowFloor(room, railMin, dockMin));
/** How wide each sidebar is drawn while the column asks for `ask`.
*
* They give way in proportion to the widths they were set to, down to their own
* floors and no further. Narrowing the column again hands the room back in that
* same proportion — which is why neither ends up wider than its setting: the
* proportions are recomputed from the settings each time, never from the last
* width drawn. */
export function barsFit(room: number, ask: number, rail: number, dock: number, railMin: number, dockMin: number) {
const want = rail + dock;
const left = Math.max(railMin + dockMin, Math.round(room - ask));
if (want <= left) return { rail, dock };
// Both are clamped to what they were set to as well as to their floors: taking
// room from one must never hand the other more than its own setting.
const railFit = clamp(Math.round((left * rail) / want), railMin, Math.min(rail, left - dockMin));
return { rail: railFit, dock: Math.min(dock, Math.max(dockMin, left - railFit)) };
}
/** The column's two ends, and what the sidebars are drawn at once it asks. */
export interface Column {
width: number;
/** The readable measure: what an untouched window draws and Home returns to. */
floor: number;
/** The narrowest a drag or key press may reach. */
min: number;
ceiling: number;
rail: number;
dock: number;
/** Absent until a hand has moved the column: an untouched window keeps
* whatever the measure setting asks for, and that setting is read by the
* stylesheet, not by this number. */
dragged: number;
commit: (w: number) => void;
/** How wide the sidebars are drawn while the column asks for `w`.
*
* The same rule as `rail`/`dock`, but for a width a hand is still moving:
* a drag has to ask this on every frame, because the committed numbers only
* catch up when the pointer is released — and until they do, the column
* would grow into room the sidebars are still occupying. */
fit: (w: number) => { rail: number; dock: number };
}
/** Everything the reading column's width is made of: the number a hand set, the
* two ends it is held between, and how much room that leaves the sidebars.
*
* It lives here rather than in the window so the window stays a window: this is
* one feature's whole state, and the rules it obeys are the three pure
* functions above it. */
export function useColumn(railW: number, dockW: number, railMin: number, dockMin: number): Column {
const [dragged, setDragged] = useState(() => {
const saved = Number(localStorage.getItem(FLOW_KEY));
return Number.isFinite(saved) && saved > 0 ? saved : 0;
});
const [room, setRoom] = useState(() => document.body.clientWidth);
useEffect(() => onRoomWidth(setRoom), []);
const floor = flowFloor(room, railMin, dockMin);
const min = flowMin(room, railMin, dockMin);
const ceiling = flowCeiling(room, railMin, dockMin);
// A window that shrank since the drag may leave the remembered number outside
// the range it is now allowed; the drawn column, and the sidebars beside it,
// both follow what is allowed rather than what was once asked for.
const width = dragged > 0 ? clamp(dragged, min, ceiling) : floor;
const bars = dragged > 0 ? barsFit(room, width, railW, dockW, railMin, dockMin) : { rail: railW, dock: dockW };
const commit = useCallback((w: number) => {
setDragged(w);
localStorage.setItem(FLOW_KEY, String(Math.round(w)));
}, []);
// The same rule the committed numbers above obey, asked for a width the
// pointer is still holding. A drag reads this on every frame: committing on
// release only is what let the column run under sidebars that had not moved
// yet, so the room was taken from nothing.
const fit = useCallback(
(w: number) => barsFit(room, w, railW, dockW, railMin, dockMin),
[room, railW, dockW, railMin, dockMin],
);
// The same object for as long as the numbers are the same. Every pane is
// memoised on its props, so a fresh object each render would look like news to
// all of them and re-draw every transcript in the window on an unrelated
// state change — the one thing the pane memo exists to prevent.
return useMemo(
() => ({ width, floor, min, ceiling, rail: bars.rail, dock: bars.dock, dragged, commit, fit }),
[width, floor, min, ceiling, bars.rail, bars.dock, dragged, commit, fit],
);
}
/** The two edges, as things a pointer can take hold of.
*
* They are placed against the column rather than by repeating its width rule
* here: that rule depends on what the scroller reserves for the scrollbar, and a
* copy of it would drift from the original the first time either changed.
*
* The box to measure against is this element's own parent, not a ref handed
* down: a child's layout effects run before its parent's ref is attached, so
* asking the parent through a ref would read null and never try again. */
export function FlowGrips({ column }: { column: Column }) {
const grips = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const box = grips.current;
const outer = box?.parentElement;
if (!box || !outer) return;
const place = () => {
const flow = outer.querySelector(".flow");
if (!flow) return;
const a = outer.getBoundingClientRect();
const b = flow.getBoundingClientRect();
box.style.setProperty("--grip-left", `${Math.round(b.left - a.left)}px`);
box.style.setProperty("--grip-right", `${Math.round(a.right - b.right)}px`);
};
place();
const ro = new ResizeObserver(place);
ro.observe(outer);
const flow = outer.querySelector(".flow");
if (flow) ro.observe(flow);
return () => ro.disconnect();
}, []);
return (
<div className="flowgrips" ref={grips}>
{(["l", "r"] as const).map((edge) => (
<FlowGrip key={edge} edge={edge} column={column} />
))}
</div>
);
}
/** Put a width on the window while a hand still holds the edge: the column
* itself, and the two sidebars giving way in proportion to what they were set
* to.
*
* Everything is written to the `.app` element, never to `:root`. Once a width
* has been committed, `App` keeps `--flow-w` on `.app` inline, and an inherited
* `:root` value can never outrank its own descendant — which is why every drag
* after the first one moved nothing at all. */
function paint(app: HTMLElement | null, w: number, column: Column) {
const bars = column.fit(w);
const target = app ?? document.documentElement;
target.style.setProperty("--flow-w", `${w}px`);
target.style.setProperty("--rail-open", `${bars.rail}px`);
target.style.setProperty("--dock-open", `${bars.dock}px`);
}
function FlowGrip({ edge, column }: { edge: "l" | "r"; column: Column }) {
// The drag's own state, kept out of React so a pointer that moves sixty times
// a second does not re-render the transcript behind it.
const drag = useRef<{ x0: number; start: number; pending: number; app: HTMLElement | null } | null>(null);
const raf = useRef(0);
const down = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = {
x0: e.clientX,
start: column.width,
pending: column.width,
app: e.currentTarget.closest(".app") as HTMLElement | null,
};
document.body.dataset.flowdrag = edge;
},
[edge, column.width],
);
const move = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.x0;
// Both edges move the same number, so the column grows at both ends and
// the pointer travels half of what it gains.
d.pending = clamp(d.start + (edge === "l" ? -dx : dx) * 2, column.min, column.ceiling);
if (!raf.current) {
raf.current = requestAnimationFrame(() => {
raf.current = 0;
const cur = drag.current;
if (cur) paint(cur.app, cur.pending, column);
});
}
},
[edge, column],
);
// Everything a drag leaves behind in the document, undone in one place: the
// pending frame and the flag the stylesheet reads to turn every cursor into a
// resize one. A drag that ends any way at all has to pass through this, or the
// window keeps a col-resize cursor over everything with no way to clear it.
const stop = useCallback(() => {
cancelAnimationFrame(raf.current);
raf.current = 0;
drag.current = null;
delete document.body.dataset.flowdrag;
}, []);
const up = useCallback(() => {
const d = drag.current;
if (!d) return;
const wanted = d.pending;
// Leave the numbers where the hand left them: the commit below re-renders
// with the same values, and the frame in between must not fall back to the
// width the column started at.
paint(d.app, wanted, column);
stop();
column.commit(wanted);
}, [column, stop]);
// The drag never committed, so the width the reader let go of is dropped: the
// column and both sidebars go back to the numbers React is holding.
const cancel = useCallback(() => {
const d = drag.current;
if (!d) return;
if (d.app) {
// A width that was never committed has no owner on `.app`: `App` writes
// `--flow-w` itself only once something has been dragged. Putting a pixel
// value back here would leave it owned by nobody, so React would never
// remove it and the column would stay pinned to that width while the
// window was free to change around it.
if (column.dragged > 0) d.app.style.setProperty("--flow-w", `${column.width}px`);
else d.app.style.removeProperty("--flow-w");
d.app.style.setProperty("--rail-open", `${column.rail}px`);
d.app.style.setProperty("--dock-open", `${column.dock}px`);
}
stop();
}, [column, stop]);
// Switching conversation, closing the tab or collapsing the pane mid-drag
// unmounts this element with the pointer still down. Nothing would arrive to
// release it, so the cleanup does what the release would have: the width it
// had reached is dropped rather than committed — the reader never let go.
useEffect(() => cancel, [cancel]);
// The keys walk the same path the hand does: an arrow towards this edge's own
// side of the window widens the column, so the left handle's ArrowLeft and the
// right handle's ArrowRight both mean "wider". Reversing it per handle would
// make one of the two teach the opposite of what dragging it does.
const key = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
const dir = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
if (dir) {
e.preventDefault();
const grow = edge === "l" ? -dir : dir;
const next = clamp(column.width + grow * (e.shiftKey ? 48 : 12), column.min, column.ceiling);
if (next !== column.width) column.commit(next);
return;
}
if (e.key === "Home") {
e.preventDefault();
column.commit(column.floor);
}
},
[edge, column],
);
return (
<div
className={`flowgrip flowgrip-${edge}`}
role="separator"
aria-orientation="vertical"
aria-label={t("调整正文字宽")}
aria-valuenow={Math.round(column.width)}
aria-valuemin={column.min}
aria-valuemax={column.ceiling}
tabIndex={0}
data-action-pointerdown="appearance.reading-width"
data-action-pointermove="appearance.reading-width"
data-action-pointerup="appearance.reading-width"
data-action-pointercancel="appearance.reading-width"
data-action-keydown="appearance.reading-width"
onKeyDown={key}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={cancel}
/>
);
}
+34 -20
View File
@@ -1,8 +1,9 @@
import { useState } from "react";
import { Fragment, useState } from "react";
import { t } from "../i18n";
import { FOLD_CHOICES, FOLD_DEFAULTS, setFoldModes, type Fold, type FoldMode, type FoldModes } from "../state/prefs";
import { startsOpen, useFoldModes } from "../state/foldpref";
import { FOLD_CHOICES, FOLD_DEFAULTS, setFoldModes, setShowsThinkingPreview, type Fold, type FoldMode, type FoldModes } from "../state/prefs";
import { startsOpen, useFoldModes, useThinkingPreview } from "../state/foldpref";
import { ApplyNote } from "./Group";
import { Switch } from "./Switch";
const PARTS: [Fold, string, string][] = [
["thinking", "思考过程", "模型作答前的推理"],
@@ -14,7 +15,6 @@ const PARTS: [Fold, string, string][] = [
const MODE: Record<FoldMode, string> = {
folded: "收起",
peek: "收起并预览首句",
live: "进行中展开",
failed: "失败时展开",
changed: "改动时展开",
@@ -37,6 +37,7 @@ const same = (a: FoldModes, b: FoldModes) => PARTS.every(([k]) => a[k] === b[k])
* reader has already opened or closed keeps that choice. */
export function Folding() {
const modes = useFoldModes();
const thinkingPreview = useThinkingPreview();
const [hot, setHot] = useState<Fold | null>(null);
const [live, setLive] = useState(false);
const preset = PRESETS.find(([, , p]) => same(p, modes))?.[0];
@@ -57,25 +58,37 @@ export function Folding() {
))}
</div>
<FoldPreview modes={modes} hot={hot} live={live} onLive={setLive} />
<FoldPreview modes={modes} thinkingPreview={thinkingPreview} hot={hot} live={live} onLive={setLive} />
<div className="grp-items fold-rows" onMouseLeave={() => setHot(null)}>
{PARTS.map(([kind, label, why]) => (
<div key={kind} className="fold-row" data-hot={hot === kind ? "" : undefined}
onMouseEnter={() => setHot(kind)} onFocus={() => setHot(kind)}>
<span className="tx">
<span className="lb">{t(label)}</span>
<span className="ds">{t(why)}</span>
</span>
<div className="seg" data-text role="radiogroup" aria-label={t(label)}>
{FOLD_CHOICES[kind].map((mode) => (
<button key={mode} role="radio" data-action="transcript.fold" data-value={`${kind}:${mode}`}
aria-checked={modes[kind] === mode} onClick={() => setFoldModes({ [kind]: mode })}>
{t((kind === "output" && OUTPUT_MODE[mode]) || MODE[mode])}
</button>
))}
<Fragment key={kind}>
<div className="fold-row" data-hot={hot === kind ? "" : undefined}
onMouseEnter={() => setHot(kind)} onFocus={() => setHot(kind)}>
<span className="tx">
<span className="lb">{t(label)}</span>
<span className="ds">{t(why)}</span>
</span>
<div className="seg" data-text role="radiogroup" aria-label={t(label)}>
{FOLD_CHOICES[kind].map((mode) => (
<button key={mode} role="radio" data-action="transcript.fold" data-value={`${kind}:${mode}`}
aria-checked={modes[kind] === mode} onClick={() => setFoldModes({ [kind]: mode })}>
{t((kind === "output" && OUTPUT_MODE[mode]) || MODE[mode])}
</button>
))}
</div>
</div>
</div>
{kind === "thinking" && (
<div className="lrow" onMouseEnter={() => setHot(kind)} onFocus={() => setHot(kind)}>
<span className="tx">
<span className="lb">{t("收起时预览思考开头")}</span>
<span className="ds">{t("在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。")}</span>
</span>
<Switch data-action="transcript.thinking-preview" on={thinkingPreview} label={t("收起时预览思考开头")}
onClick={() => setShowsThinkingPreview(!thinkingPreview)} />
</div>
)}
</Fragment>
))}
</div>
<ApplyNote id="folding" />
@@ -85,7 +98,7 @@ export function Folding() {
// A schematic turn rather than real cards: it has to show a failed step and a
// running one on demand, which no real conversation holds still for.
function FoldPreview({ modes, hot, live, onLive }: { modes: FoldModes; hot: Fold | null; live: boolean; onLive: (v: boolean) => void }) {
function FoldPreview({ modes, thinkingPreview, hot, live, onLive }: { modes: FoldModes; thinkingPreview: boolean; hot: Fold | null; live: boolean; onLive: (v: boolean) => void }) {
const think = startsOpen(modes.thinking, live);
const work = startsOpen(modes.activity, live, false, true);
const step = (failed: boolean, changed = false) => startsOpen(modes.steps, false, failed, changed);
@@ -104,6 +117,7 @@ function FoldPreview({ modes, hot, live, onLive }: { modes: FoldModes; hot: Fold
<div className="fp-block" {...part("thinking")} data-open={think ? "" : undefined}>
<span className="fp-sum">{live ? t("思考中…") : t("思考了 6 秒")}</span>
{think && <Lines n={2} dim />}
{!think && thinkingPreview && <span className="thinking-preview">{t("先检查配置,再决定改哪里。")}</span>}
</div>
{!live && <Lines n={2} />}
<div className="fp-block fp-work" {...part("activity")} data-open={work ? "" : undefined}>
@@ -0,0 +1,160 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Network } from "./Network";
import { MockPort } from "../port/mock";
import type { NetworkSettings } from "../port/port";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const initial: NetworkSettings = {
mode: "custom", type: "http", server: "proxy.example", port: 8080,
effective: "http://proxy.example:8080", endpoint: "https://model.example/v1", hasPassword: true,
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (why: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function portWith(settings: NetworkSettings = initial) {
const port = new MockPort();
vi.spyOn(port, "network").mockResolvedValue(settings);
vi.spyOn(port, "saveNetwork").mockImplementation(async (draft) => ({ ...draft, effective: "direct" }));
vi.spyOn(port, "diagnoseNetwork").mockResolvedValue([{ step: "connect", ok: true, detail: "saved route tested", durationMs: 0 }]);
return port;
}
const save = () => screen.getByRole<HTMLButtonElement>("button", { name: "保存" });
it("disables saving again when edits are reverted", async () => {
const port = portWith();
render(<Network port={port} />);
const field = await screen.findByLabelText<HTMLInputElement>("服务器");
await userEvent.type(field, ".draft");
expect(save().disabled).toBe(false);
await userEvent.clear(field);
await userEvent.type(field, initial.server!);
const clear = screen.getByRole("checkbox", { name: "删除已保存的密码" });
await userEvent.click(clear);
expect(save().disabled).toBe(false);
await userEvent.click(clear);
expect(save().disabled).toBe(true);
expect(screen.queryByText(/未保存/)).toBeNull();
await userEvent.click(save());
expect(port.saveNetwork).not.toHaveBeenCalled();
});
it("distinguishes loading from a failed read", async () => {
const port = portWith();
const pending = deferred<NetworkSettings>();
vi.mocked(port.network).mockReturnValue(pending.promise);
render(<Network port={port} />);
expect(screen.getByRole("status").textContent).toBe("正在读取网络配置…");
await act(async () => pending.reject(new Error("network settings unavailable")));
expect(screen.getByRole("alert").textContent).toBe("network settings unavailable");
expect(screen.queryByRole("button", { name: "保存" })).toBeNull();
});
it("only saves changed fields and keeps the effective route tied to the saved state", async () => {
const port = portWith();
render(<Network port={port} />);
await screen.findByRole("radio", { name: "直连" });
expect(save().disabled).toBe(true);
await userEvent.click(screen.getByRole("radio", { name: "直连" }));
expect(save().disabled).toBe(false);
expect(screen.getByText(initial.effective)).toBeTruthy();
expect(screen.getByText(/未保存/)).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "测试已保存配置" }));
expect(port.diagnoseNetwork).toHaveBeenCalledExactlyOnceWith();
expect(port.saveNetwork).not.toHaveBeenCalled();
expect(await screen.findByText("saved route tested")).toBeTruthy();
await userEvent.click(save());
await waitFor(() => expect(save().disabled).toBe(true));
expect(port.saveNetwork).toHaveBeenCalledWith(expect.objectContaining({ mode: "off" }), "", false);
expect(screen.getByText("direct")).toBeTruthy();
expect(screen.queryByText("saved route tested")).toBeNull();
});
it("freezes a submitted draft and preserves it after refusal", async () => {
const port = portWith();
const pending = deferred<NetworkSettings>();
vi.mocked(port.saveNetwork).mockReturnValue(pending.promise);
render(<Network port={port} />);
const field = await screen.findByLabelText<HTMLInputElement>("服务器");
await userEvent.type(field, ".new");
await userEvent.click(save());
expect(field.matches(":disabled")).toBe(true);
expect(screen.getByRole("radio", { name: "直连" }).matches(":disabled")).toBe(true);
await userEvent.click(screen.getByRole("radio", { name: "直连" }));
await userEvent.type(field, "ignored");
await act(async () => pending.reject(new Error("disk refused")));
expect(await screen.findByText("disk refused")).toBeTruthy();
expect(field.value).toBe("proxy.example.new");
expect(screen.getByRole("radio", { name: "手动设置" }).getAttribute("aria-checked")).toBe("true");
expect(save().disabled).toBe(false);
expect(screen.getByText(initial.effective)).toBeTruthy();
});
it("lets a draft change during a test without relabelling the tested saved route", async () => {
const port = portWith();
const pending = deferred<Awaited<ReturnType<typeof port.diagnoseNetwork>>>();
vi.mocked(port.diagnoseNetwork).mockReturnValue(pending.promise);
render(<Network port={port} />);
const field = await screen.findByLabelText<HTMLInputElement>("服务器");
await userEvent.click(screen.getByRole("button", { name: "测试已保存配置" }));
expect(field.matches(":disabled")).toBe(false);
await userEvent.type(field, ".draft");
await act(async () => pending.resolve([{ step: "connect", ok: true, detail: "original saved route", durationMs: 0 }]));
expect(field.value).toBe("proxy.example.draft");
expect(screen.getByText(initial.effective)).toBeTruthy();
expect(screen.getByRole("region", { name: "已保存配置的测试结果" }).textContent).toContain("original saved route");
expect(save().disabled).toBe(false);
});
it("keeps password replacement and explicit deletion distinct", async () => {
const port = portWith();
render(<Network port={port} />);
const clear = await screen.findByRole("checkbox", { name: "删除已保存的密码" });
await userEvent.click(clear);
expect(save().disabled).toBe(false);
await userEvent.click(save());
await waitFor(() => expect(save().disabled).toBe(true));
expect(port.saveNetwork).toHaveBeenLastCalledWith(expect.anything(), "", true);
const password = screen.getByLabelText<HTMLInputElement>("密码(可选)");
await userEvent.type(password, "replacement-fixture");
await userEvent.click(save());
await waitFor(() => expect(password.value).toBe(""));
expect(port.saveNetwork).toHaveBeenLastCalledWith(expect.anything(), "replacement-fixture", false);
});
it.each(["load", "save", "diagnose"] as const)("drops an old port's pending %s result", async (operation) => {
const a = portWith(), b = portWith({ mode: "off", effective: "B direct" });
const pending = deferred<NetworkSettings>();
const probe = deferred<Awaited<ReturnType<typeof a.diagnoseNetwork>>>();
if (operation === "load") vi.mocked(a.network).mockReturnValue(pending.promise);
if (operation === "save") vi.mocked(a.saveNetwork).mockReturnValue(pending.promise);
if (operation === "diagnose") vi.mocked(a.diagnoseNetwork).mockReturnValue(probe.promise);
const view = render(<Network port={a} />);
if (operation !== "load") {
await screen.findByRole("radio", { name: "直连" });
if (operation === "save") {
await userEvent.click(screen.getByRole("radio", { name: "直连" }));
await userEvent.click(save());
} else await userEvent.click(screen.getByRole("button", { name: "测试已保存配置" }));
}
view.rerender(<Network port={b} />);
await screen.findByText("B direct");
await act(async () => {
pending.resolve({ ...initial, effective: "A late" });
probe.resolve([{ step: "connect", ok: true, detail: "A late probe", durationMs: 0 }]);
});
expect(screen.queryByText("A late")).toBeNull();
expect(screen.queryByText("A late probe")).toBeNull();
expect(screen.getByRole("radio", { name: "直连" }).getAttribute("aria-checked")).toBe("true");
expect(save().disabled).toBe(true);
});
@@ -4,7 +4,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Network } from "./Network";
import { boot, STORAGE } from "../i18n";
import { boot, STORAGE, t } from "../i18n";
import type { AgentPort, NetworkProbe } from "../port/port";
afterEach(() => {
@@ -28,7 +28,7 @@ async function run(lang: "zh" | "en") {
diagnoseNetwork: async () => probes,
} as unknown as AgentPort;
render(<Network port={port} />);
await userEvent.click(await screen.findByRole("button", { name: lang === "en" ? "Test it" : "测试连接" }));
await userEvent.click(await screen.findByRole("button", { name: t("测试已保存配置") }));
}
it("words each probe step in English from its code, with the pieces filled in", async () => {
+58 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { AgentPort, NetworkProbe, NetworkSettings } from "../port/port";
import { reason, say } from "../i18n/kernel";
@@ -20,49 +20,76 @@ const STEP_LABEL: Record<string, string> = {
};
const TYPES = ["http", "https", "socks5", "socks5h"];
const EDITABLE: (keyof NetworkSettings)[] = ["mode", "type", "server", "port", "username", "noProxy"];
export function Network({ port, host = "" }: { port: AgentPort; host?: string }) {
const [net, setNet] = useState<NetworkSettings | null>(null);
const [connection, setConnection] = useState({ port, generation: 0 });
if (connection.port !== port) setConnection({ port, generation: connection.generation + 1 });
return <NetworkInput key={connection.generation} port={port} host={host} />;
}
function NetworkInput({ port, host }: { port: AgentPort; host: string }) {
const [settings, setSettings] = useState<{ saved: NetworkSettings; draft: NetworkSettings } | null>(null);
const [password, setPassword] = useState("");
const [clearPassword, setClear] = useState(false);
const [probes, setProbes] = useState<NetworkProbe[] | null>(null);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const live = useRef(false);
const pending = useRef<"" | "save" | "test">("");
useEffect(() => {
port.network().then(setNet).catch(() => setNet(null));
let current = true;
live.current = true;
port.network().then((net) => current && setSettings({ saved: net, draft: net }))
.catch((e) => current && setError(reason(e)));
return () => { current = false; live.current = false; };
}, [port]);
if (!net) return <div className="empty">{t("无法读取网络配置。")}</div>;
if (!settings) return <div className="empty" role={error ? "alert" : "status"}>{error || t("正在读取网络配置…")}</div>;
const { saved, draft: net } = settings;
const changed = EDITABLE.some((key) => net[key] !== saved[key]) || password !== "" || clearPassword;
const patch = (p: Partial<NetworkSettings>) => setNet({ ...net, ...p });
const patch = (p: Partial<NetworkSettings>) => {
if (!live.current || pending.current === "save") return;
setSettings((current) => current && { ...current, draft: { ...current.draft, ...p } });
};
const save = async () => {
if (!live.current || pending.current || !changed) return;
pending.current = "save";
setBusy("save");
setError("");
try {
setNet(await port.saveNetwork(net, password, clearPassword));
const result = await port.saveNetwork(net, password, clearPassword);
if (!live.current) return;
setSettings({ saved: result, draft: result });
setPassword("");
setClear(false);
// The old result described the old settings; keeping it on screen would
// claim a path that has not been tested since.
setProbes(null);
} catch (e) {
setError(reason(e));
if (live.current) setError(reason(e));
} finally {
setBusy("");
pending.current = "";
if (live.current) setBusy("");
}
};
const diagnose = async () => {
if (!live.current || pending.current) return;
pending.current = "test";
setBusy("test");
setError("");
try {
setProbes(await port.diagnoseNetwork());
const result = await port.diagnoseNetwork();
if (live.current) setProbes(result);
} catch (e) {
setError(reason(e));
if (live.current) setError(reason(e));
} finally {
setBusy("");
pending.current = "";
if (live.current) setBusy("");
}
};
@@ -73,7 +100,7 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
)}
<div className="seg" data-text role="radiogroup" aria-label={t("代理模式")}>
{MODES.map(([id, label]) => (
<button key={id} role="radio" aria-checked={net.mode === id} onClick={() => patch({ mode: id })}>
<button key={id} role="radio" aria-checked={net.mode === id} disabled={busy === "save"} onClick={() => patch({ mode: id })}>
{t(label)}
</button>
))}
@@ -82,18 +109,18 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
<div className="kv">
<span className="k">{t("当前生效")}</span>
<span className="v">{net.effective || "—"}</span>
<span className="v">{saved.effective || "—"}</span>
</div>
{net.endpoint && (
{saved.endpoint && (
<div className="kv">
<span className="k">{t("测试目标")}</span>
<span className="v">{net.endpoint}</span>
<span className="v">{saved.endpoint}</span>
</div>
)}
{!!net.direct?.length && (
{!!saved.direct?.length && (
<div className="kv">
<span className="k">{t("始终直连")}</span>
<span className="v">{net.direct.join(", ")}</span>
<span className="v">{saved.direct.join(", ")}</span>
</div>
)}
@@ -101,7 +128,7 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
<div className="fields">
<label>
<span>{t("协议")}</span>
<select value={net.type || "http"} onChange={(e) => patch({ type: e.target.value })}>
<select value={net.type || "http"} disabled={busy === "save"} onChange={(e) => patch({ type: e.target.value })}>
{TYPES.map((t) => (
<option key={t} value={t}>
{t}
@@ -111,13 +138,14 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
</label>
<label className="grow">
<span>{t("服务器")}</span>
<input value={net.server ?? ""} placeholder="proxy.corp" onChange={(e) => patch({ server: e.target.value })} />
<input value={net.server ?? ""} disabled={busy === "save"} placeholder="proxy.corp" onChange={(e) => patch({ server: e.target.value })} />
</label>
<label>
<span>{t("端口")}</span>
<input
className="port"
inputMode="numeric"
disabled={busy === "save"}
value={net.port ? String(net.port) : ""}
placeholder="8080"
onChange={(e) => patch({ port: Number(e.target.value.replace(/\D/g, "")) || 0 })}
@@ -125,15 +153,17 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
</label>
<label className="grow">
<span>{t("用户名(可选)")}</span>
<input value={net.username ?? ""} onChange={(e) => patch({ username: e.target.value })} />
<input value={net.username ?? ""} disabled={busy === "save"} onChange={(e) => patch({ username: e.target.value })} />
</label>
<label className="grow">
<span>{t("密码(可选)")}</span>
<input
type="password"
disabled={busy === "save"}
value={password}
placeholder={t(net.hasPassword ? "已保存,留空即保持不变" : "可填写 ${PROXY_PASSWORD}")}
onChange={(e) => {
if (!live.current || pending.current === "save") return;
setPassword(e.target.value);
setClear(false);
}}
@@ -141,7 +171,9 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
</label>
{net.hasPassword && !password && (
<label className="chk">
<input type="checkbox" checked={clearPassword} onChange={(e) => setClear(e.target.checked)} />
<input type="checkbox" checked={clearPassword} disabled={busy === "save"} onChange={(e) => {
if (live.current && pending.current !== "save") setClear(e.target.checked);
}} />
<span>{t("删除已保存的密码")}</span>
</label>
)}
@@ -149,6 +181,7 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
<span>{t("这些地址直连")}</span>
<input
value={net.noProxy ?? ""}
disabled={busy === "save"}
placeholder="localhost, 127.0.0.1, *.internal"
onChange={(e) => patch({ noProxy: e.target.value })}
/>
@@ -158,15 +191,16 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
<div className="acts">
<button className="act" data-action="network.diagnose" disabled={!!busy} onClick={() => void diagnose()}>
{t(busy === "test" ? "测试中…" : "测试连接")}
{t(busy === "test" ? "测试中…" : "测试已保存配置")}
</button>
<button className="act" data-action="network.save" data-primary disabled={!!busy} onClick={() => void save()}>
<button className="act" data-action="network.save" data-primary disabled={!!busy || !changed} onClick={() => void save()}>
{t(busy === "save" ? "保存中…" : "保存")}
</button>
</div>
{changed && <p className="note" role="status">{t("有未保存的改动;连接测试使用已保存的配置。")}</p>}
{probes && (
<div className="probes">
<div className="probes" role="region" aria-label={t("已保存配置的测试结果")}>
{probes.map((p, i) => (
<div className="probe" key={i} data-ok={p.ok ? "" : undefined}>
<i className="mk">{p.ok ? "✓" : "×"}</i>
+1 -3
View File
@@ -49,7 +49,6 @@ import type { PaneProps, PaneReport } from "./panetypes";
import { useRate, useTrail } from "./num";
import { useCompactNow } from "./CompactNow";
import { DOCK, Gutter } from "./Gutter";
import { FlowGrips } from "./FlowResizer";
import { Find } from "./Find";
import { useFind } from "./usefind";
import { RunAnalysis } from "./RunAnalysis";
@@ -97,7 +96,7 @@ const totalsOf = (st: SessionStatus) => ({
incompleteReason: st.sessionCostQuote?.incompleteReason,
});
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, column, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
const [s, dispatch] = useReducer(reduce, initialState);
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
@@ -495,7 +494,6 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
<Find find={find} pending={historyPaging.hasMore || historyPaging.busy || historyPaging.failed} failed={historyPaging.failed} />
<div className="pbody rewind-notice-host" ref={body} data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
{column && tab === "flow" && <FlowGrips column={column} />}
<RewindNotice key={JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath])} readUndo={onReadUndo} onUndo={onUndoAvailable} busy={!!restoreNotice?.working || !!reloadFailure?.working} refreshKey={checkpoints} moved={s.rewindMoved} running={running} shown={visible && tab === "flow" && !rt.readOnly} />
<div className="pviews">
<div className="worktree-stage-host" ref={setWorktreeTarget} hidden={tab !== "flow" || !worktreePhase}/>
@@ -86,6 +86,37 @@ describe("a rail longer than it can show", () => {
// that holds the thousandth rather than on the thousandth itself.
expect(Math.abs(picked - 1000)).toBeLessThanOrEqual(40);
});
it("opens a folded run instead of guessing which question it stands for", async () => {
const { view, onJump } = await rail(2000);
const run = view.container.querySelector<HTMLElement>(".srail-m[data-folded]");
expect(run, "2000 questions in 600px must fold").not.toBeNull();
// First press: nothing jumps, because the run names a range and the reader
// has not said which question in it yet.
fireEvent.click(run!);
expect(onJump).not.toHaveBeenCalled();
const rows = view.container.querySelectorAll<HTMLElement>(".srail-subrow");
expect(rows.length).toBeGreaterThan(1);
// Second press, on a row: that question, exactly.
const label = rows[1].textContent ?? "";
fireEvent.click(rows[1]);
expect(onJump).toHaveBeenCalledTimes(1);
expect((onJump.mock.calls[0][0] as RailMark).text).toBe(label);
expect(view.container.querySelector(".srail-sub")).toBeNull();
});
it("jumps on the second press when the run is already open", async () => {
const { view, onJump } = await rail(2000);
const run = view.container.querySelector<HTMLElement>(".srail-m[data-folded]")!;
fireEvent.click(run);
fireEvent.click(run);
// Open, then the same line again: the contract the run carried before rows
// existed, kept so a fast second press is not a dead click.
expect(onJump).toHaveBeenCalledTimes(1);
expect(view.container.querySelector(".srail-sub")).toBeNull();
});
});
describe("a star belongs to the question, not the row id", () => {
+55 -3
View File
@@ -37,6 +37,11 @@ interface Props {
// Only the transcript on screen answers the keys; a pane on another tab keeps
// its rail but must not fight for them.
bound: boolean;
/** Raw messages still unloaded before the first mark. A paged conversation
* mounts only its tail, so the rail would otherwise look complete while most
* of the conversation is still on disk. */
earlier?: number;
onLoadEarlier?: () => void;
}
// The native scrollbar owns continuous position. The semantic rail is a compact
@@ -147,13 +152,17 @@ export function railLocalY(clientY: number, rectTop: number, rectHeight: number,
return (clientY - rectTop) * (railHeight / rectHeight);
}
export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props) {
export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier = 0, onLoadEarlier }: Props) {
const host = useRef<HTMLDivElement>(null);
const [tops, setTops] = useState<number[]>([]);
// Null while every question keeps its own line; a list once they have to
// share, because reading them apart has stopped working.
const [groups, setGroups] = useState<RailView[] | null>(null);
const [at, setAt] = useState(-1);
// Which folded run has been opened into its own questions. A run is one line
// standing for many; without this a press can only mean "the first of them",
// which is not the question the reader pointed at.
const [opened, setOpened] = useState<number | null>(null);
// Which mark the keys last walked to. Kept apart from `at`, which the pointer
// owns: hovering somewhere else must not move where the keys resume from.
const here = useRef(-1);
@@ -316,11 +325,24 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props
};
if (marks.length === 0) return null;
const shown = at >= 0 && at < view.length ? view[at] : null;
const shown = opened === null && at >= 0 && at < view.length ? view[at] : null;
const head = shown ? marks[shown.from] : null;
return (
<div className="railhost" ref={host} aria-hidden={undefined}>
{/* A paged conversation mounts only its tail. Without this the rail ends
where the loaded window begins and reads as a short conversation. */}
{earlier > 0 && onLoadEarlier && (
<button
type="button"
className="srail-earlier"
style={{ top: Math.max(6, (view[0]?.top ?? PAD) - 14) }}
aria-label={t("更早还有 {n} 条消息,点击加载", { n: earlier })}
title={t("更早还有 {n} 条消息,点击加载", { n: earlier })}
data-action="history.more"
onClick={onLoadEarlier}
/>
)}
<nav
className="srail"
aria-label={t("你说过的话")}
@@ -362,7 +384,11 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props
title={m.msgIndex === undefined ? undefined : starred ? t("右键取消这处星标") : t("右键给这处加星标")}
onFocus={() => setAt(i)}
onBlur={() => setAt(-1)}
onClick={() => onJump(m)}
onClick={() => {
if (!folded) { onJump(m); return; }
if (opened === i) { setOpened(null); onJump(m); return; }
setOpened(i);
}}
onContextMenu={(e) => {
e.preventDefault();
star(m.msgIndex);
@@ -382,6 +408,32 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props
);
})}
</nav>
{/* A folded run is one line standing for many, so a press on it can only
guess. Opening it first lets the reader pick the question they meant;
every row is one mark, and the run closes on the way out. */}
{opened !== null && view[opened] && !view[opened].single && (
<div
className="srail-sub"
style={{ top: Math.min(Math.max(view[opened].top, 60), Math.max(60, geom.current.rail - 60)) }}
>
<div className="srail-subhd">{t("第 {a} 至 {b} 条", { a: view[opened].from + 1, b: view[opened].to + 1 })}</div>
{Array.from({ length: view[opened].to - view[opened].from + 1 }, (_, k) => {
const mk = marks[view[opened]!.from + k];
if (!mk) return null;
return (
<button
key={mk.id}
type="button"
className="srail-subrow"
data-action="rail.pick"
onClick={() => { setOpened(null); onJump(mk); }}
>
{mk.text.slice(0, 60)}
</button>
);
})}
</div>
)}
{/* 贴着轨道两端的那几条,预览要收进容器里 —— 否则它会被裁掉一半 */}
{shown && (
<div
+4 -28
View File
@@ -8,6 +8,7 @@ import { listenAction } from "./listen";
import { useRuntimeReload } from "./RuntimeReload";
import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry } from "../port/port";
import { useSettingsSearch } from "./useSettingsSearch";
import { useSettingLanding } from "./useSettingLanding";
import { bytes, tokens as fmtTokens } from "../i18n/format";
import { SECTION_NAME } from "./prefsnav";
import { SettingsNav } from "./SettingsNav";
@@ -316,32 +317,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
});
};
useEffect(() => {
if (!landed) return;
const el = root.current?.querySelector<HTMLElement>(`#set-${CSS.escape(landed)}`);
// 折叠起来的块在 DOM 里,但不在屏幕上 —— 滚过去会停在一片空白上。要开哪一
// 个由结构回答:不需要再维护一张「哪些设置是高级」的表,也不需要把落点穿到
// 每个页面里去。
for (let d = el?.closest("details"); d; d = d.parentElement?.closest("details") ?? null) d.open = true;
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
const aim = () => el?.scrollIntoView({ block: "start", behavior });
aim();
// The page is still growing under the scroll: theme packs and the font list
// arrive late and push the anchor below a target already locked in. A jump
// owns its landing until it lapses, so re-aim for as long as it is owed.
const grew = new ResizeObserver(aim);
const col = root.current?.querySelector(".prefs-col");
if (col) grew.observe(col);
// 「就是这里」是一次导航反馈,不是这个设置变了 —— 所以它只是元素上短暂的
// 一个标记,不进 React 状态,也不用往每个块穿一个纯表现的 prop。
el?.setAttribute("data-landed", "");
const done = setTimeout(() => setLanded(""), 900);
return () => {
clearTimeout(done);
grew.disconnect();
el?.removeAttribute("data-landed");
};
}, [landed, at]);
useSettingLanding(root, landed, at, setLanded);
return (
<SettingsLeaveContext.Provider value={navigation.register}><div
@@ -420,9 +396,9 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
</div>
<ConfigTrouble port={port} onRepaired={onChanged} />
<div className="prefs-body">
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => setAt(section)); }} />
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} />
<div className="prefs-main" role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
<Boundary
fallback={
<div className="find" data-lvl="err" role="alert">
+1 -1
View File
@@ -471,7 +471,7 @@ export function Transcript({ items, port, historyPaging, entering, onEntered, br
ref={scroll}
hidden={hidden}
>
<Rail marks={marks} total={items.length} scroll={scroll} flow={flow} onJump={jumpTo} bound={!hidden} scope={scope} />
<Rail marks={marks} total={items.length} scroll={scroll} flow={flow} onJump={jumpTo} bound={!hidden} scope={scope} earlier={historyPaging?.earlier ?? 0} onLoadEarlier={historyPaging?.hasMore ? loadOlder : undefined} />
<div className="flow-edge" aria-hidden="true" />
{/* 空转录是「窗口的空白」,壁纸该在那儿;一有内容它就是内容了。 */}
<div className="flow" ref={flow} data-empty={items.length === 0 && !historyPaging?.hasMore && !historyPaging?.busy && !historyPaging?.failed ? "" : undefined}>
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Wallpaper } from "../port/look";
import { WallpaperScene } from "./WallpaperScene";
const scene: Wallpaper = {
url: "/bg.jpg",
kind: "scene",
title: "花海",
audio: "/scene.mp3",
parallax: 0.04,
muted: false,
volume: 0.5,
opacity: 0.5,
dim: 0.55,
focusX: 0.5,
focusY: 0.5,
};
afterEach(() => {
cleanup();
vi.restoreAllMocks();
document.documentElement.style.removeProperty("--bg-x");
document.documentElement.style.removeProperty("--bg-y");
});
describe("a Wallpaper Engine backdrop", () => {
it("renders a looping soundtrack and starts it", async () => {
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
render(<WallpaperScene wallpaper={scene} />);
const audio = document.querySelector("audio");
expect(audio?.getAttribute("src")).toBe("/scene.mp3");
expect(audio?.loop).toBe(true);
await waitFor(() => expect(audio?.volume).toBeCloseTo(0.5));
await waitFor(() => expect(play).toHaveBeenCalled());
});
it("does not start at zero volume", () => {
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
render(<WallpaperScene wallpaper={{ ...scene, volume: 0 }} />);
expect(play).not.toHaveBeenCalled();
});
it("moves the backdrop with the pointer", () => {
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
render(<WallpaperScene wallpaper={scene} />);
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 100, clientY: 100 }));
expect(document.documentElement.style.getPropertyValue("--bg-x")).not.toBe("50%");
expect(document.documentElement.style.getPropertyValue("--bg-y")).not.toBe("50%");
});
it("does not install the soundtrack when muted", () => {
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
render(<WallpaperScene wallpaper={{ ...scene, muted: true }} />);
const audio = document.querySelector("audio");
expect(audio?.muted).toBe(true);
expect(play).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,99 @@
import { createPortal } from "react-dom";
import { useEffect, useRef } from "react";
import type { Wallpaper } from "../port/look";
import { listenAction } from "./listen";
/** The part of a Wallpaper Engine scene this window can replay: the extracted
* backdrop moves a little with the pointer, and its soundtracks loop behind
* the interface. Shaders and particles stay in Wallpaper Engine. */
export function WallpaperScene({ wallpaper }: { wallpaper?: Wallpaper }) {
const audio = useRef<(HTMLAudioElement | null)[]>([]);
const sources = audioSources(wallpaper);
useEffect(() => {
const paper = wallpaper;
if (!paper || paper.kind !== "scene" || !paper.parallax) return;
const root = document.documentElement;
const baseX = clampPct(paper.focusX * 100);
const baseY = clampPct(paper.focusY * 100);
const shift = Math.min(0.15, Math.max(0, paper.parallax)) * 100;
const reduced = () =>
root.dataset.effects === "reduced" ||
(typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
const move = (event: PointerEvent) => {
if (reduced()) return;
const dx = (event.clientX / Math.max(innerWidth, 1) - 0.5) * shift;
const dy = (event.clientY / Math.max(innerHeight, 1) - 0.5) * shift;
root.style.setProperty("--bg-x", `${clampPct(baseX - dx)}%`);
root.style.setProperty("--bg-y", `${clampPct(baseY - dy)}%`);
};
const off = listenAction(window, "pointermove", { action: "wallpaper.parallax", listener: move as EventListener }, { passive: true });
return () => {
off();
root.style.setProperty("--bg-x", `${baseX}%`);
root.style.setProperty("--bg-y", `${baseY}%`);
};
}, [wallpaper?.kind, wallpaper?.parallax, wallpaper?.focusX, wallpaper?.focusY]);
useEffect(() => {
for (const element of audio.current) {
if (element) element.volume = clampLevel(wallpaper?.volume ?? 1);
}
}, [wallpaper?.volume, sources.length]);
useEffect(() => {
if (!sources.length || wallpaper?.kind !== "scene" || wallpaper.muted || (wallpaper.volume ?? 1) <= 0) return;
const start = () => {
for (const element of audio.current) {
if (!element) continue;
try {
void Promise.resolve(element.play()).catch(() => {});
} catch {
// Autoplay is refused until a gesture; the listeners below retry.
}
}
};
start();
const offPointer = listenAction(window, "pointerdown", { action: "wallpaper.sound", listener: start as EventListener }, { once: true });
const offKey = listenAction(window, "keydown", { action: "wallpaper.sound", listener: start as EventListener }, { once: true });
return () => {
offPointer();
offKey();
for (const element of audio.current) element?.pause();
};
}, [sources.join("\u0000"), wallpaper?.muted, wallpaper?.kind, wallpaper?.volume]);
if (!sources.length) return null;
return createPortal(
<>
{sources.map((src, i) => (
<audio
key={`${i}:${src}`}
ref={(element) => {
audio.current[i] = element;
}}
src={src}
loop
muted={wallpaper?.muted}
preload="auto"
hidden
/>
))}
</>,
document.body,
);
}
function audioSources(wallpaper?: Wallpaper): string[] {
if (wallpaper?.kind !== "scene") return [];
if (wallpaper.audios?.length) return wallpaper.audios;
return wallpaper.audio ? [wallpaper.audio] : [];
}
function clampPct(value: number): number {
return Math.min(100, Math.max(0, value));
}
function clampLevel(value: number): number {
return Math.min(1, Math.max(0, value));
}
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { t } from "../i18n";
import type { AgentPort, Appearance } from "../port/port";
import type { SceneFolder } from "../port/look";
import { WallpaperSceneImport } from "./WallpaperSceneImport";
afterEach(cleanup);
const sceneLook: Appearance = {
wallpaper: {
url: "/bg.jpg",
kind: "scene",
title: "花海",
audio: "/scene.mp3",
parallax: 0.02,
muted: false,
volume: 0.5,
opacity: 0.5,
dim: 0.55,
focusX: 0.5,
focusY: 0.5,
},
};
function files() {
return [
new File(['{"title":"花海"}'], "project.json", { type: "application/json" }),
new File([new Uint8Array([1, 2, 3])], "scene.pkg"),
new File([new Uint8Array([4])], "preview.jpg", { type: "image/jpeg" }),
];
}
describe("importing a Wallpaper Engine folder", () => {
it("sends project.json, scene.pkg and the preview together", async () => {
const uploadScene = vi.fn(async (_folder: SceneFolder) => sceneLook);
const onLook = vi.fn();
const onError = vi.fn();
const { container } = render(
<WallpaperSceneImport
port={{ uploadScene } as unknown as AgentPort}
look={{}}
onLook={onLook}
setPaper={() => {}}
onError={onError}
/>,
);
const input = container.querySelector('input[data-action="wallpaper.scene"]') as HTMLInputElement;
const [project, scene, preview] = files();
fireEvent.change(input, { target: { files: [project, scene, preview] } });
await waitFor(() => expect(uploadScene).toHaveBeenCalledTimes(1));
expect(uploadScene.mock.calls[0][0]).toMatchObject({ project, scene, preview });
expect(onLook).toHaveBeenCalledWith(sceneLook);
expect(onError).toHaveBeenCalledWith("");
});
it("says what is missing instead of uploading half a folder", async () => {
const uploadScene = vi.fn();
const onError = vi.fn();
const { container } = render(
<WallpaperSceneImport
port={{ uploadScene } as unknown as AgentPort}
look={{}}
onLook={() => {}}
setPaper={() => {}}
onError={onError}
/>,
);
const input = container.querySelector('input[data-action="wallpaper.scene"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File([new Uint8Array([1])], "scene.pkg")] } });
expect(uploadScene).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(t("文件夹里需要 project.json 和 scene.pkg"));
});
it("mutes a scene's soundtrack", () => {
const setPaper = vi.fn();
render(
<WallpaperSceneImport
port={{ uploadScene: vi.fn() } as unknown as AgentPort}
look={sceneLook}
onLook={() => {}}
setPaper={setPaper}
onError={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: t("关闭声音") }));
expect(setPaper).toHaveBeenCalledWith({ muted: true });
});
});
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentPort, Appearance } from "../port/port";
import type { Wallpaper } from "../port/look";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
interface Props {
port: AgentPort;
look: Appearance;
onLook: (look: Appearance) => void;
setPaper: (patch: Partial<Wallpaper>) => void;
onError: (message: string) => void;
}
/** Picks a Wallpaper Engine folder and shows what was imported. Kept out of
* Appearance so that panel can stay about the picture it already knows. */
export function WallpaperSceneImport({ port, look, onLook, setPaper, onError }: Props) {
const folder = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
const input = folder.current;
if (!input) return;
input.setAttribute("webkitdirectory", "");
input.setAttribute("directory", "");
}, []);
const pick = useCallback(
async (chosen: FileList | null) => {
const files = Array.from(chosen ?? []);
const project = files.find((f) => f.name.toLowerCase() === "project.json");
const scene = files.find((f) => f.name.toLowerCase() === "scene.pkg");
if (!project || !scene) {
onError(t("文件夹里需要 project.json 和 scene.pkg"));
return;
}
const preview = files.find((f) => /^preview\.(jpe?g|png|webp|avif)$/i.test(f.name));
setBusy(true);
onError("");
try {
onLook(await port.uploadScene({ project, scene, preview }));
} catch (e) {
onError(reason(e));
} finally {
setBusy(false);
}
},
[port, onLook, onError],
);
const paper = look.wallpaper;
return (
<>
<input
ref={folder}
data-action="wallpaper.scene"
type="file"
multiple
hidden
onChange={(e) => {
void pick(e.target.files);
e.target.value = "";
}}
/>
<button className="paperpick" data-action="wallpaper.scene" data-busy={busy ? "" : undefined} onClick={() => folder.current?.click()}>
<span className="plus" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M8 3.7v8.6M3.7 8h8.6" />
</svg>
</span>
{t("导入 Wallpaper Engine…")}
</button>
{paper?.kind === "scene" && (
<>
<div className="prow">
<span className="tx">{t("Wallpaper Engine 场景")}</span>
<span className="now">{paper.title || t("未命名场景")}</span>
{(paper.audios?.length || paper.audio) && (
<button className="now nowbtn" data-action="wallpaper.sound" onClick={() => setPaper({ muted: !paper.muted })}>
{paper.muted ? t("打开声音") : t("关闭声音")}
</button>
)}
</div>
<p className="note">{t("场景以主图和声音播放;粒子和着色器效果暂未再现。")}</p>
</>
)}
</>
);
}
@@ -0,0 +1,16 @@
import type { Appearance } from "../port/port";
import { Backdrop } from "./Backdrop";
import { WallpaperScene } from "./WallpaperScene";
import { WallpaperVolume } from "./WallpaperVolume";
/** Everything behind the window: the pack's sky, a picture or video
* wallpaper, and a Wallpaper Engine scene's sound and controls. */
export function WallpaperStage({ sky, look, onLook }: { sky: boolean; look: Appearance; onLook: (look: Appearance) => void }) {
return (
<>
<Backdrop sky={sky} wallpaper={look.wallpaper} />
<WallpaperScene wallpaper={look.wallpaper} />
<WallpaperVolume look={look} onLook={onLook} />
</>
);
}
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { t } from "../i18n";
import type { Appearance } from "../port/port";
import type { Wallpaper } from "../port/look";
import { WallpaperVolume } from "./WallpaperVolume";
afterEach(cleanup);
function unit(over: Partial<Wallpaper> = {}): Wallpaper {
return {
url: "/bg.jpg",
kind: "scene",
title: "花海",
audio: "/scene.mp3",
parallax: 0.02,
muted: false,
volume: 0.6,
opacity: 0.5,
dim: 0.55,
focusX: 0.5,
focusY: 0.5,
...over,
};
}
function look(paper: Wallpaper): Appearance {
return { wallpaper: paper };
}
describe("the scene's main-window volume control", () => {
it("lives outside the app layout", () => {
const { container } = render(<WallpaperVolume look={look(unit())} onLook={() => {}} />);
expect(container.querySelector(".wvol")).toBeNull();
expect(document.querySelector(".wvol")).not.toBeNull();
});
it("mutes from the icon without losing the level", () => {
const onLook = vi.fn((_next: Appearance) => {});
render(<WallpaperVolume look={look(unit())} onLook={onLook} />);
fireEvent.click(screen.getByRole("button", { name: t("关闭声音") }));
expect(onLook.mock.calls[0][0].wallpaper).toMatchObject({ muted: true, volume: 0.6 });
});
it("restores a level when unmuting from silence", () => {
const onLook = vi.fn((_next: Appearance) => {});
render(<WallpaperVolume look={look(unit({ muted: true, volume: 0 }))} onLook={onLook} />);
fireEvent.click(screen.getByRole("button", { name: t("打开声音") }));
expect(onLook.mock.calls[0][0].wallpaper).toMatchObject({ muted: false, volume: 1 });
});
it("sets the level from the vertical slider", () => {
const onLook = vi.fn((_next: Appearance) => {});
render(<WallpaperVolume look={look(unit())} onLook={onLook} />);
fireEvent.change(screen.getByRole("slider", { name: t("音量") }), { target: { value: "0.25" } });
expect(onLook.mock.calls[0][0].wallpaper).toMatchObject({ volume: 0.25, muted: false });
});
});
@@ -0,0 +1,78 @@
import { createPortal } from "react-dom";
import type { Appearance } from "../port/port";
import type { Wallpaper } from "../port/look";
import { t } from "../i18n";
interface Props {
look: Appearance;
onLook: (look: Appearance) => void;
}
/** A scene's soundtrack control: the icon is the mute switch, and the vertical
* level appears while the pointer or keyboard focus is on it. */
export function WallpaperVolume({ look, onLook }: Props) {
const paper = look.wallpaper;
if (!paper || paper.kind !== "scene" || !hasAudio(paper)) return null;
const volume = clamp(paper.volume ?? 1);
const silent = paper.muted || volume <= 0;
const update = (patch: Partial<Wallpaper>) => onLook({ ...look, wallpaper: { ...paper, ...patch } });
return createPortal(
<div
className="wvol"
data-silent={silent ? "" : undefined}
style={{ position: "fixed", right: 8, bottom: 8, zIndex: 25, display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}
>
<div className="wvol-pop">
<input
className="wvol-range"
type="range"
min={0}
max={1}
step={0.05}
value={volume}
aria-label={t("音量")}
data-action="wallpaper.volume"
style={{ writingMode: "vertical-lr", direction: "rtl", width: 18, height: 96, margin: "4px 0", accentColor: "var(--accent)", cursor: "pointer" }}
onChange={(e) => {
const next = Number(e.target.value);
update({ volume: next, muted: next <= 0 });
}}
/>
</div>
<button
type="button"
className="wvol-btn"
data-action="wallpaper.sound"
aria-label={silent ? t("打开声音") : t("关闭声音")}
title={silent ? t("打开声音") : t("关闭声音")}
style={{ width: 34, height: 34, display: "grid", placeItems: "center", padding: 0, border: "1px solid var(--hair)", borderRadius: "50%", background: "color-mix(in srgb, var(--raised) 84%, transparent)", color: "var(--text)", cursor: "pointer", backdropFilter: "blur(12px)" }}
onClick={() => {
if (silent) update({ muted: false, volume: volume > 0 ? volume : 1 });
else update({ muted: true });
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 9h4l5-4v14l-5-4H4z" />
{silent ? (
<path d="M17 9l5 6M22 9l-5 6" />
) : (
<>
<path d="M16 8a5 5 0 0 1 0 8" />
<path d="M18.5 5.5a9 9 0 0 1 0 13" />
</>
)}
</svg>
</button>
</div>,
document.body,
);
}
function hasAudio(paper: Wallpaper): boolean {
return Boolean(paper.audios?.length || paper.audio);
}
function clamp(value: number): number {
return Math.min(1, Math.max(0, value));
}
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
afterEach(cleanup);
describe("the scene volume slider", () => {
it("uses the same filled track as the focus sliders", () => {
const { container } = render(<WallpaperVolumeSlider volume={0.6} onChange={() => {}} />);
const slider = screen.getByRole("slider", { name: /音量/ }) as HTMLInputElement;
expect(slider.style.getPropertyValue("--at")).toBe("60%");
expect(container.textContent).toContain("60%");
});
it("writes the level and treats zero as silent", () => {
const onChange = vi.fn();
render(<WallpaperVolumeSlider volume={0.6} onChange={onChange} />);
const slider = screen.getByRole("slider", { name: /音量/ });
fireEvent.change(slider, { target: { value: "0.25" } });
expect(onChange).toHaveBeenCalledWith({ volume: 0.25, muted: false });
fireEvent.change(slider, { target: { value: "0" } });
expect(onChange).toHaveBeenLastCalledWith({ volume: 0, muted: true });
});
});
@@ -0,0 +1,30 @@
import type { CSSProperties } from "react";
import type { Wallpaper } from "../port/look";
import { t } from "../i18n";
import { pct } from "../i18n/format";
/** The scene's soundtrack level, drawn with the same slider as the focus
* controls beside it. */
export function WallpaperVolumeSlider({ volume, onChange }: { volume?: number; onChange: (patch: Partial<Wallpaper>) => void }) {
const value = Math.min(1, Math.max(0, volume ?? 1));
return (
<label className="prow">
<span className="tx">{t("音量")}</span>
<input
className="slider"
style={{ "--at": `${Math.round(value * 100)}%` } as CSSProperties}
type="range"
min={0}
max={1}
step={0.05}
value={value}
data-action="wallpaper.volume"
onChange={(e) => {
const next = Number(e.target.value);
onChange({ volume: next, muted: next <= 0 });
}}
/>
<span className="now">{pct(value)}</span>
</label>
);
}
@@ -1,66 +1,102 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SayCard } from "./SayCard";
import { FOLD_CHOICES, FOLD_DEFAULTS, setFoldModes } from "../../state/prefs";
import { firstSentence } from "../thoughtPeek";
import { Folding } from "../Folding";
import { FOLD_DEFAULTS, setFoldModes, setShowsThinkingPreview } from "../../state/prefs";
afterEach(() => {
cleanup();
setFoldModes(FOLD_DEFAULTS);
setShowsThinkingPreview(false);
});
const item = { t: "say" as const, id: "s", text: "answer", reasoning: "先检查配置文件。然后再看日志。", done: true };
const peek = (el: HTMLElement) => el.querySelector(".peek");
const peek = (el: HTMLElement) => el.querySelector(".thinking-preview");
describe("firstSentence", () => {
it("takes the first sentence of Chinese and Latin text", () => {
expect(firstSentence("先检查配置文件。然后再看日志。", 80)).toBe("先检查配置文件。");
expect(firstSentence("Check the config first. Then the logs.", 80)).toBe("Check the config first.");
});
it("cuts a long first sentence at the limit with an ellipsis", () => {
const out = firstSentence("a".repeat(200), 20);
expect([...out]).toHaveLength(21);
expect(out.endsWith("…")).toBe(true);
});
it("ignores leading blank lines and whitespace", () => {
expect(firstSentence("\n\n hello there. More", 80)).toBe("hello there.");
});
it("is empty for blank reasoning", () => {
expect(firstSentence(" \n ", 80)).toBe("");
});
});
describe("the thinking peek", () => {
it("is a choice for thinking only, and not the default", () => {
expect(FOLD_CHOICES.thinking).toContain("peek");
expect(FOLD_CHOICES.activity).not.toContain("peek");
expect(FOLD_DEFAULTS.thinking).not.toBe("peek");
});
it("shows nothing beside a folded block under the default", () => {
expect(peek(render(<SayCard item={item} />).container)).toBeNull();
});
it("shows the first sentence beside a folded block and hides it once opened", async () => {
setFoldModes({ thinking: "peek" });
describe("collapsed thinking previews", () => {
it("shows the opening text only when enabled and collapsed", async () => {
const { container } = render(<SayCard item={item} />);
expect(peek(container)?.textContent).toBe("先检查配置文件。");
expect((container.querySelector("details") as HTMLDetailsElement).open).toBe(false);
await userEvent.click(container.querySelector("summary") as HTMLElement);
expect(peek(container)).toBeNull();
act(() => setShowsThinkingPreview(true));
expect(peek(container)?.textContent).toBe(item.reasoning);
await userEvent.click(container.querySelector("summary")!);
expect(peek(container)).toBeNull();
await userEvent.click(container.querySelector("summary")!);
expect(peek(container)?.textContent).toBe(item.reasoning);
act(() => setShowsThinkingPreview(false));
expect(peek(container)).toBeNull();
});
it("follows the setting for a block already on screen", () => {
const { container } = render(<SayCard item={item} />);
expect(peek(container)).toBeNull();
act(() => setFoldModes({ thinking: "peek" }));
expect(peek(container)).not.toBeNull();
it("retains the opening as more reasoning streams, then survives completion", () => {
setShowsThinkingPreview(true);
const { container, rerender } = render(<SayCard item={{ ...item, done: false, text: "", reasoning: " First step." }} />);
expect(peek(container)?.textContent).toBe("First step.");
const reasoning = ` First step.\n${"More reasoning. ".repeat(30)}`;
rerender(<SayCard item={{ ...item, done: false, text: "", reasoning }} />);
expect(peek(container)?.textContent).toBe(reasoning.trimStart());
rerender(<SayCard item={{ ...item, reasoning }} />);
expect(peek(container)?.textContent).toBe(reasoning.trimStart());
});
it("shows no peek while the thought is still streaming", () => {
setFoldModes({ thinking: "peek" });
expect(peek(render(<SayCard item={{ ...item, done: false, text: "" }} />).container)).toBeNull();
it("opens while thinking, then previews when the answer starts, unless manually overridden", async () => {
setFoldModes({ thinking: "live" });
setShowsThinkingPreview(true);
const { container, rerender } = render(<SayCard item={{ ...item, done: false, text: "" }} />);
const details = () => container.querySelector("details")!;
expect(details().open).toBe(true);
expect(peek(container)).toBeNull();
rerender(<SayCard item={{ ...item, done: false }} />);
expect(details().open).toBe(false);
expect(peek(container)?.textContent).toBe(item.reasoning);
await userEvent.click(container.querySelector("summary")!);
rerender(<SayCard item={item} />);
expect(details().open).toBe(true);
expect(peek(container)).toBeNull();
act(() => setFoldModes({ thinking: "folded" }));
expect(details().open).toBe(true);
});
it("lets a manually collapsed block preview even when the default is expanded", async () => {
setFoldModes({ thinking: "open" });
setShowsThinkingPreview(true);
const { container } = render(<SayCard item={item} />);
expect(peek(container)).toBeNull();
await userEvent.click(container.querySelector("summary")!);
expect(peek(container)?.textContent).toBe(item.reasoning);
act(() => setFoldModes({ thinking: "live" }));
expect(peek(container)?.textContent).toBe(item.reasoning);
});
it("does not render a disclosure or preview for blank reasoning", () => {
setShowsThinkingPreview(true);
const { container } = render(<SayCard item={{ ...item, reasoning: " \n " }} />);
expect(container.querySelector("details")).toBeNull();
expect(peek(container)).toBeNull();
});
});
describe("the independent preview setting", () => {
it("changes the transcript and demonstration without presets changing the preview preference", async () => {
const settings = render(<Folding />);
const card = render(<SayCard item={item} />);
const sw = screen.getByRole("switch");
expect(sw.getAttribute("aria-checked")).toBe("false");
await userEvent.click(sw);
expect(peek(card.container)?.textContent).toBe(item.reasoning);
expect(settings.container.querySelector('.fp-block[data-part="thinking"] .thinking-preview')).not.toBeNull();
const choices = settings.container.querySelector('[role="radiogroup"][aria-label]')!;
const presets = within(choices as HTMLElement).getAllByRole("radio");
await userEvent.click(presets[3]);
expect(sw.getAttribute("aria-checked")).toBe("true");
expect(card.container.querySelector("details")!.open).toBe(true);
expect(peek(card.container)).toBeNull();
await userEvent.click(presets[1]);
expect(sw.getAttribute("aria-checked")).toBe("true");
expect(peek(card.container)?.textContent).toBe(item.reasoning);
await userEvent.click(sw);
expect(peek(card.container)).toBeNull();
expect(settings.container.querySelector(".thinking-preview")).toBeNull();
});
});
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useFoldModes, useStartsOpen } from "../../state/foldpref";
import { firstSentence } from "../thoughtPeek";
import { useThinkingPreview, useStartsOpen } from "../../state/foldpref";
import { StudioIcon } from "../StudioIcon";
import { t } from "../../i18n";
import { count, decimals } from "../../i18n/format";
@@ -31,7 +30,6 @@ function ThoughtLabel({ item }: { item: Extract<Item, { t: "say" }> }) {
}
const LIVE_TICK_MS = 100;
const PEEK_CHARS = 80;
// While the model is still thinking the reader sees the clock run, not a bare
// ellipsis; a card this window did not watch begin has no start to count from.
@@ -107,8 +105,8 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
const start = useStartsOpen("thinking", !item.done && !item.text);
const [touched, setOpen] = useState<boolean | null>(null);
const open = touched ?? start;
const peeks = useFoldModes().thinking === "peek" && !open && item.done;
const peek = useMemo(() => (peeks ? firstSentence(item.reasoning ?? "", PEEK_CHARS) : ""), [peeks, item.reasoning]);
const peeks = useThinkingPreview() && !open;
const peek = peeks ? item.reasoning?.trimStart() : "";
const [menu, setMenu] = useState<"" | "retry" | "more">("");
const shut = useCallback(() => setMenu(""), []);
const retry = useRef<HTMLButtonElement>(null);
@@ -155,7 +153,7 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
<details className="think" open={open} onToggle={(e) => e.currentTarget.open !== open && setOpen(e.currentTarget.open)}>
<summary>
<span className="fold">{item.done || item.thoughtMs !== undefined ? <ThoughtLabel item={item} /> : <LiveThought id={item.id} />}</span>
{peek && <span className="peek">{peek}</span>}
{peek && <span className="thinking-preview">{peek}</span>}
</summary>
<div className="tk">
{thought}
@@ -67,7 +67,7 @@ it("gathers the controls under one button below the bar fold, keeps them in the
expect(document.documentElement.dataset.fold).toContain("bar");
const button = more()!;
expect(button.getAttribute("aria-expanded")).toBe("false");
for (const sel of [".find-action", ".theme-toggle", ".top-action"]) {
for (const sel of [".find-action", ".theme-toggle", ".view-action"]) {
expect(document.querySelector(`.chrome .r-fold ${sel}`), sel).not.toBeNull();
}
await userEvent.click(button);
@@ -28,7 +28,6 @@ async function draw(shell: Record<string, unknown>, host?: string) {
status={{ preset: "balanced", toolApprovalMode: "ask", workspaceRoot: ROOT, sessionPath: ROOT + "/s.jsonl" } as never}
title="会话"
steer={0}
onSettings={vi.fn()}
account={null}
theme="dark"
onTheme={vi.fn()}
+5 -2
View File
@@ -103,12 +103,15 @@ function carriesFiles(e: DragEvent): boolean {
return types.includes(PATH_TYPE) || types.includes("Files") || types.includes("text/uri-list") || types.includes("text/plain");
}
// The innermost zone holding the target: a wide zone must not shadow a narrow
// one inside it, whichever mounted first.
function zoneAt(target: EventTarget | null): Zone | null {
if (!(target instanceof Node)) return null;
let found: Zone | null = null;
for (const zone of zones) {
if (zone.el.contains(target)) return zone;
if (zone.el.contains(target) && (!found || found.el.contains(zone.el))) found = zone;
}
return null;
return found;
}
function light(next: Zone | null, dt: DataTransfer | null = null) {
@@ -27,7 +27,7 @@ function fixture(contents = false) {
const generation = { current: 0 };
const release = vi.fn();
const land = vi.fn();
const paging: HistoryPaging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, more: vi.fn(async () => {}), reload: vi.fn(async () => {}) };
const paging: HistoryPaging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, earlier: 0, more: vi.fn(async () => {}), reload: vi.fn(async () => {}) };
const hook = renderHook(({ revision, snapshot = "one", hidden = false }) => useHistoryAnchor({ ...paging, revision, snapshot }, scroll, [[item]], hidden, generation, release, land),
{ initialProps: { revision: 1, snapshot: "one", hidden: false } });
return { ...hook, generation, land, paging };
+3 -3
View File
@@ -79,9 +79,9 @@ export function apply(look: Appearance | null, busy = false) {
if (look?.textSize && look.textSize !== 1) style.setProperty("--fs-scale", String(look.textSize));
else style.removeProperty("--fs-scale");
// Reading size moves the transcript's prose alone, so the frame around it
// stays where the layout put it.
style.setProperty("--read", `${readSizeOf(look?.readSize)}px`);
const readSize = readSizeOf(look?.readSize);
style.setProperty("--read", `${readSize}px`);
style.setProperty("--read-scale", String(readSize / readDefault()));
// How far that prose runs is the other half of the same question, and the
// only one with two honest answers: a line short enough to read without
@@ -59,13 +59,19 @@ const app = () => document.querySelector<HTMLElement>(".app")!;
const toggleSidebar = () => userEvent.click(document.querySelector<HTMLElement>(".gutter-l .grip")!);
async function openAppearance() {
await userEvent.click(screen.getByRole("button", { name: "沙盒" }));
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
await screen.findByRole("group", { name: "图标栏" });
}
const choice = (name: string) => screen.getByRole("button", { name });
const pressed = (name: string) => choice(name).getAttribute("aria-pressed");
it("offers the tools destination only through the navigation entries, not a second button in the top bar", async () => {
await open();
expect(screen.queryByRole("button", { name: "沙盒" })).toBeNull();
expect(within(screen.getByRole("navigation", { name: "主导航" })).getByRole("button", { name: "工具与权限" })).toBeTruthy();
});
it("always draws the rail by default, with every entry in it", async () => {
await open();
expect(nav()).not.toBeNull();
@@ -47,7 +47,7 @@ async function mount(running = false, pathless = false) {
const [report, setReport] = useState<PaneReport | null>(null);
const onReport = useCallback((_: string, next: PaneReport) => setReport(next), []);
return <>
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} onSettings={() => {}} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath }}
title="active" active visible sideHost={null} side={false} onFocus={() => {}} onReport={onReport}
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { useState } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, expect, it, vi } from "vitest";
import "./testkit";
import { MockPort } from "../port/mock";
import { Composer } from "./Composer";
import { install, useFileDrop } from "./filedrop";
import { PATH_TYPE } from "./filedrag";
beforeAll(install);
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
function transfer(path: string) {
const data = new Map<string, string>([[PATH_TYPE, path]]);
return {
files: [], items: [], effectAllowed: "copy", dropEffect: "none",
get types() { return [...data.keys()]; },
setData: (type: string, value: string) => data.set(type, value),
getData: (type: string) => data.get(type) ?? "",
};
}
function Pane({ port }: { port: MockPort }) {
return (
<section className="pane">
<div data-testid="transcript">earlier answers</div>
<Composer port={port} status={null} running={false} onSubmit={vi.fn(async () => true)} onChanged={vi.fn()} onError={vi.fn()} />
</section>
);
}
it("takes a file dropped anywhere on the pane, not only on the composer", async () => {
const port = new MockPort();
const dropRefs = vi.spyOn(port, "dropRefs").mockImplementation(async (paths) => paths.map((path) => ({ path: "README.md", ref: `@README.md`, image: path.endsWith(".png") })));
render(<Pane port={port} />);
const transcript = screen.getByTestId("transcript");
const dataTransfer = transfer("/workspace/README.md");
fireEvent.dragOver(transcript, { dataTransfer });
expect(screen.getByText("松手 → 引用 1 个文件")).toBeTruthy();
fireEvent.drop(transcript, { dataTransfer });
await screen.findByRole("button", { name: "移除 README.md" });
expect(dropRefs).toHaveBeenCalledWith(["/workspace/README.md"]);
});
it("leaves a drop on a nested zone to that zone even when a wider one mounted first", async () => {
const outer = vi.fn();
const inner = vi.fn();
function Nest() {
const [show, setShow] = useState(false);
const wide = useFileDrop(outer, () => {});
const narrow = useFileDrop(inner, () => {});
return (
<div data-testid="wide" ref={wide}>
<button onClick={() => setShow(true)}>open</button>
{show && <div data-testid="narrow" ref={narrow} />}
</div>
);
}
render(<Nest />);
fireEvent.click(screen.getByText("open"));
const dataTransfer = transfer("/workspace/a.txt");
act(() => { fireEvent.drop(screen.getByTestId("narrow"), { dataTransfer }); });
await waitFor(() => expect(inner).toHaveBeenCalledTimes(1));
expect(outer).not.toHaveBeenCalled();
});
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import type { Column } from "./FlowResizer";
import type { AgentPort, ContextBreakdown, McpEntry, SessionStatus } from "../port/port";
import type { HubPort, RuntimeView } from "../port/hub";
import type { ConnectOffer } from "./ConnectNote";
@@ -75,10 +74,6 @@ export interface PaneProps {
dockW: number;
dockMax: number;
onDockW: (w: number) => void;
// The reading column's width, the two ends it is held between, and what the
// sidebars are drawn at once it asks. Absent where nothing can drag it: a pane
// rendered on its own keeps the width its stylesheet gives it.
column?: Column;
manualBrowser?: boolean;
onManualBrowser?: (on: boolean) => void;
// The window's failed-request notice, handed only to the pane in front.
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "./testkit";
import { App } from "./App";
import { MockHub } from "../port/mock_hub";
import { boot, STORAGE } from "../i18n";
import { refresh } from "./viewport";
beforeEach(async () => {
await import("./Settings");
sessionStorage.clear();
localStorage.clear();
localStorage.setItem(STORAGE, "zh");
boot();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(1600);
vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900);
refresh();
});
afterEach(() => {
cleanup();
sessionStorage.clear();
localStorage.clear();
vi.restoreAllMocks();
});
async function open() {
const hub = new MockHub();
const build = hub.portFor.bind(hub);
const prepared = new WeakSet();
hub.portFor = (rt) => {
const port = build(rt);
if (!prepared.has(port)) {
prepared.add(port);
port.providerSetup = async () => null;
port.welcomeSeen = async () => true;
}
return port;
};
render(<App hub={hub} />);
await screen.findByRole("combobox", { name: "任务输入" });
}
it("draws no handles on the reading column and ignores a width an older build remembered", async () => {
localStorage.setItem("rx-flow-w", "900");
await open();
expect(document.querySelector(".flowgrip")).toBeNull();
expect(document.querySelector<HTMLElement>(".app")!.style.getPropertyValue("--flow-w")).toBe("");
});
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Settings } from "./Settings";
import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); });
function draw(at: string, port = new MockPort()) {
render(<Settings
hub={new MockHub()} port={port} status={null}
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}}
weight="" onWeight={() => {}} look={{}} onLook={() => {}}
reloadThemes={() => {}} onClose={() => {}} onChanged={() => {}}
onError={() => {}} at={at} account={null} accountUnread="" reloadAccount={() => {}}
/>);
}
it.each([
["appearance", "版本"],
["tools", "会话"],
])("starts the next category at the top after scrolling %s", async (from, to) => {
draw(from);
const panel = screen.getByRole("tabpanel");
panel.scrollTop = 1800;
panel.scrollLeft = 120;
await userEvent.click(screen.getByRole("tab", { name: new RegExp(`^${to}(?!间消息)`) }));
expect(screen.getByRole("heading", { name: to, level: 2 })).toBeTruthy();
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
expect(screen.getByRole("tabpanel").scrollLeft).toBe(0);
});
it("starts a keyboard-selected category at the top and keeps focus on its tab", async () => {
draw("tools");
const current = screen.getByRole("tab", { name: /^工具与权限/ });
current.focus();
screen.getByRole("tabpanel").scrollTop = 1600;
await userEvent.keyboard("{ArrowDown}");
const next = screen.getByRole("tab", { name: "会话间消息" });
expect(next.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(next);
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
});
it("keeps the current category's scroll and unsaved input when it is selected again", async () => {
draw("persona:persona");
const prefix = await screen.findByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" });
await waitFor(() => expect(prefix.disabled).toBe(false));
await userEvent.type(prefix, "Unsaved draft");
const panel = screen.getByRole("tabpanel");
panel.scrollTop = 650;
await userEvent.click(screen.getByRole("tab", { name: "人设" }));
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "字体");
expect(screen.getByRole("tabpanel").scrollTop).toBe(650);
expect(screen.getByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" }).value).toBe("Unsaved draft");
});
function anchorScrolling() {
return vi.spyOn(Element.prototype, "scrollIntoView").mockImplementation(function (this: Element) {
const panel = this.closest<HTMLElement>('[role="tabpanel"]');
if (panel) panel.scrollTop = 420;
});
}
it("keeps a search result's anchor after switching away from a scrolled category", async () => {
const scroll = anchorScrolling();
draw("session");
screen.getByRole("tabpanel").scrollTop = 1500;
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "柔和");
await userEvent.click(await screen.findByRole("option", { name: /文字对比度/ }));
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
expect(scroll.mock.contexts).toContain(target);
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
});
it("opens an initial anchor inside its disclosure", async () => {
const scroll = anchorScrolling();
draw("appearance:contrast");
await screen.findByRole("heading", { name: "外观", level: 2 });
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
expect(scroll.mock.contexts).toContain(target);
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
});
it("waits for a delayed setting before starting the landing feedback lifetime", async () => {
const port = new MockPort();
const saved = await port.observabilitySettings();
let finish!: (value: typeof saved) => void;
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.useFakeTimers();
const scroll = anchorScrolling();
draw("network:trace-export", port);
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
expect(document.querySelector('[data-setting="trace-export"]')).toBeNull();
await act(async () => { finish(saved); });
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
expect(target.getAttribute("data-landed")).toBe("");
expect(scroll.mock.contexts).toContain(target);
});
it("lands on a searched setting that arrives after navigation", async () => {
const port = new MockPort();
const saved = await port.observabilitySettings();
let finish!: (value: typeof saved) => void;
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
const scroll = anchorScrolling();
draw("session", port);
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "追踪导出");
await userEvent.click(await screen.findByRole("option", { name: /追踪导出/ }));
await act(async () => { finish(saved); });
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
expect(target.getAttribute("data-landed")).toBe("");
expect(scroll.mock.contexts).toContain(target);
});
it("cancels a pending landing when the user chooses another category", async () => {
const port = new MockPort();
const saved = await port.observabilitySettings();
let finish!: (value: typeof saved) => void;
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
draw("network:trace-export", port);
await userEvent.click(screen.getByRole("tab", { name: /^版本/ }));
await act(async () => { finish(saved); });
await userEvent.click(screen.getByRole("tab", { name: /^网络/ }));
const target = await waitFor(() => {
const found = document.querySelector<HTMLElement>('[data-setting="trace-export"]');
expect(found).toBeTruthy();
return found!;
});
expect(target.hasAttribute("data-landed")).toBe(false);
});
@@ -40,7 +40,7 @@ it.each(["completed", "pending"])("keeps a %s machine theme import when the comm
render(<App hub={hub} />);
await screen.findByRole("combobox", { name: "任务输入" });
await waitFor(() => expect(sessionStorage.getItem("rx-active-pane")).toBe(remote.id));
await userEvent.click(screen.getByRole("button", { name: "沙盒" }));
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
const sheet = document.querySelector(".prefs")!;
fireEvent.change(sheet.querySelector('input[data-action="theme.import"]')!, {
@@ -41,7 +41,7 @@ it.each(["completed", "pending"])("keeps the %s machine theme inventory when the
render(<App hub={hub} />);
await screen.findByRole("combobox", { name: "任务输入" });
await waitFor(() => expect(sessionStorage.getItem("rx-active-pane")).toBe(remote.id));
await userEvent.click(screen.getByRole("button", { name: "沙盒" }));
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
const sheet = document.querySelector(".prefs")!;
if (state === "completed") await act(async () => finishLocal(packs));
@@ -176,6 +176,24 @@ describe("decorative roles are themeable and the status hues are not", () => {
expect(read("--link")).toBe("#eeeeee");
});
const SYNTAX = {
synKeyword: "--syn-key", synString: "--syn-str", synNumber: "--syn-num", synFunction: "--syn-fn",
synComment: "--syn-comment", synType: "--syn-type", synOperator: "--syn-op", synPunctuation: "--syn-punc", synVariable: "--syn-var",
};
it("maps each of the nine syntax tokens onto its own variable", () => {
const light = Object.fromEntries(Object.keys(SYNTAX).map((name, i) => [name, `#${String(i + 1).repeat(6)}`]));
apply({ ...pack, tokens: { ...pack.tokens, light: { ...pack.tokens.light, ...light } } }, "light");
Object.entries(SYNTAX).forEach(([name, variable]) => expect(read(variable), name).toBe(light[name]));
expect(new Set(Object.values(light)).size).toBe(9);
});
it("writes no variable for a syntax token the pack does not name, so each surface keeps its own default", () => {
apply({ ...pack, tokens: { ...pack.tokens, light: { ...pack.tokens.light, synKeyword: "#123456" } } }, "light");
expect(read("--syn-key")).toBe("#123456");
["--syn-comment", "--syn-type", "--syn-op", "--syn-punc", "--syn-var"].forEach((variable) => expect(read(variable), variable).toBe(""));
});
it("carries a pack's own shadow onto the one variable menus, popovers and dialogs cast", () => {
const shadow = "0 0 0 1px #00000040, 0 8px 24px -8px #00000033";
apply({ ...pack, tokens: { light: { ...pack.tokens.light, shadowFloat: shadow }, dark: pack.tokens.dark } }, "light");
+5
View File
@@ -33,6 +33,11 @@ const SURFACE: Record<string, string[]> = {
synString: ["--syn-str"],
synNumber: ["--syn-num"],
synFunction: ["--syn-fn"],
synComment: ["--syn-comment"],
synType: ["--syn-type"],
synOperator: ["--syn-op"],
synPunctuation: ["--syn-punc"],
synVariable: ["--syn-var"],
// Shape and type. --r-pill is absent because a pill is a shape rather than a
// size: a pack that could set it would round a button into something else.
radiusXs: ["--r-xs"],
@@ -1,10 +0,0 @@
const segmenter = new Intl.Segmenter(undefined, { granularity: "sentence" });
/** The first sentence of a thought, cut to `max` characters with an ellipsis. */
export function firstSentence(reasoning: string, max: number): string {
const text = reasoning.trim();
if (!text) return "";
const first = segmenter.segment(text)[Symbol.iterator]().next().value?.segment.trim() ?? "";
const chars = [...first.replace(/\s+/g, " ")];
return chars.length > max ? `${chars.slice(0, max).join("")}…` : chars.join("");
}
@@ -4,6 +4,7 @@ import { toolBlurb } from "./toolBlurbs";
const catalogue = new URL("../../../../internal/runtime/usecap/testdata/capability_list.json", import.meta.url);
const peerTools = ["dispatch_to_project", "list_peers", "send_peer_message"];
const configTools = ["advise", "best_of_n", "run_script", "read_mcp_resource", "present_files"];
const mediaTools = ["web_search", "read_video", "import_web_image", "computer_act", "computer_read"];
describe("the tool blurbs", () => {
@@ -14,7 +15,7 @@ describe("the tool blurbs", () => {
expect(tools.filter((name) => toolBlurb(name) === undefined)).toEqual([]);
});
it("cover the peer, media and web tools that assembly adds outside the catalogue", () => {
expect([...peerTools, ...mediaTools].filter((name) => toolBlurb(name) === undefined)).toEqual([]);
it("cover the peer, media, web and config-gated tools that assembly adds outside the catalogue", () => {
expect([...peerTools, ...mediaTools, ...configTools].filter((name) => toolBlurb(name) === undefined)).toEqual([]);
});
});
@@ -5,6 +5,8 @@ import { t } from "../i18n";
// this build has no line for, such as one an MCP server declares.
export function toolBlurb(name: string): string | undefined {
switch (name) {
case "advise":
return t("向更强的模型求助:它读完到目前为止的对话后,用方案、诊断或审查回答你的问题;它自己不执行任何东西。");
case "ask":
return t("向你提问:遇到只有你能决定或提供的事时,模型用它来问你。");
case "await_user":
@@ -13,6 +15,8 @@ export function toolBlurb(name: string): string | undefined {
return t("在命令行里执行命令并返回输出,用于构建、测试、git、包管理等。");
case "bash_output":
return t("读取后台任务新产生的输出。");
case "best_of_n":
return t("把一个明确的编码任务并行做 2–4 遍,每遍在独立的 git 工作树里;再由评审比较,只把最好的一份写回工作区。");
case "browser_act":
return t("在浏览器页面上执行真实操作,比如点击和输入。");
case "browser_open":
@@ -89,8 +93,12 @@ export function toolBlurb(name: string): string | undefined {
return t("修改 Jupyter 笔记本(.ipynb)里的一个单元格,可替换或插入。");
case "parallel_tasks":
return t("同时派出多个只读子代理任务,全部完成后返回摘要。");
case "present_files":
return t("把已有的最终文件以可点击的卡片展示给你,包括命令行生成的文档;路径须在工作区或已授权的文件夹内,打开的是当前文件而不是快照,也不保证内容正确。");
case "read_file":
return t("读取文本文件,可指定起始行和行数,输出带行号。");
case "read_mcp_resource":
return t("读取已连接的 MCP 服务器自己列出的一份资源(文档、记录、文件);服务器没列出的地址会被拒绝,返回的是服务器的数据,不是你的指令。");
case "read_only_skill":
return t("以只读方式调用一个技能;子代理类技能在只读的隔离会话里运行。");
case "read_only_task":
@@ -111,6 +119,8 @@ export function toolBlurb(name: string): string | undefined {
return t("在隔离的子代理里结合网页内容和本地代码做调研,适合既要外部资料又要本地验证的问题。");
case "review":
return t("在隔离的子代理里审查待提交的改动,指出正确性、安全、缺测试和隐藏的行为变化;只读。");
case "run_script":
return t("运行一小段 Starlark(类 Python)脚本来调用多个工具,几步相互依赖的操作只需一次往返,只返回脚本打印的内容;每次调用都和模型直接调用一样受检查和审批。");
case "run_skill":
return t("调用系统提示里技能索引中的一份技能手册。");
case "security_review":
+1 -1
View File
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
* cannot move a pixel however far its slider is dragged. It measures the
* preview, which is drawn at the window's shape, rather than assuming one.
* Both axes count as free until the image decodes: never disable on a guess. */
export function useCrop(url: string | undefined, kind: "image" | "video" | undefined, box: HTMLElement | null) {
export function useCrop(url: string | undefined, kind: "image" | "video" | "scene" | undefined, box: HTMLElement | null) {
const [image, setImage] = useState(0);
const [frame, setFrame] = useState(0);
@@ -127,7 +127,7 @@ it("continues searching when fast page responses batch busy state transitions",
it("searches the next page when a committed revision is the only changed read state", () => {
const more = vi.fn(async () => {});
const paging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, more, reload: vi.fn(async () => {}) };
const paging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, earlier: 0, more, reload: vi.fn(async () => {}) };
const { rerender } = renderHook(({ revision }) => useHistorySearch({ ...paging, revision }, true, "needle"), { initialProps: { revision: 1 } });
expect(more).toHaveBeenCalledTimes(1);
rerender({ revision: 2 });
@@ -9,6 +9,10 @@ export interface HistoryPaging {
hasMore: boolean;
busy: boolean;
failed: boolean;
/** How many raw messages sit before the loaded window. The rail draws only
* what is mounted, so without this a paged conversation looks shorter than
* it is until someone scrolls up. */
earlier: number;
more: () => Promise<void>;
reload: () => Promise<void>;
}
@@ -41,7 +45,7 @@ export function useHistoryPages(port: Pick<AgentPort, "history" | "historyPage">
const owner = useMemo(() => ({ epoch: 0, cursor: "", snapshot: "", before: 0, total: 0, paging: !!port.historyPage, more: null as Promise<void> | null }), [port]);
const current = useRef(owner);
current.current = owner;
const [state, setState] = useState({ snapshot: "", revision: 0, hasMore: false, busy: false, failed: false });
const [state, setState] = useState({ snapshot: "", revision: 0, hasMore: false, busy: false, failed: false, earlier: 0 });
useEffect(() => () => { owner.epoch++; }, [owner]);
const load = useCallback(async (applies: () => boolean = () => true) => {
@@ -67,7 +71,7 @@ export function useHistoryPages(port: Pick<AgentPort, "history" | "historyPage">
owner.before = page?.start ?? 0;
owner.total = page?.total ?? 0;
dispatch({ kind: "__restore", ...restored });
setState((old) => ({ snapshot: owner.snapshot, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false }));
setState((old) => ({ snapshot: owner.snapshot, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false, earlier: owner.before }));
} catch (error) {
if (valid()) setState((old) => ({ ...old, busy: false, failed: true }));
throw error;
@@ -90,7 +94,7 @@ export function useHistoryPages(port: Pick<AgentPort, "history" | "historyPage">
owner.before = page.start;
owner.more = null;
dispatch({ kind: "__history_prepend", ...projection(page) });
setState((old) => ({ ...old, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false }));
setState((old) => ({ ...old, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false, earlier: owner.before }));
}).catch(async (error) => {
if (!valid()) return;
if (error instanceof HttpError && error.reason?.code === "history.snapshot_changed") { await load(); return; }
+3 -1
View File
@@ -39,7 +39,9 @@ export function useIntake({ root, onReceive }: Options): {
const ref = useCallback(
(node: HTMLElement | null) => {
pane.current = node?.closest<HTMLElement>(".pane") ?? null;
const off = zone(node);
// The whole pane takes the drop: aiming at the composer is not something
// a reader should have to do with a file in hand.
const off = zone(pane.current ?? node);
return () => {
pane.current?.removeAttribute("data-intake");
pane.current = null;
@@ -0,0 +1,37 @@
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
export function useSettingLanding(root: RefObject<HTMLDivElement | null>, anchor: string, section: string, setAnchor: Dispatch<SetStateAction<string>>) {
useEffect(() => {
if (!anchor) return;
const col = root.current?.querySelector(".prefs-col");
if (!col) return;
let target: HTMLElement | null = null;
let done: ReturnType<typeof setTimeout> | undefined;
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
const aim = () => {
const found = col.querySelector<HTMLElement>(`#set-${CSS.escape(anchor)}`);
if (!found) return;
for (let parent = found.closest("details"); parent; parent = parent.parentElement?.closest("details") ?? null)
parent.open = true;
if (target !== found) {
target?.removeAttribute("data-landed");
target = found;
target.setAttribute("data-landed", "");
clearTimeout(done);
done = setTimeout(() => setAnchor(""), 900);
}
found.scrollIntoView({ block: "start", behavior });
};
const grew = new ResizeObserver(aim);
const arrived = new MutationObserver(aim);
grew.observe(col);
arrived.observe(col, { childList: true, subtree: true });
aim();
return () => {
clearTimeout(done);
grew.disconnect();
arrived.disconnect();
target?.removeAttribute("data-landed");
};
}, [root, anchor, section, setAnchor]);
}
@@ -258,7 +258,7 @@ it("supports reader keyboard invocation, navigation and dismissal without submit
select(read, "restart");
expect(fireEvent.keyDown(read, { key: "F10", shiftKey: true })).toBe(false);
const copy = await screen.findByRole("menuitem", { name: "复制" });
expect(document.activeElement).toBe(copy);
await waitFor(() => expect(document.activeElement).toBe(copy));
await user.keyboard("{End}");
expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "加入对话" }));
await user.keyboard("{Escape}");
@@ -53,7 +53,7 @@ it.each([[false, "原目录"], [true, "隔离目录"]])("identifies the conversa
const current = isolated ? copy : root;
vi.spyOn(port, "workspaces").mockResolvedValue({ current, isolated, canSwitch: true, canIsolate: true, recents: [] });
render(<Chrome port={port} status={{ workspaceRoot: current, sessionPath: path } as never}
title="工作" steer={0} onSettings={vi.fn()} account={null} theme="dark" onTheme={vi.fn()}
title="工作" steer={0} account={null} theme="dark" onTheme={vi.fn()}
onFind={vi.fn()} view="flow" rows={0} surfaces={0} onView={vi.fn()} />);
expect(await screen.findByText(label)).toBeTruthy();
expect(screen.getByText(label).getAttribute("title")).toBe(current);
@@ -65,7 +65,7 @@ it("ignores old checkout metadata when switching conversations before it arrives
let finish!: (value: Awaited<ReturnType<MockPort["workspaces"]>>) => void;
vi.spyOn(old, "workspaces").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.spyOn(next, "workspaces").mockResolvedValue({ current: root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
const props = { title: "工作", steer: 0, onSettings: vi.fn(), account: null, theme: "dark" as const,
const props = { title: "工作", steer: 0, account: null, theme: "dark" as const,
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
const { rerender } = render(<Chrome {...props} port={old} status={{ workspaceRoot: copy } as never} />);
rerender(<Chrome {...props} port={next} status={{ workspaceRoot: root } as never} />);
+4 -4
View File
@@ -22,22 +22,22 @@ require (
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.3 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/godbus/dbus/v5 v5.2.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/text v0.43.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
modernc.org/sqlite v1.61.0 // indirect
mvdan.cc/sh/v3 v3.14.1 // indirect
)
+18 -18
View File
@@ -18,16 +18,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U=
github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -44,8 +44,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -74,24 +74,24 @@ golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
@@ -100,8 +100,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+134
View File
@@ -0,0 +1,134 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-25
---
# Checkpoint cancellation / 检查点准入取消
## Cause and owner
The checkpoint barrier waited on a condition variable without the invocation's
context. Cancellation could leave a call waiting behind an exclusive checkpoint
operation, then allow it to enter tool-use hooks or execution after that owner
released. The Agent could not finish its deferred admission cleanup until then.
The shared barrier now accepts a context for writer and exclusive admission.
Cancellation wakes condition waiters under the same mutex used for admission;
the loop checks cancellation before changing ownership. A cancelled waiter does
not decrement active writers, release exclusive access or advance generation.
Existing context-free methods retain their background-context behavior.
`MutationObserver.BeginWriteContext` uses this admission, retains the existing
durable undo retirement, and checks cancellation again after preparation. A
successful permit has an idempotent release closure: repeated cleanup cannot
release a second writer.
The Agent passes its current invocation context after
real target resolution and permission, before synchronous hooks or execution.
## Authority and data flow
| Boundary | Input and decision |
| --- | --- |
| Target and permission | Existing tool/proxy resolution and permission gates; unchanged grants |
| Admission | Invocation context plus current barrier ownership; cancellation only abandons this request |
| Undo preparation | Existing checkpoint Store and transaction persistence; completed retirement stays retired |
| User code | Starts only after preparation returns a live permit; tool `Executed` is not forged |
| Cleanup | Existing invocation defer and one permit's idempotent release |
The context callback only broadcasts the condition variable. It cannot grant
admission, change another caller's ownership or interrupt an active writer.
No file path, command, shell launcher, approval bypass or arbitrary callback is
introduced into the barrier. No persisted format, cross-process claim, wire
field, tool schema, provider prefix, permission setting or default changes.
## Compatibility and limits
The barrier coordinates a Store's writers against rewind. Concurrent writers
remain supported; this is not file-level exclusion or a cross-process lease.
Existing nonblocking `TryEnter*` calls keep their behavior. Production rewind
uses these nonblocking methods; the new exclusive context method does not turn
rewind into a queued operation or change its busy refusal.
Cancellation governs admission, not a revocable execution permit. Once user
code starts, its own cancellation and physical completion rules still apply.
Undo persistence and ordinary mutex acquisition are not interrupted. If
cancellation occurs during durable preparation, the completed retirement is
preserved, this permit is released, and the call stops before user code.
Old undo is not restored after another action may have invalidated it.
Background task/fleet callers use identified, cancellable registration outside
the registry mutex; see [background writer admission](CHECKPOINT_WRITER_ADMISSION_SECURITY.md).
The legacy context-free method remains available to existing callers.
An asynchronous hook
can outlive its synchronous callback; this slice does not register or reconcile
that process.
No FIFO, bounded wait, model-capacity separation, complete effect
receipt, safe isolated integration or whole-series completion is claimed.
## Reachability and validation
Deterministic Agent tests hold an exclusive barrier, park direct and deferred
writer/reader-hook calls, cancel, then require completion while the owner still
holds access. They verify no callback/tool entry or changed user bytes, and no
leaked writer. Original owners compile and fail these behavioral assertions.
A live actor's aborted checkpoint wait must also release its provisional
workspace paths so a second Owner can acquire them before the actor ends.
Real `boot.Build` tests retain built-in tools, capability resolution and shell
hooks. The fixture injects a Store-bound observer through the public Agent
binding and cancels at the permission boundary.
Original reader-hook cases
remain blocked and attempt hook entry after cleanup releases exclusive access;
the shell launcher itself refuses that cancelled command.
Built-in writer
cases are controls: the existing workspace lease already refuses them first.
This is an assembly regression with an explicit collaborator, not evidence
that production rewind deliberately holds the barrier indefinitely.
Barrier tests cover cancelled/free admission, queued writer/exclusive waits,
deadline expiry, shutdown, successful legacy/context waiters and preservation
of another owner and generation. Fake time and `synctest` establish ordering;
the boot watchdog only detects a stuck call, not a performance threshold.
Public checkpoint APIs create a committed undo offer. Cancelling before
admission preserves it across reload. Cancelling through the Store's undo-change
notification after retirement keeps it retired across reload. Repeated permit
cleanup cannot admit rewind while a second writer remains.
These fixtures
exercise owner contracts rather than asserting an observed production incident.
Local validation and authored security analysis do not replace exact-head
hosted platform checks or independent review.
## 中文说明
旧检查点屏障不接收调用 context,取消无法唤醒等待者;独占方稍后退出后,旧调用
仍可能进入 hook 或工具。现在等待、取消和准入由同一屏障所有者协调,取消只撤回
本次请求,不会释放别人的许可或推进版本。Agent 在实际目标解析和权限检查之后,
将调用 context 传给检查点准入,成功许可仍由调用退出路径释放。
准入前取消保留既有 undo;持久化退役期间发生取消,则保留已经完成的退役并退出,
不恢复可能已失效的旧 undo。重复释放同一许可不会减少其他写入者的计数。
普通上下文无关 API、非阻塞 rewind、并行写入、权限、持久化格式和工具定义保持
既有约定。本次不强制打断进行中的文件 I/O,不解决后台登记锁、异步 hook 进程、
公平队列、跨进程文件事务、验证证据或整个隔离恢复生命周期。
真实装配测试明确注入检查点 Store;旧读取 hook 路径会阻塞并尝试进入回调,但实际
shell 启动器会拒绝已取消的命令,不能把它描述成已运行 shell 写入。写工具对照由
现有工作区租约先行拒绝。确定性交错、磁盘重载、本地 race 和后续 CI 分开记账。
@@ -0,0 +1,139 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-26
---
# Background writer admission / 后台写入者准入
## Cause and repair
`MutationObserver.RegisterWriter` held the shared parent/child registry mutex
while waiting for checkpoint admission, retiring undo on disk and notifying the
undo owner. Unregistration also persisted the checkpoint under that mutex.
Registry queries and unrelated writer retirement could block behind these steps.
Task and fleet background registration used the context-free method. A cancelled
call could remain waiting behind an exclusive checkpoint operation. Changing only
the barrier did not pass cancellation through these three registration sites.
The registry now reserves an identified pending registration under its mutex and
prepares outside it. Pending entries do not count as active writers.
Publication
uses a separate serialization mutex, takes the latest registry snapshot briefly,
then releases the registry mutex before calling the existing checkpoint Store.
This prevents an older snapshot from overtaking a newer removal.
The context method returns cleanup bound to that registration's identity. Task
and fleet transfer this cleanup to the background job. Cancellation bounds
admission; it does not revoke a successfully handed-off job's writer permit.
Repeated or old cleanup cannot remove a replacement with the same string ID.
## Source, sink and authority
| Boundary | Decision and guarantee |
| --- | --- |
| Source | Existing host execution ID, writer kind, turn and caller context |
| Pending reservation | One token per ID; duplicate callers await that token |
| Preparation | Existing barrier and durable undo retirement, outside registry mutex |
| Publication | Existing Store active-writer projection; latest snapshots serialized |
| Handoff | Task/fleet job owns the returned identified cleanup |
| Retirement | Remove only the expected token, cancel its preparation, release its permit |
Cancelled followers do not cancel their leader. A retired pending registration
cannot resurrect after preparation returns. Panic cleanup releases the acquired
barrier permit, closes duplicate callers' rendezvous and rethrows the panic.
Undo already retired durably remains retired after cancellation or panic.
The undo notification callback is an existing host callback, not new untrusted
executable input. A callback that queries the writer registry is a public-API
regression fixture; no production callback deadlock is asserted from that fixture.
The production controller callback emits `RewindChanged`.
## Compatibility and limits
Legacy registration remains context-free. Legacy `UnregisterWriter(id)` retains
its current-ID meaning; new production callers use identified cleanup instead.
Duplicates share a registration rather than owning reference-counted permits,
as before.
This change introduces no persisted format, cross-process protocol,
path authority, permission grant, tool schema, provider prefix or default policy.
Synchronous Store I/O and ordinary mutex acquisition are not context-abortable.
Cancellation is checked around preparation; queries of the registry stay available
while Store I/O is blocked. Publication can still serialize behind slow disk I/O.
Store persistence remains best effort under its existing contract.
The registry and checkpoint barrier are in-process rewind coordination. This is
not file-level mutual exclusion, process-scope completion, automatic isolation,
result integration, recovery journaling or root-budget enforcement. Asynchronous
hooks and processes require their own physical-lifetime integration.
The accepted [job finalization owner](https://github.com/esengine/DeepSeek-Reasonix/pull/14469)
may prove that a closure will not be invoked.
Task/fleet register returned writer-token cleanup with that owner alongside
transcript/progress cleanup, instead of a closure-only defer or ID unregistration.
Cleanup runs on actual closure return or proven no-launch, before completion
publication. This is not proof of descendant process-scope completion.
## Verification boundaries
Deterministic tests cover parked exclusive admission, cancellation, duplicate
followers, pending retirement, replacement generations, panic and durable undo.
A Store-mutex fixture parks publication and checks that registry access continues;
its watchdog detects a stuck fixture, not a product latency requirement.
Real task/fleet calls exercise cancelled background admission. Real `boot.Build`
tests cover direct and dynamically resolved task/fleet handoff, a completed parent
turn while children remain live, and final job cleanup.
The fixture installs the
shared observer through the public Agent setter; it does not claim a concurrent
default Controller rewind incident.
Compiling preceding-owner overlays reproduce four observer failures; preceding
task/fleet callers reproduce two cancellation failures plus their parent test.
The observer overlay adds only a context-shaped adapter to legacy registration
and ID-only cleanup; it does not assert that the old API offered identified cleanup.
The combined job-owner regression checks both ordinary writer release and a
same-ID replacement admitted before the old job finishes. Its finalizer must
release only its original token.
Accepted no-launch tests and checkpoint
admission tests remain part of the combined validation, including real boot
entry points. These do not establish unknown process-scope termination.
Local race and root results, hosted CI, native platforms and independent review
are reported separately in the PR.
## 中文说明
原登记流程持有父子代理共享的登记表锁等待检查点、持久化撤销状态并调用通知;
移除登记时也在该锁内持久化,导致查询及其他写入者的清理一起等待。
task/fleet 三个生产入口没有把调用上下文交给后台写入者准入。
新流程先在锁内创建带身份的待准入记录,再在锁外等待及准备;待准入记录不计入
活动写入者。单独的发布锁串行提交最新快照,登记表锁不覆盖 Store I/O。
成功后返回绑定本次身份的清理回调,并转交后台任务;旧回调不能释放同 ID 的新登记。
取消只终止准入等待,不撤销已经交接的后台执行。取消跟随者不影响首个申请,
待准入记录被移除后不能复活;panic 关闭等待出口并释放本次许可,继续传播原 panic。
已经持久化的撤销退休状态不会恢复成可撤销。
旧接口保留原语义,没有变更权限、持久格式、工具定义、稳定提示前缀或默认策略。
磁盘 I/O 和普通互斥锁本身仍不可取消;这是进程内的 rewind 协调,不能替代跨进程文件
事务、进程物理完成、自动隔离或恢复日志。原生平台、CI 与独立审查以各自实际证据为准。
已接受的 #14469 统一实际闭包返回与已证明不启动时的资源终结。本分支把登记 token 的
清理回调交给这个所有者,与进度和 transcript 资源一起在完成发布前终结,不再只靠闭包
defer,也不退回按字符串 ID 移除。组合回归覆盖普通释放及旧任务结束前同 ID 新登记;
旧 finalizer 不能释放新 token。
这仍不证明后代进程 scope 完成或所有后台生命周期闭合。
@@ -0,0 +1,71 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-24
---
# Concurrent mutation review series / 并发写入分批审查
## Purpose / 目的
The complete implementation remains pinned at
`467a61668a0781cab171536158536283d931e4d9` in #14133 as a source reference.
It is being split following the maintainer's review-size request.
The source PR's validation is historical evidence, not qualification of a slice.
完整实现保留在 #14133 的上述提交。本系列按维护者要求拆分;原实现的测试记录
保留,但每一批都必须独立构建、检查及测试,不能继承整体分支的通过结论。
## Sequence and review boundaries / 顺序与边界
| Order / 顺序 | Review unit / 审查单元 | Prerequisite and activation / 前置条件及接入 |
| --- | --- | --- |
| 1 | Mutation admission primitive, content versions, legacy claim compatibility | Current slice; library only. No tool/controller/settings activation. |
| 2 | Tool effect descriptions and per-actor read-version provenance | Depends on 1. Review path grants, approval ordering, overlays and same-file stale-view regressions. |
| 3 | Shared tool/agent transaction assembly | Depends on 2. Review parent/child/fleet/dynamic paths, refused preparation and physical completion. |
| 4 | Source snapshots and private worktree preparation | Independent platform layer where possible. Review Git common-directory operations, modes, symlinks, untracked and ignored inputs. |
| 5 | Journaled per-file application and conditional recovery | Depends on 1 and 4. Review manifest containment, preimages, identity, failure injection, conditional rollback and idempotence. |
| 6 | Frozen check inputs and verification receipts | Review input observation, environment identity, stale evidence and unavailable backend behavior before integration activation. |
| 7 | Durable isolated task records and lifecycle | Depends on 4 and 6. Split preparation/execution from recovery/retention if either diff becomes too broad. |
| 8 | Task/fleet/background and nested ownership | Depends on 3 and 7. Review budgets, continuation, direct-parent identity, cancellation, restart and cross-version fences. |
| 9 | Root conversations and source integration | Depends on 5–8. Review current grants, source drift, controlled checks and recovery blocking together. |
| 10 | Controlled portable verifier and confined shell/hooks | Separate written command/authority security review; no unsupported-platform enforcement claim. |
| 11 | Controller/wire/frontends and migration/default selection | Activate only after required lower-layer review and platform evidence; preserve legacy settings and active runtime protocols. |
| 12 | Product/process performance and final acceptance | Reconcile every source delta, tests and compatibility evidence on the combined accepted tree. |
表中是依赖关系及审查边界,不是承诺将每行所有文件一次性提交。若一行仍然
包含多个独立机制,应继续拆分。前置接口经审查确定后,再提交对应的接入批次。
优先使用直接面向 `studio` 的独立 PR;需要堆叠时明确实际基分支及依赖。
## Extraction and security rules / 拆分与安全规则
1. Each slice MUST build and run its own tests without unsubmitted sibling
patches. No lint baseline, safety gate or coverage floor is relaxed to make
an extraction pass.
2. Every PR MUST state Cause, Blast radius, Neighbouring behaviours tested and
Why this layer, plus its actual base/head and local/hosted evidence.
3. Sensitive slices MUST document input provenance, authority, filesystem or
command sinks, validation, failure recovery and explicit unenforced bounds.
4. Original CodeQL findings remain unresolved until assessed at their actual
sources and sinks. Moving code between PRs is not security remediation.
5. The source branch MUST remain recoverable. Each extracted file or hunk is
accounted for as kept, adapted, deferred or intentionally dropped; the
combined final tree is compared with the pinned source plus upstream changes.
6. Primitive API simplifications in slice 1 are intentional: use `Options`,
lease methods and explicit-context version reads. Runtime-only context
conveniences and legacy recovery admission hooks are deferred to their users.
每批必须独立验证,不降低门禁;安全说明不能用“旧分支测过”替代。拆分时保留
完整来源,并记录适配或延期的差异;最后对合并后的代码树做完整性核对。第一批
去掉尚无调用者的便利函数,后续接入时按已审查接口适配,不复制旧 API。
## First-slice review / 第一批审查入口
Read [the coordinator security review](MUTATION_COORDINATOR_SECURITY.md), then
`internal/state/mutation/doc.go`, `coordinator.go`, `registry.go`, `resources.go`,
`capacity.go` and `completion.go`. The compatibility change is limited to
`workspacelease/claim_attempt.go` and its existing `paths.go` publication owner.
先阅读安全审查及包说明,再审查资源申请、登记、身份、容量及释放。旧协议的
调整限于独立短申请及其发布清理,不接入运行时恢复屏障或修改旧锁语义。
+45
View File
@@ -1662,6 +1662,51 @@ sources are merged; on a name collision `reasonix.toml` wins.
keep working — move them into `reasonix.toml`'s `[[plugins]]` or a `.mcp.json` when
convenient.
## Custom instructions / 提示词怎么注入
Choose the scope before adding a prompt.
### One task / 单次任务
Type the instructions in chat. For a reusable prompt you invoke explicitly,
use a [custom slash command](#slash-commands).
直接在聊天框输入要求。需要反复手动调用的提示词可写成[自定义斜杠命令](#slash-commands)。
### Repository rules / 仓库规则
- Create `AGENTS.md`, or use the repository's existing `REASONIX.md` / `CLAUDE.md`,
at the workspace root. For example: `- Run the project tests before reporting completion.`
- Use `AGENTS.local.md` for personal rules in that directory, or an instruction
file in your Reasonix home for user-global rules.
- See [standing-instruction resolution](./SESSION_MEMORY_RETRIEVAL.md#instruction-resolution)
for hierarchy, imports, and `/memory instructions` to inspect what loaded.
- 在工作区根目录创建 `AGENTS.md`,或沿用仓库已有的 `REASONIX.md` / `CLAUDE.md`。
例如添加 `- 完成前运行项目测试。`。
- 当前目录的个人规则可放在 `AGENTS.local.md`;跨项目的用户规则放在 Reasonix 主目录中的指令文件。
- 层级、导入及查看已加载内容的 `/memory instructions` 命令见
[常驻指令文档](./SESSION_MEMORY_RETRIEVAL.md#instruction-resolution)。
### User-wide persona / 用户级人设
1. In Studio, open **Settings → Persona → System persona**.
2. Fill **Start of the system prompt** and/or **Additional instructions**, then **Save**.
3. Start a new session, or wait for the existing session's next rebuild
(for example, a model switch). Saving does **not** change the running session's prompt.
These settings supplement built-in guidance; they do not replace it. They are
stored in your user configuration, not a project's `reasonix.toml`.
See [System persona](./PERSONA.md) for configuration fields and limits.
1. 在 Studio 打开**设置 → 人设 → 系统人设**。
2. 填写**系统提示词开头**和/或**补充要求**,点击**保存**。
3. 新建会话,或等现有会话下一次重建(例如切换模型)后生效。
**保存不会改变正在运行的会话提示词**。
这些设置补充而非替换内置指令,写入用户配置,不由项目 `reasonix.toml` 设置。
配置字段及限制见[系统人设](./PERSONA.md)。
## Slash commands
In an interactive `reasonix` session, built-in commands (`/compact`, `/context`, `/new`, `/clear`, `/rewind`,
+105
View File
@@ -0,0 +1,105 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-25
---
# Hook checkpoint admission / Hook 检查点写入准入
## Root cause and repair
A reader's tool-use hooks can write files. Checkpoint admission previously used
only the concrete tool's read-only classification, so these callbacks bypassed
the rewind barrier and omitted the explicit hook coverage gap.
When an observer is installed, the invocation requires checkpoint write
admission if either the tool or its hook set may write. Admission stays before
callbacks. The permit spans synchronous callbacks, execution and failure cleanup.
An admitted hook set records `GapHookWrite` even around a reader. This reports
unknown coverage; it does not invent file preimages or make an incomplete
checkpoint complete. Tool execution and read-only classification are unchanged.
## Authority and data flow
| Boundary | Contract |
| --- | --- |
| Target access | Existing permission and target resolution finish before checkpoint admission |
| Hook capability | Existing optional capability report; legacy custom hooks remain conservatively write-capable |
| Checkpoint owner | `MutationObserver.BeginWriteContext`: enter rewind barrier, durably retire undo, then return permit |
| Failure | Closed barrier or failed undo retirement refuses the call before hook/tool entry |
| Cleanup | Existing invocation defer releases the permit on success, refusal or failure |
| Coverage | Existing `GapHookWrite`, never a fabricated path or captured preimage |
No new hook launch path, permission grant, shell program, dependency, persisted
field, wire schema, tool definition or configuration default is introduced.
The repaired caller uses the same checkpoint owner as an ordinary writer.
Empty and non-tool hook runners do not acquire a reader's write permit or
retire undo. An enabled tool-hook set can conservatively require admission
even if no individual command matches this invocation.
## Concurrency and compatibility bounds
The checkpoint barrier excludes rewind; it does not serialize all writers or
replace file-level, cross-process mutation coordination. Readers with possible
hook writes now follow the existing writer wait/shutdown contract.
Invocation admission accepts its context; cancellation can abandon a wait
without releasing the exclusive owner. See [cancellation bounds](CHECKPOINT_CANCELLATION_SECURITY.md).
This does not add a fair queue, global execution mutex or filesystem scan.
Imported asynchronous hooks can outlive their callback. Their process lifetime
is not covered by this permit; registering and reconciling those background
writers remains a separate lifecycle requirement.
Old records and formats are unchanged. `GapHookWrite` already has supported
readers; its presence now accurately includes reader-side hooks. A checkpoint
can therefore advertise incomplete coverage where it previously omitted it.
This slice is independent of #14397's provisional-lease repair and #14398's
optional host invalidation callback. It does not finish delivery-receipt or
verification-evidence accounting, capture arbitrary hook writes, or sandbox
the approved program.
## Validation and reachability
Tests construct an Agent with the same hooks/observer wiring used by boot.
The callback writes real bytes and attempts a nonblocking exclusive entry:
it must hold a writer permit, and rewind must be refused until it exits.
Pre-success, pre-refusal, post-success and post-failure paths cover direct and
deferred reader targets. Negative cases cover closed admission and a deliberate
durable-retirement I/O fault, plus empty/non-tool hook runners.
Public checkpoint APIs create a real committed rewind/undo offer. The callback
must see it retired before entry, and reloading must not revive it. Fault fixtures
model unavailable storage; they do not claim an observed production disk fault.
The undo fixture enters the Agent invocation directly to test that contract.
Ordinary visible Controller turns already retire undo when opening a checkpoint;
the fixture is not evidence that such a turn normally retains an undo offer.
Real `boot.Build` tests run a helper process that writes another file around a
read, and inspect the actual controller checkpoint's coverage. A compiling
original-owner overlay must fail these assertions. Author tests do not replace
independent review or exact-head native CI.
## 中文说明
只读工具不代表整个调用只读。原接线按工具分类跳过 checkpoint 写屏障,导致周围
可能写文件的 hook 未参与 rewind 互斥,也未记入覆盖缺口。
现在工具或 hook 任一可能写入,都先通过已有写入准入:进入屏障并持久化退役旧的
undo,再允许回调执行。准入失败则 hook 和工具都不执行;调用退出后释放许可。
hook 的未知写入只记录既有覆盖缺口,不伪造预映像或修改工具 `Executed`。无工具
hook 的读取保持原行为。权限、工具定义、配置和持久化格式不变。
该屏障只保护 checkpoint 与 rewind,不是完整文件事务或跨进程锁;调用等待支持
取消,但后台登记、验证证据、自动隔离与恢复仍需后续实现。测试的存储故障是明确故障注入,
不冒称已在线上发生。
导入的异步 hook 进程可能晚于回调结束,当前许可不覆盖它的完整运行期;这类后台
写入的登记和恢复仍是后续生命周期工作。
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more