Compare commits

...
82 Commits
Author SHA1 Message Date
esengine 87fb22211d docs(release-notes): studio 2.54.0 2026-10-27 17:19:09 -07:00
YHH f00e8ac590 Merge pull request #14672 from esengine/fx/global-shortcut-page
fix(studio): put global-shortcut on a settings page and keep Composer under the size ceiling
2026-10-27 12:01:37 -07:00
YHH af176c59f3 Merge branch 'studio' into fx/global-shortcut-page 2026-10-27 11:34:12 -07:00
YHH cd9c8723de Merge pull request #14551 from chuiwenwei/fix/acp-inbox-refusals-carry-codes
fix(acp): an inbox refusal says which refusal it is
2026-10-27 11:22:28 -07:00
YHH edefe37198 Merge pull request #14371 from SivanCola/codex/worktree-snapshot-content-14025
fix(worktree): capture current bytes and refuse stale apply / 修复快照旧内容与过期整合覆盖
2026-10-27 10:57:26 -07:00
YHH 7059b4ac45 Merge pull request #14631 from SivanCola/codex/skill-entry-root-budget-14025
fix(control): share root budget with explicit skills / 显式技能共享任务根预算
2026-10-27 10:56:48 -07:00
esengine e210f9be5e fix(studio): put global-shortcut on a settings page and keep Composer under the size ceiling 2026-10-27 10:47:07 -07:00
YHH bfbb153ab3 Merge pull request #14522 from chuiwenwei/fix/a-total-says-how-many-it-could-not-price
fix(stats): a total says how many calls it could not price
2026-10-27 10:26:13 -07:00
YHH ffa0911a56 Merge pull request #14671 from SivanCola/codex/authoritative-local-cost-budget-14025
fix: use authoritative local cost quotes / 本地费用门禁采用权威报价
2026-10-27 10:21:42 -07:00
YHH 6ff61b6c0e Merge pull request #14037 from imcreazy/fix/closing-the-last-page-keeps-the-workbench
fix(studio): closing the last page offers a new tab
2026-10-27 10:20:32 -07:00
YHH e85a2de9c4 Merge pull request #14578 from esengine/c5-hide-prefix
feat(providers): a provider can hide the prefix all its models share
2026-10-27 10:09:02 -07:00
YHH 0f5e12064c Merge pull request #14670 from esengine/fx/usage-upload-bytes
feat(usage): show how much is uploaded to model providers
2026-10-27 09:53:33 -07:00
YHH a36dfec9e5 Merge pull request #14647 from esengine/fx/settings-pages-nav
feat(desktop): long settings sections split into pages in the navigation
2026-10-27 09:52:57 -07:00
YHH c112ecdfc9 Merge pull request #14592 from lancy69/feat/8290-global-window-shortcut
feat(studio): add an opt-in global show/hide shortcut
2026-10-27 09:52:40 -07:00
SivanCola 6279a638dc fix: use authoritative quotes for local task cost gates
Problem: local task cost gates could count a base rate while the accounting
event charged the vendor's occurrence-time peak rate, allowing extra tools.

Root cause: Agent budgeting recomputed spend with the legacy float adapter
instead of consuming the quote owned by the existing pricing sink.

Fix: forward an optional usage-quoter capability, capture one authoritative
quote for both delivery and budgeting, and accumulate fixed-point original
currency buckets. Preserve known subtotal and incomplete-usage semantics.
Keep display currency, provider requests and cancellation separate.

Verification: actual Agent and Boot peak/off-peak/relay regressions, preceding
owner negatives with controls, full root package inventory, relevant owner
and Boot race suites, static gates, cross-compilation and a pinned Windows
inventory with verifier mutants. Hosted native and independent review remain
separate. Shared root money and reservations are not implemented here.

Refs #14025.
2026-10-28 00:33:05 +08:00
esengine 1a208789c5 feat(usage): show how much is uploaded to model providers
Every model request carries the whole conversation, so upload grows with
context size times request count and nothing in the app showed it. Each send
attempt now reports its body size, a per-process meter keeps it per provider
and day beside the usage records, and Settings > Usage shows today, the last 30
days, the average request and the providers.
2026-10-27 08:41:11 -07:00
esengine d21e4bebbf feat(desktop): long settings sections split into pages in the navigation 2026-10-27 07:21:20 -07:00
lancy69 e0728253bb feat(studio): add an opt-in global show/hide shortcut
Own one disabled-by-default shortcut in the local Electron profile, with immediate authorized IPC and visible registration failures. Preserve old registration and saved state when rebind or persistence fails; restore at ready and unregister during quit.

Keep browser/network viewers unsupported and in-app bindings unchanged. Cover lifecycle, authorization, failure rollback, bounded native input and settings neighbours; add a real Linux/X11 OS-key smoke and transparent platform limits.

Fixes #8290. Refs #7623; stopped-app launching is not supplied.
2026-10-27 21:54:36 +08:00
YHH 75317a6fe3 Merge pull request #14666 from lancy69/feat/14610-remember-feedback-contact
feat(studio): remember last submitted feedback contact
2026-10-27 06:17:35 -07:00
YHH c7a869c9e9 Merge pull request #14662 from esengine/fx/session-tail-fork
fix(session): open the newest part of a history too large to replay
2026-10-27 06:09:34 -07:00
YHH 984b715f97 Merge pull request #14664 from esengine/fx/compact-notice-code
fix(compaction): word the dropped-turn notices in the window's language
2026-10-27 06:08:57 -07:00
YHH a4c2ebd5be Merge pull request #14571 from esengine/c5-vision-filter
feat(studio): the vision row lists only models declared to read images
2026-10-27 06:08:35 -07:00
YHH c4960bd263 Merge pull request #14589 from lancy69/feat/14388-theme-surface-opacity
feat(theme): allow per-scheme backdrop surface opacity
2026-10-27 05:50:13 -07:00
YHH 37a0cf23c6 Merge pull request #14591 from lancy69/fix/14497-queue-composer-layout
fix(studio): integrate pending queue into composer layout
2026-10-27 05:46:55 -07:00
YHH 281d9264cc Merge pull request #14590 from lancy69/fix/12019-soft-contrast-tier
fix(studio): give Soft contrast a lower AA palette
2026-10-27 05:43:45 -07:00
YHH 7bbc19bb1a Merge pull request #14588 from lancy69/fix/14470-wallpaper-settings
feat(studio): expose wallpaper settings and selected preview
2026-10-27 05:40:38 -07:00
esengine 6161a95f5c fix(session): open the newest part of a history too large to replay
A session whose message payload passes the replay bound could not be opened
and the window offered no way forward. The refusal now carries a code, and
the window offers to continue from the most recent part as a new session; the
original files are only read.
2026-10-27 05:33:53 -07:00
YHH d371619a9e Merge pull request #14657 from lancy69/fix/14632-session-delete
fix(studio): keep deleted legacy conversations out of the sidebar
2026-10-27 05:30:48 -07:00
YHH 3fef1b4ea1 Merge pull request #14602 from lancy69/fix/14231-editor-save-controls
fix(studio): keep file Save compact and readable
2026-10-27 05:30:31 -07:00
Lancy 180d3a71eb feat(studio): remember last submitted feedback contact 2026-10-27 20:03:17 +08:00
Lancy 336ca9cc30 fix(sessionstore): persist removal of legacy v4 conversations 2026-10-27 19:50:00 +08:00
esengine eeb0e27878 fix(compaction): word the dropped-turn notices in the window's language
The two notices a fold leaves when user turns survive only through the summary
carried no code, so a Chinese window showed the English fallback. They now
carry a code and a typed payload like the other coded notices.
2026-10-27 04:00:05 -07:00
YHH e19aff7f26 Merge pull request #14655 from esengine/fx/blocked-calls-carry-identity
fix(agent): calls the host stops before they run carry a refusal identity the window words
2026-10-27 03:06:49 -07:00
YHH fed8d4e387 Merge pull request #14650 from esengine/fx/settings-storage-tally
fix(desktop): opening settings no longer walks the disk for the storage total
2026-10-27 03:02:54 -07:00
Will 03b0268479 docs(release-notes): record #14551 2026-10-27 18:01:53 +08:00
Will d7d0c8aeef fix(acp): an inbox refusal says which refusal it is
Every inbox handler answered a store refusal with -32600 and prose, so a client
could not tell "gone" from "wrong state" from "may already have run" — and one
that retried on any error re-sent work the kernel refuses precisely because the
turn may already have run. The HTTP frontend already names 14 conditions; ACP
now answers the same names, with -32020 for "the queue refused this call" and
-32021 for "the work may already have run", and leaves -32600 to a malformed
frame alone.

docs/ACP.md states the table and each refusal's class (terminal,
retry-after-repair, do not retry). repolint's family-vs-writer check now reads
both writers, so a sentinel added to the store cannot reach ACP unnamed.
2026-10-27 18:01:52 +08:00
Will c1321f9971 docs(release-notes): record #14522 2026-10-27 18:01:49 +08:00
Will dff7a9e7f3 fix(stats): a total says how many calls it could not price
A row already records why its money may be incomplete (`cost_estimated`,
`incomplete_reason`), but RangeStats kept only a bool, so a total could say
"this folded in an estimate" and never "of these calls, N were never priced,
because …". Both read paths now count those calls and name the distinct
reasons; the projection carries the reason as schema 8, which reindexes from
the daily files; an old or reason-less row still counts, it just adds no
reason to the vocabulary.
2026-10-27 18:01:49 +08:00
YHH 1fbb96530a Merge pull request #14648 from esengine/fx/summary-retry-connection-reset
fix(agent): retry a context summary once when its stream is reset
2026-10-27 02:59:18 -07:00
esengine 96cd01d6e2 fix(agent): calls the host stops before they run carry a refusal identity the window words 2026-10-27 02:37:11 -07:00
esengine f41b9d4131 fix(desktop): opening settings no longer walks the disk for the storage total 2026-10-27 02:10:00 -07:00
esengine 8094bcb3a8 fix(agent): retry a context summary once when its stream is reset 2026-10-27 01:40:28 -07:00
YHH d5ead8314c Merge pull request #14635 from esengine/fx/settings-type-roles
feat(desktop): settings text follows one set of type roles
2026-10-27 00:53:36 -07:00
esengine d2a2f9fe97 feat(desktop): settings text follows one set of type roles 2026-10-27 00:27:33 -07:00
YHH cf5f12ed55 Merge pull request #14620 from SivanCola/feature/provider-health-api
feat(provider): gate connection health on user opt-in / 连接检测默认关闭并限定用户授权
2026-10-26 23:57:52 -07:00
YHH 1d81d02a3f Merge pull request #14630 from esengine/fx/workbench-git-scroll
fix(desktop): the multi-repository Git panel scrolls instead of growing past the explorer
2026-10-26 22:54:49 -07:00
YHH 3c4982bc5c Merge pull request #14626 from esengine/fx/modal-focus-no-ring-after-mouse-open
fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring
2026-10-26 22:54:10 -07:00
SivanCola 6012926b9f fix(control): bind root budgets for explicit subagent skills
Problem: Explicit slash, composer and CLI profile children could exceed a
configured root token/wall budget because they bypassed the normal Agent turn.

Root cause: Those Controller entries invoked skill runners without preparing
the executor-owned scope; Goal skills installed observation without the spend
limit. Synchronous profile calls also lacked turn admission serialization.

Fix: Bind the existing Goal/root scope after blocking hooks for interactive
skills. Reuse synchronous Controller admission and root preparation for CLI
profile run/preview, retaining child pause and cancellation semantics.

Verification: Actual Boot HTTP/file-read and Controller lifecycle regressions;
exact old-owner overlay fails 13 named events with four passing controls.
Complete 239-package coverage includes the retained original Boot 15m alarm
and two disjoint remainder groups; four owner race suites and focused Boot
race pass. Root vet, full lint, check and actionlint pass. Windows/Linux
compile-only checks and the literal native gate contract pass locally.
2026-10-27 13:54:03 +08:00
esengine fd901eabed fix(desktop): the multi-repository Git panel scrolls instead of growing past the explorer 2026-10-26 22:36:58 -07:00
esengine b79621c47c fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring 2026-10-26 22:30:41 -07:00
esengine f193dfabe7 fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring 2026-10-26 22:30:30 -07:00
YHH 27b8e944ec Merge pull request #14621 from esengine/fx/phone-test-wait-for-delivery
test(notify): wait for the first phone delivery before attaching the link
2026-10-26 21:59:06 -07:00
esengine 5d973599fc test(notify): wait for the first phone delivery before attaching the link 2026-10-26 21:34:00 -07:00
YHH 37a74507d1 Merge pull request #14618 from esengine/fx/queue-hold-notice-clears
fix(desktop): clear the held-queue notice when the queue resumes
2026-10-26 21:32:07 -07:00
YHH b7fe9ca5f7 Merge pull request #14614 from SivanCola/codex/goal-resume-planner-budget-14025
fix(control): renew planner budget on Goal resume / 恢复 Goal 时续给规划预算
2026-10-26 21:30:02 -07:00
SivanCola 012bd085aa fix(provider): declare health consent as immediately effective
Problem: The setting catalogue described health consent as applying on session open.
Root cause: The declaration did not match the controller's per-request global read.
Fix: Mark the guarded setting Immediate; disable and authorization changes are
checked before cached evidence is returned.
Verification: Settings package tests and the registered HTTP opt-in/disable
regression pass. No payload or request logic changed.
2026-10-27 12:22:01 +08:00
SivanCola 590bc21470 feat(provider): add opt-in authorized connection health API
Problem: automatic picker probes need explicit user consent and must not
borrow credentials for project-defined destinations.
Root cause: the combined implementation used the merged configuration and
had no backend-enforced default-off setting.
Fix: use a strict user-global configuration snapshot, require the opt-in and
provider access before every cache read, refuse all redirects, and expose a
guarded settings API. Cache only fixed-size digests and status/timestamps;
never retain upstream error bodies or log credentials.
Verification: authorization/default-off/disable and malformed-config HTTP
regressions, previous-reader save overlay, desktop setting round trip,
provider-health race tests, and frontend route contracts passed. Full lint
and inventory-partitioned boot validation are tracked separately in the PR.

Refs #14617
2026-10-27 12:11:50 +08:00
esengine 589bcab7a3 fix(desktop): clear the held-queue notice when the queue resumes 2026-10-26 21:03:37 -07:00
YHH 61a6c0b18b Merge pull request #14604 from SivanCola/codex/tracked-command-job-owner-14025
fix(proc): retain published Job Object owner / 保留已发布的作业对象 owner
2026-10-26 20:49:37 -07:00
YHH 5831a67380 Merge pull request #14612 from esengine/fix/feedback-esc-lightbox
fix(studio): Escape closes only the innermost open layer
2026-10-26 20:47:14 -07:00
SivanCola b3ba6ee899 fix(control): renew the planner budget on Goal spend resume
Problem: A dual-model Goal resumed after a spend-budget pause can stop its
new planner research after one tool round despite the user renewing the slice.

Root cause: ResumeGoal resets only the executor's local budget. The tool
planner keeps its own Agent accumulator and stricter local gate, so renewing
the shared executor root does not clear that old planner spend.

Fix: On a successful budget-spend resume, also reset the existing companion
planner Agent through the runner's established accessor. Preserve manual
pauses, refused resumes, cumulative Goal statistics and delivery evidence.
No new API, default, cancellation authority, prompt or persisted format.

Verification: Actual Controller/Coordinator loop reproduces two resumed
planner requests instead of four on the preceding owner. Actual Boot plus
HTTP/SSE reads three fixture files per renewed slice; the exact old owner
overlay compiles and fails both regressions with four passing controls.
Full 239-package coverage reconciles 24061 unique test/subtest passes and
215 existing skips. The original Boot command hit its 15m package alarm;
all 846 top names close via 842 original terminals and disjoint 2/2-name
remainder groups, without raising timeouts. Complete control/coordinator
race1758 passes/4 existing skips, actual Boot race1 pass, static gates and
Windows/Linux compile-only checks pass. New-head hosted/native gates remain.
2026-10-27 11:36:01 +08:00
esengine fc68f914f8 fix(studio): Escape closes only the innermost open layer 2026-10-26 20:27:40 -07:00
SivanCola eafeac4ea4 test(proc): recognize the Windows recorder by executable path
Problem: The Windows fallback control missed its recorder marker and could
not remove the still-running taskkill test executable.

Root cause: exec.Command("taskkill", ...) preserves the bare command in
argv[0]. The recorder compared that argument with taskkill.exe, missed its
special entry point, and entered the complete package suite instead.

Fix: Identify the recorder with os.Executable, require LookPath to select
the fixture's same file, and fail closed on an unexpected recorder entry.
Add a bare-command alias probe whose fallback selector runs only the inert
helper. Run/Wait completes before temporary-directory cleanup. Keep the
production ownership repair unchanged and extend the native inventory to
seven pass events versus four expected failures and three passing controls.

Verification: Windows cross-compile, Windows vet and pinned Windows proc
lint pass; full root vet/lint and make check pass at unchanged baseline.
All 6268 selected Darwin inputs match the previous qualified commit; this
append changes only the Windows fixture, verifier inventory and docs.
The complete original Windows failure log is preserved. Fresh native
Windows execution on this new commit remains required, not inferred.
2026-10-27 11:25:55 +08:00
YHH 432c8927f2 Merge pull request #14606 from esengine/fix/share-endpoint-facts
fix(openai): an endpoint's refusal of stream_options is learned once per process
2026-10-26 20:16:15 -07:00
esengine 306aeecded fix(openai): an endpoint's refusal of stream_options is learned once per process 2026-10-26 19:58:28 -07:00
SivanCola 75a36959fd fix(proc): retain published command job ownership
Problem: Repeated cancellation or shell reaping can discard an established
Windows Job Object owner and perform a new PID-based stop attempt.

Root cause: TrackedCommand removes its one-shot TrackedJob on the first kill
and also starts a redundant PID tree observer after successful job assignment.

Fix: Keep the published owner across repeated and late cancellation, and let
an established Job Object own its tree. Preserve existing non-job fallbacks.
Add native Windows owned-child probes with a harmless taskkill recorder and
an exact preceding-owner counterfactual inventory gate.

Verification: Root inventory reconciles 239 packages, 24060 test/subtest
passes and 215 existing skips. Original Boot command hit its 15m package
alarm; all 845 top-level tests close through the recorded disjoint remainder
groups without a timeout increase. Complete proc and shell race suites pass.
Root vet, full lint, make check and actionlint pass. Windows new/old owners
compile and Windows vet passes; actual native regressions remain a hosted gate.
No full PID-identity, descendant-quiescence or physical-scope claim.
2026-10-27 10:48:46 +08:00
Lancy 03c406a5a4 fix(studio): keep file Save compact and readable 2026-10-27 09:33:58 +08:00
esengine 74805c102c docs(release-notes): studio 2.53.1 2026-10-26 18:01:26 -07:00
YHH e9268cc022 Merge pull request #14597 from esengine/fix/settings-header-no-drag
fix(studio): the full-window settings header's buttons respond to clicks
2026-10-26 18:01:04 -07:00
esengine d05faa37a7 fix(studio): the full-window settings header's buttons respond to clicks 2026-10-26 17:40:10 -07:00
YHH b0bb1588bc Merge pull request #14595 from SivanCola/codex/controller-root-routing-budget-14025
fix(control): include semantic routing in root budgets / 语义路由计入根预算
2026-10-26 17:35:56 -07:00
SivanCola b19415a2a4 fix(control): account semantic routing in the root task budget
Problem: semantic routing runs before Agent/Coordinator root preparation, so its reported usage can permit an extra executor tool round.

Root cause: Controller binds no root before routing, and SemanticRouter does not report finalized usage to the accepted context observer.

Fix: share executor root preparation after admission/hooks and before routing in headless and interactive turns. Observe semantic terminal usage once, independent of audit/sink and answer success. Preserve cache hits, new-task/scope/reset lifetimes, normal accounting and cancellation authorities.

Verification: disjoint required/rest commands pass all 239 root packages; four complete owner race suites pass 3939 events with 14 existing skips; actual Boot race passes 12 events. Exact preceding owners compile and fail 15 regression events with six passing controls. Format, vet, full lint and make check pass at byte-identical baseline. Six Windows/Linux binaries compile; local literal Windows gate verifies 21 required events/two package terminals and rejects skip/missing-terminal controls, not native execution.
2026-10-27 07:23:30 +08:00
lancy69 c5334b70f3 fix(studio): integrate pending queue into composer layout 2026-10-27 02:40:28 +08:00
Lancy 47df9ff015 fix(studio): make soft contrast lower without crossing AA 2026-10-27 02:39:29 +08:00
Lancy 4a28c20aad feat(theme): add per-scheme backdrop surface opacity 2026-10-27 02:39:02 +08:00
Lancy 0aedda5da2 feat(studio): expose wallpaper settings and selected preview 2026-10-27 02:36:36 +08:00
esengine 51f2b1b5ec feat(providers): a provider can hide the prefix all its models share
The prefix is read off the entry's own model list up to its last slash and left
out of how models are drawn, in every listing; ids and refs never change, an
alias wins, and models that would read the same keep their prefix. Fixes #14573.
2026-10-26 07:46:44 -07:00
esengine e062fcad00 feat(studio): the vision row lists only models declared to read images
A model chosen earlier stays visible, marked as not declared. Refs #14418.
2026-10-26 07:07:26 -07:00
SivanCola ef34969818 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:21 +08:00
SivanCola fd8729692b test(worktree): reject stat-cache-hidden source overwrites
Problem: A snapshot-only regression does not establish that the shared tree
reader also protects the source workspace before Apply publishes a candidate.

Root cause: The old cached index can hide a user's intervening edit from the
whole-tree drift check, allowing Apply to return success and overwrite it.

Fix: Exercise actual snapshot/candidate/Apply assembly on disposable real Git
repositories. Require ErrWorkspaceMoved and unchanged user bytes. Document the
existing Git driver confinement and the additional rehashing cost explicitly.

Verification: A compiling overlay of the original production file fails for
both wrong success and actual overwrite; the repaired owner passes. Full final
worktree race passes (48.247s). Broader root and static qualification records
are retained separately; production code is unchanged by this test follow-up.

Refs #14025
Refs #14133
2026-10-24 22:48:54 +08:00
SivanCola 0f943c7f7a fix(worktree): rebuild private index before capturing content
Problem: A candidate snapshot can contain old bytes after the working file
changed while preserving its size and mtime.

Root cause: Copying the source index carries Git's trusted stat cache and
assume-unchanged state into StageAll, so staging can skip the actual bytes.

Fix: Preserve the copied index's staged tree, rebuild the private index from
that tree, then stage current content. Handle an unmerged private index first.
Keep staged ignored inputs, the real conflicted index, HEAD and refs intact.
Document concrete Git/path/authority boundaries and the unchanged need for
source coordination. Include the inherited documentation-lint prerequisite.

Verification: All three cache-policy variants fail against the original
function through a compiler overlay and pass with the repair. Full worktree
race passes, plus final expanded cache and conflict regressions. Check, lint
and root vet pass. Full builtin/boot exits zero (22.243s / 444.076s tests),
using a measured 15-minute package alarm with no product-deadline change.
The exact-commit full root sweep and hosted checks remain separate evidence.

Refs #14025
Refs #14133
2026-10-24 22:37:40 +08:00
imcreazy 602c9a3e1d fix(studio): closing the last page offers a new tab 2026-10-21 07:13:30 +08:00
246 changed files with 9286 additions and 457 deletions

No files matched your search

+161
View File
@@ -223,6 +223,32 @@ 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/...
- name: test (Windows published command job owner)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -euo pipefail
evidence="$RUNNER_TEMP/published-job-owner"
mkdir -p "$evidence"
go test -count=1 -timeout=3m -json -run '^TestPublishedJob' ./internal/base/proc | tee "$evidence/positive.jsonl"
node scripts/published-job-evidence.mjs positive "$evidence/positive.jsonl"
node scripts/published-job-evidence.mjs overlay "$evidence"
set +e
go test -count=1 -timeout=3m -json -overlay="$evidence/overlay.json" -run '^TestPublishedJob' ./internal/base/proc > "$evidence/negative.jsonl"
old_result=$?
set -e
test "$old_result" -eq 1
node scripts/published-job-evidence.mjs negative "$evidence/negative.jsonl"
- name: upload Windows published command job owner evidence
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: published-job-owner-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/published-job-owner/
if-no-files-found: error
retention-days: 7
- name: test (Windows isolation owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 25
@@ -323,6 +349,52 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Controller routing root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage|EffectRoutingRootBudget)$' ./internal/runtime/capability ./internal/assembly/boot | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/capability", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
([
"TestEffectRoutingRootBudget",
"TestEffectRoutingRootBudget/balanced",
"TestEffectRoutingRootBudget/cached_continuation",
"TestEffectRoutingRootBudget/cached_new_scope",
"TestEffectRoutingRootBudget/cached_new_task",
"TestEffectRoutingRootBudget/cached_reset",
"TestEffectRoutingRootBudget/context_zero",
"TestEffectRoutingRootBudget/dual",
"TestEffectRoutingRootBudget/headless",
"TestEffectRoutingRootBudget/interactive",
"TestEffectRoutingRootBudget/missing",
"TestEffectRoutingRootBudget/unconfigured",
"TestSemanticRootUsage",
"TestSemanticRootUsage/billed_error",
"TestSemanticRootUsage/cache",
"TestSemanticRootUsage/estimated",
"TestSemanticRootUsage/invalid_answer",
"TestSemanticRootUsage/missing",
"TestSemanticRootUsage/nil_sink",
"TestSemanticRootUsage/no_observer",
"TestSemanticRootUsage/reported"
] - $tests | length) == 0
' "$RUNNER_TEMP/controller-routing-budget.jsonl"
- name: upload Windows Controller routing root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: controller-routing-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/controller-routing-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Coordinator root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
@@ -356,6 +428,35 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows resumed Goal planner budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(GoalResumeRenewsPlannerBudget|GoalNonBudgetResumeKeepsPlannerBudget|GoalTokenBudgetPausesAndResumes|EffectGoalResumeRenewsPlannerBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-planner-resume.jsonl"
jq -se '
["TestGoalResumeRenewsPlannerBudget",
"TestGoalNonBudgetResumeKeepsPlannerBudget",
"TestGoalNonBudgetResumeKeepsPlannerBudget/manual_pause",
"TestGoalNonBudgetResumeKeepsPlannerBudget/not_resumable",
"TestGoalTokenBudgetPausesAndResumes",
"TestEffectGoalResumeRenewsPlannerBudget"] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/goal-planner-resume.jsonl"
- name: upload Windows resumed Goal planner budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: goal-planner-resume-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/goal-planner-resume.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows root wall budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
@@ -567,6 +668,38 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows explicit skill root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task",
"TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope",
"TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session",
"TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero",
"TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget",
"TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview",
"TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips",
"TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero",
"TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
- name: upload Windows explicit skill root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: explicit-skill-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root triage budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
@@ -628,6 +761,34 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows local cost quote)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*|EffectLocalCostUsesOccurrenceQuote)$' ./internal/contract/event ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
jq -se '
["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak",
"TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown",
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks",
"TestEffectLocalCostUsesOccurrenceQuote", "TestEffectLocalCostUsesOccurrenceQuote/peak",
"TestEffectLocalCostUsesOccurrenceQuote/offpeak", "TestEffectLocalCostUsesOccurrenceQuote/relay"] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/local-cost-quote.jsonl"
- name: upload Windows local cost quote inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: local-cost-quote-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/local-cost-quote.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows estimated usage quotes)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
+47
View File
@@ -72,6 +72,38 @@ go run ./cmd/reasonix serve # from the repository root
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
```
## Global show/hide shortcut
In the desktop shell, **Settings → Appearance → Global show / hide shortcut**
offers one opt-in binding, disabled by default.
1. Click **Record shortcut**, then press a modifier and key combination.
Escape cancels recording.
2. Review the platform-native label, then **Apply shortcut** to register it.
3. **Clear shortcut** disables it immediately.
You can also edit the Electron accelerator directly (up to 128 characters).
The shell requires Control, Command, Alt or Super, even through IPC or when
restored from disk. Shift may accompany one of them but cannot qualify alone.
The shortcut hides a focused Studio window; otherwise it restores, shows and
focuses the existing window. It does not change in-app shortcuts or tray settings.
The primary Electron instance owns the binding and stores it in its local shell
profile. Changes apply immediately; invalid or unavailable bindings leave the
previous saved and registered binding intact.
Startup registration failures are shown in settings rather than reported as
success. An unavailable binding may be occupied, reserved by the OS, denied
permission, or unsupported by the desktop.
Native Wayland environments may not support registration with this Electron
backend; no portal authorization or cross-platform success is implied.
The binding exists only while Studio is running. It cannot launch a stopped
application and no external daemon is installed. Browser and network viewers
cannot configure a remote machine's global shortcut.
## Building and testing
```sh
@@ -79,6 +111,21 @@ make studio-test # go test ./... for this modul
cd frontend-next && pnpm typecheck && pnpm test && pnpm build
```
The Linux/X11 and macOS shortcut smoke drives the real Studio window, chord
capture, and OS key events. After building the host and frontend, run
`pnpm shortcut-live` in `electron/`. macOS uses System Events and requires
Accessibility/Automation permission for the launching process.
Use isolated `REASONIX_HOME` and `XDG_CONFIG_HOME` directories; the smoke seeds
a test provider at an unreachable loopback port.
- On Linux/X11, `REASONIX_XDOTOOL` names `xdotool`.
- On Linux/X11, `REASONIX_XMESSAGE` optionally names `xmessage` for the separate-app focus check.
- `REASONIX_SCREENSHOTS` optionally names the screenshot output directory.
This test requires a working display. Windows and Wayland are not covered by
this native smoke; successful builds do not establish native shortcut behavior.
Packaging runs through electron-builder; `release-studio.yml` drives it on all
three platforms. Linux ships a `.deb` because that is what makes Studio
self-updating there. See `docs/STUDIO_RELEASE.md`.
+2 -1
View File
@@ -10,11 +10,12 @@
"start": "electron .",
"build:host": "node packaging/build-host.js",
"build:linux-payload": "node packaging/linux-payload.js",
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs",
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs test/globalshortcut.test.mjs",
"build:app": "electron-builder --dir",
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"shortcut-live": "electron test/globalshortcut_live.js",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_live.js",
"browser-fit-pages": "electron test/browser_fit_pages.js",
+65
View File
@@ -0,0 +1,65 @@
"use strict";
const PREF_KEY = "shell-global-shortcut";
const MAX_BINDING_LENGTH = 128;
function createGlobalShortcut({ globalShortcut, binding = "", save, windowOf, showWindow }) {
let state = { binding, status: binding ? "unavailable" : "disabled" };
let closed = false;
const press = () => {
if (closed) return;
const win = windowOf();
if (win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused()) win.hide();
else showWindow();
};
function register(value) {
try {
return globalShortcut.register(value, press) ? "" : "unavailable";
} catch {
return "invalid";
}
}
function apply(value, persist) {
if (closed) return { ...state, error: "quitting" };
if (typeof value !== "string" || value.length > MAX_BINDING_LENGTH) return { ...state, error: "invalid" };
const next = value.trim();
if (next && !next.split("+").slice(0, -1).some(part => /^(CommandOrControl|CmdOrCtrl|Command|Cmd|Control|Ctrl|Alt|Option|AltGr|Super|Meta)$/i.test(part))) {
return { ...state, error: "invalid" };
}
if (next === state.binding && state.status === "registered") return { ...state };
if (next) {
const error = register(next);
if (error) return { ...state, error };
}
try {
if (persist) save(next);
} catch {
if (next) globalShortcut.unregister(next);
return { ...state, error: "save" };
}
if (state.status === "registered" && state.binding !== next) globalShortcut.unregister(state.binding);
state = { binding: next, status: next ? "registered" : "disabled" };
return { ...state };
}
return {
start() {
const result = apply(binding, false);
if (result.error) state = { binding, status: result.error };
return { ...state };
},
read: () => ({ ...state }),
apply: value => apply(value, true),
close() {
closed = true;
if (state.status === "registered") globalShortcut.unregister(state.binding);
},
};
}
function registerGlobalShortcutIPC(ipcMain, fromWindow, shortcutOf) {
const authorized = event => fromWindow(event) && event.senderFrame === event.sender.mainFrame;
ipcMain.handle("shell:global-shortcut", event => authorized(event) ? shortcutOf()?.read() ?? { binding: "", status: "unavailable" } : null);
ipcMain.handle("shell:set-global-shortcut", (event, value) => authorized(event) ? shortcutOf()?.apply(value) ?? { binding: "", status: "unavailable" } : null);
}
module.exports = { PREF_KEY, MAX_BINDING_LENGTH, createGlobalShortcut, registerGlobalShortcutIPC };
+18 -3
View File
@@ -1,5 +1,5 @@
"use strict";
const { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
const { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
const { relaunchForOzonePlatform } = require("./ozone");
// Before the instance lock: this process must hold nothing its relaunch needs.
@@ -31,7 +31,8 @@ const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor, themeSourceFor } = require("./ground");
const { loadPrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs");
const { loadPrefs, savePrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs");
const { PREF_KEY: SHORTCUT_PREF, createGlobalShortcut, registerGlobalShortcutIPC } = require("./globalshortcut");
const { stateFile, openWindow } = require("./windowstate");
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { processMemoryHandler } = require("./procmem");
@@ -90,6 +91,7 @@ let browserRelay = null;
let reload = null;
let powerGuard = null;
let logs = null;
let windowShortcut = null;
let handshaken = false;
let starting = null;
@@ -407,6 +409,7 @@ nativeTheme.on("updated", () => {
});
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
registerGlobalShortcutIPC(ipcMain, fromWindow, () => windowShortcut);
const showTextContextMenu = textContextMenuHandler(Menu, fromWindow, () => origin, uiLang);
ipcMain.handle("text:context-menu", showTextContextMenu);
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
@@ -554,7 +557,18 @@ if (!primary) {
}
app.whenReady().then(() => {
if (!primary) return;
if (!primary || quitting) return;
windowShortcut = createGlobalShortcut({
globalShortcut,
binding: savedPrefs[SHORTCUT_PREF] ?? "",
save: binding => {
const file = prefsFile(app.getPath("userData"));
savePrefs(file, { ...loadPrefs(file), [SHORTCUT_PREF]: binding });
},
windowOf: () => win,
showWindow,
});
windowShortcut.start();
installApplicationMenu(uiLang);
boot().catch((err) => {
console.error("reasonix-studio:", err.message);
@@ -601,6 +615,7 @@ module.exports = { current: () => ({ win, tray, client, origin }) };
const drainCua = createQuitGate(() => cua, () => app.quit(), error => logs.shell.line(`CUA cleanup: ${error.message}`));
app.on("before-quit", event => {
quitting = true;
windowShortcut?.close();
logs.shell.line("shell: quitting");
drainCua(event);
browserRelay?.stop();
+14 -5
View File
@@ -1,6 +1,7 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { PREF_KEY: SHORTCUT_PREF } = require("./globalshortcut");
// The page's own preferences, kept past the origin that stored them. The kernel
// listens on a fresh loopback port each launch and localStorage is keyed by
@@ -20,13 +21,12 @@ function pageSleepAfterMs(prefs) {
return prefs && prefs["rx-page-sleep"] === "on" ? PAGE_SLEEP_MS : Infinity;
}
// Anything that is not one of the page's own string preferences is dropped, in
// both directions: a hand-edited file or a page bug cannot widen what is kept.
// Only page string preferences and the shell-owned shortcut survive a disk read.
function pick(value) {
const out = {};
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
for (const [key, v] of Object.entries(value)) {
if (key.startsWith(PREFIX) && typeof v === "string") out[key] = v;
if ((key.startsWith(PREFIX) || key === SHORTCUT_PREF) && typeof v === "string") out[key] = v;
}
return out;
}
@@ -54,12 +54,15 @@ function savePrefs(file, prefs) {
// after these handlers exist.
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
ipcMain.on("prefs:load", (event) => {
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {};
});
ipcMain.on("prefs:save", (event, prefs) => {
if (fromWindow(event)) {
try {
savePrefs(fileOf(), prefs);
const current = loadPrefs(fileOf());
const next = pickPagePrefs(prefs);
if (current[SHORTCUT_PREF] !== undefined) next[SHORTCUT_PREF] = current[SHORTCUT_PREF];
savePrefs(fileOf(), next);
onSave();
} catch {
// A preference that did not save is one relaunch of lost state, not a
@@ -70,4 +73,10 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
});
}
function pickPagePrefs(prefs) {
const page = pick(prefs);
delete page[SHORTCUT_PREF];
return page;
}
module.exports = { PREFIX, PAGE_SLEEP_MS, pageSleepAfterMs, prefsFile, pick, loadPrefs, savePrefs, registerPrefs };
+2
View File
@@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
showTextContextMenu: (request) => ipcRenderer.invoke("text:context-menu", request),
// What the shell's graphics are running as this launch.
graphics: () => ipcRenderer.invoke("shell:graphics"),
globalShortcut: () => ipcRenderer.invoke("shell:global-shortcut"),
setGlobalShortcut: (binding) => ipcRenderer.invoke("shell:set-global-shortcut", binding),
// The window's processes as type and two sizes, for the memory a report may carry.
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
@@ -0,0 +1,150 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { createGlobalShortcut, registerGlobalShortcutIPC, PREF_KEY } = require("../src/globalshortcut");
const { savePrefs, loadPrefs, registerPrefs } = require("../src/prefs");
function fixture(binding = "") {
const registered = new Map();
const saved = [];
const win = { visible: true, focused: true, minimized: false, destroyed: false,
isVisible() { return this.visible; }, isFocused() { return this.focused; },
isMinimized() { return this.minimized; }, isDestroyed() { return this.destroyed; },
hide() { this.visible = false; this.focused = false; },
};
let shown = 0;
let failSave = false;
const api = {
register(key, callback) {
if (key === "Control+invalid") throw new TypeError("invalid accelerator");
if (key === "Control+occupied") return false;
registered.set(key, callback); return true;
},
unregister(key) { registered.delete(key); },
};
const shortcut = createGlobalShortcut({ globalShortcut: api, binding,
save(value) { if (failSave) throw new Error("disk full"); saved.push(value); },
windowOf: () => win, showWindow() { shown++; win.visible = true; win.focused = true; win.minimized = false; },
});
return { shortcut, registered, saved, win, get shown() { return shown; }, failSave() { failSave = true; } };
}
test("starts disabled without claiming an OS binding and restores a saved binding before any renderer", () => {
const off = fixture();
assert.deepEqual(off.shortcut.start(), { binding: "", status: "disabled" });
assert.equal(off.registered.size, 0);
const on = fixture("Control+Space");
assert.deepEqual(on.shortcut.start(), { binding: "Control+Space", status: "registered" });
assert.equal(on.registered.size, 1);
assert.deepEqual(on.saved, []);
});
test("bare keys and Shift-only chords cannot claim machine-wide input through apply or restored preferences", () => {
for (const binding of ["C", "Space", "F9", "Plus", " Shift ", "Shift+A", "Shift+Space"]) {
const restored = fixture(binding);
assert.deepEqual(restored.shortcut.start(), { binding, status: "invalid" });
assert.equal(restored.registered.size, 0);
const f = fixture("Control+Space"); f.shortcut.start();
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error: "invalid" });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
assert.deepEqual(f.saved, []);
}
for (const modifier of ["CommandOrControl", "CmdOrCtrl", "Command", "Cmd", "Control", "Ctrl", "Alt", "Option", "AltGr", "Super", "Meta"]) {
const f = fixture();
assert.equal(f.shortcut.apply(`${modifier}+Space`).status, "registered");
assert.equal(f.shortcut.apply(`${modifier}+Shift+Space`).status, "registered");
}
});
test("focused hides; unfocused, hidden and minimized show through the existing path", () => {
for (const changed of [{}, { focused: false }, { visible: false, focused: false }, { minimized: true }]) {
const f = fixture("Control+Space"); f.shortcut.start(); Object.assign(f.win, changed);
f.registered.get("Control+Space")();
assert.equal(f.shown, Object.keys(changed).length ? 1 : 0);
assert.equal(f.win.visible, Object.keys(changed).length > 0);
}
});
test("failed rebind preserves both registration and saved choice, successful replacement removes only the old binding", () => {
const f = fixture("Control+Space"); f.shortcut.start();
for (const [binding, error] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"], [42, "invalid"]]) {
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
assert.deepEqual(f.saved, []);
}
assert.deepEqual(f.shortcut.apply(" Control+Shift+Space "), { binding: "Control+Shift+Space", status: "registered" });
assert.deepEqual([...f.registered.keys()], ["Control+Shift+Space"]);
assert.deepEqual(f.saved, ["Control+Shift+Space"]);
f.shortcut.apply("");
assert.equal(f.registered.size, 0);
assert.deepEqual(f.saved, ["Control+Shift+Space", ""]);
});
test("disk failure rolls back the prospective registration, including disabling", () => {
const f = fixture("Control+Space"); f.shortcut.start(); f.failSave();
for (const value of ["Control+Shift+Space", ""]) {
assert.deepEqual(f.shortcut.apply(value), { binding: "Control+Space", status: "registered", error: "save" });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
}
});
test("startup failure is honest and may be replaced or disabled", () => {
for (const [binding, status] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"]]) {
const f = fixture(binding);
assert.deepEqual(f.shortcut.start(), { binding, status });
assert.equal(f.registered.size, 0);
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
}
});
test("quit unregisters its binding, suppresses queued presses and refuses new registration", () => {
const f = fixture("Control+Space"); f.shortcut.start();
const queued = f.registered.get("Control+Space");
f.shortcut.close(); f.shortcut.close(); queued();
assert.equal(f.registered.size, 0); assert.equal(f.shown, 0); assert.equal(f.win.visible, true);
assert.equal(f.shortcut.apply("Control+Shift+Space").error, "quitting");
});
test("only the authorized Studio main frame can read or immediately apply a shortcut", () => {
const f = fixture(); f.shortcut.start();
const handlers = new Map(); const sender = { mainFrame: {} };
registerGlobalShortcutIPC({ handle: (key, fn) => handlers.set(key, fn) }, event => event.sender === sender, () => f.shortcut);
for (const event of [{ sender: { mainFrame: {} }, senderFrame: {} }, { sender, senderFrame: {} }]) {
assert.equal(handlers.get("shell:global-shortcut")(event), null);
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space"), null);
}
assert.equal(f.registered.size, 0);
const event = { sender, senderFrame: sender.mainFrame };
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space").status, "registered");
assert.equal(handlers.get("shell:global-shortcut")(event).binding, "Control+Space");
assert.deepEqual(handlers.get("shell:set-global-shortcut")(event, " ".repeat(128) + "Control+Space"), { binding: "Control+Space", status: "registered", error: "invalid" });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
assert.deepEqual(f.saved, ["Control+Space"]);
});
test("page preference flushing cannot overwrite the shell-owned saved binding", t => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-shortcut-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const file = path.join(dir, "window-prefs.json");
savePrefs(file, { [PREF_KEY]: "Control+Space", "rx-lang": "en" });
const handlers = {};
registerPrefs({ on: (key, fn) => handlers[key] = fn }, () => file, () => true);
const event = {};
handlers["prefs:load"](event);
assert.deepEqual(event.returnValue, { "rx-lang": "en" });
handlers["prefs:save"](event, { "rx-lang": "zh", [PREF_KEY]: "occupied" });
assert.deepEqual(loadPrefs(file), { "rx-lang": "zh", [PREF_KEY]: "Control+Space" });
});
test("oversized restored preferences never reach native registration", () => {
const binding = "Control+" + "x".repeat(128);
const f = fixture(binding);
assert.deepEqual(f.shortcut.start(), { binding, status: "invalid" });
assert.equal(f.registered.size, 0);
assert.deepEqual(f.saved, []);
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
});
@@ -0,0 +1,115 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync, spawn } = require("node:child_process");
const { app, globalShortcut } = require("electron");
const { wait } = require("./livekit");
const home = process.env.REASONIX_HOME;
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native shortcut smoke");
fs.mkdirSync(home, { recursive: true });
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
const { current } = require("../src/main");
const input = process.env.REASONIX_XDOTOOL || "xdotool";
const binding = "Control+Shift+F9";
let neighbour;
async function until(read, description) {
const end = Date.now() + 40000;
while (Date.now() < end) {
if (await read()) return;
await wait(100);
}
throw new Error(`Timed out: ${description}`);
}
async function run() {
await until(() => current().win && !current().win.webContents.isLoading(), "Studio window");
const win = current().win;
const js = source => win.webContents.executeJavaScript(source);
await until(() => js('!!document.querySelector(".chrome")'), "real Studio workbench");
await until(() => js('!document.getElementById("boot")'), "startup overlay finishes");
assert.equal(await js('window.reasonixHost.globalShortcut().then(value => value.status)'), "disabled");
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
await until(() => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'), "settings navigation");
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
await until(() => js('!!document.querySelector("#set-global-shortcut input:not(:disabled)")'), "shortcut settings");
const out = process.env.REASONIX_SCREENSHOTS;
async function screenshots(stage) {
if (!out) return;
fs.mkdirSync(out, { recursive: true });
for (const width of [1280, 390]) {
win.setSize(1280, 1000);
win.webContents.enableDeviceEmulation({ screenPosition: "desktop", screenSize: { width, height: 1000 }, viewSize: { width, height: 1000 }, deviceScaleFactor: 1, scale: 1 });
for (const mode of ["light", "dark"]) {
await js(`document.querySelector('[data-action="appearance.scheme"][data-value="${mode}"]').click()`);
await js('document.querySelector("#set-global-shortcut").scrollIntoView({block:"center"})');
await wait(350);
const png = await win.webContents.capturePage({ x: 0, y: 0, width, height: 1000 });
fs.writeFileSync(path.join(out, `${stage}-${mode}-${width}.png`), png.toPNG());
}
}
win.webContents.disableDeviceEmulation();
}
await screenshots("before-disabled");
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="record"]\').click()');
win.webContents.sendInputEvent({ type: "keyDown", keyCode: "F9", modifiers: ["control", "shift"] });
win.webContents.sendInputEvent({ type: "keyUp", keyCode: "F9", modifiers: ["control", "shift"] });
await until(() => js(`document.querySelector("#set-global-shortcut input").value === "${binding}"`), "captured modifier chord");
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="apply"]\').click()');
await until(() => js('window.reasonixHost.globalShortcut().then(value => value.status === "registered")'), "real OS registration");
assert.equal(globalShortcut.isRegistered(binding), true);
console.log(`ok: real Studio settings capture/apply registered the native ${process.platform} binding`);
await screenshots("after-registered");
if (process.platform === "darwin") {
execFileSync("osascript", ["-e", `tell application "System Events" to set frontmost of first application process whose unix id is ${process.pid} to true`], { timeout: 10000 });
}
win.show(); win.focus();
await until(() => win.isFocused(), "focused Studio window");
const press = () => process.platform === "darwin"
? execFileSync("osascript", ["-e", 'tell application "System Events" to key code 101 using {control down, shift down}'], { timeout: 10000 })
: execFileSync(input, ["key", "--clearmodifiers", "ctrl+shift+F9"]);
press();
await until(() => !win.isVisible(), "OS key hides focused Studio");
press();
await until(() => win.isVisible() && win.isFocused(), "OS key restores hidden Studio");
console.log("ok: real OS key hides the focused window and restores it from hidden");
win.minimize();
await until(() => win.isMinimized(), "minimized Studio");
press();
await until(() => !win.isMinimized() && win.isVisible() && win.isFocused(), "OS key restores minimized Studio");
console.log("ok: real OS key restores and focuses the minimized window");
if (process.env.REASONIX_XMESSAGE) {
neighbour = spawn(process.env.REASONIX_XMESSAGE, ["-title", "Reasonix shortcut smoke neighbour", "-buttons", "OK", "Separate native application"], { stdio: "ignore" });
await until(() => {
try { return execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim(); } catch { return false; }
}, "external application window");
const id = execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim().split("\n")[0];
execFileSync(input, ["windowfocus", "--sync", id]);
await until(() => !win.isFocused() && win.isVisible(), "external application takes focus");
press();
await until(() => win.isFocused() && win.isVisible(), "OS key raises Studio from another app");
console.log("ok: real OS key raises visible, unfocused Studio while a separate native application has focus");
neighbour.kill();
}
assert.equal(globalShortcut.register("Control+Shift+F10", () => {}), true);
const failed = await js('window.reasonixHost.setGlobalShortcut("Control+Shift+F10")');
assert.deepEqual(failed, { binding, status: "registered", error: "unavailable" });
globalShortcut.unregister("Control+Shift+F10");
const saved = JSON.parse(fs.readFileSync(path.join(app.getPath("userData"), "window-prefs.json"), "utf8"));
assert.equal(saved["shell-global-shortcut"], binding);
assert.equal((await js('window.reasonixHost.setGlobalShortcut("C")')).error, "invalid");
assert.equal(globalShortcut.isRegistered("C"), false);
assert.equal((await js('window.reasonixHost.setGlobalShortcut("invalid shortcut value")')).error, "invalid");
assert.equal((await js('window.reasonixHost.setGlobalShortcut("")')).status, "disabled");
assert.equal(globalShortcut.isRegistered(binding), false);
console.log("ok: native conflict/invalid entries preserve prior binding and disabling unregisters it");
}
run().then(() => { app.quit(); }).catch(error => {
neighbour?.kill();
console.error(error.stack || error);
app.exit(1);
});
+1 -1
View File
@@ -84,7 +84,7 @@ async function run() {
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
}))()`);
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openWorkspace", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
+9
View File
@@ -7,6 +7,15 @@ reviewed: 2026-09-22
# Reasonix Studio frontend
## Feedback contact
- Studio prefills new reports with the contact value from the last successful
submission, stored in this device's local browser storage.
- The field stays editable; submitting it empty clears the remembered value.
Failed submissions do not replace it.
- An unsent draft takes precedence while the page is open.
- No feedback body or screenshots are persisted by this preference.
## Tests
`pnpm test` runs one worker per test file on the threads pool. Threads rather
+1
View File
@@ -49,6 +49,7 @@ node perf/queue.mjs # 待送达:正文读不回来时,「改」不许
node perf/reask.mjs # 改写框:没有换行的长段落,框要长到让人读得到全文,宽屏和手机宽度各一遍
node perf/composer-reflow.mjs # Draft and skill-chip wrapping during column transitions, interface zoom and native scrollbar gutters
node perf/composer-toolbar-row.mjs # The buttons under the composer stay on one row from 900 to 400 px of conversation width, with the widest realistic labels
node perf/workbench-save.mjs # File toolbar sizing, Save contrast and edit/save/read states: light/dark × Chinese/English × desktop/browser × wide/phone
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
+8 -3
View File
@@ -164,7 +164,7 @@ const settled = async (page) => {
};
const found = new Map();
for (const scheme of ["light", "dark"]) {
for (const scheme of ["light", "dark"]) for (const tier of ["", "soft"]) {
const page = await browser.newPage({ viewport: { width: 1512, height: 950 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
// The receipt card is off unless a machine asked for it, and an unrendered
@@ -177,6 +177,11 @@ for (const scheme of ["light", "dark"]) {
});
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate(({ scheme, tier }) => {
document.documentElement.dataset.theme = scheme;
if (tier) document.documentElement.dataset.contrast = tier;
else delete document.documentElement.dataset.contrast;
}, { scheme, tier });
await page.waitForTimeout(400);
// 一屏里画不出所有状态:审批、提问、失败的调用、设置页各自带着自己的配色。
@@ -223,11 +228,11 @@ for (const scheme of ["light", "dark"]) {
f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: ["只在 darwin 验过"], unverified: ["Windows"], saysSomething: true } });
});
await settled(page);
for (const row of await sweep(page, scheme, "工作台")) found.set(row.key, row);
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "工作台")) found.set(row.key, row);
await page.locator('[data-action="chrome.settings"]').first().click();
await settled(page);
for (const row of await sweep(page, scheme, "设置")) if (!found.has(row.key)) found.set(row.key, row);
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "设置")) if (!found.has(row.key)) found.set(row.key, row);
await page.close();
}
+3 -1
View File
@@ -383,7 +383,8 @@ class BenchPort extends MockPort {
// Lines the person queued: the panel leaves out the host's own queued
// continuations, so a seed of those would draw nothing.
intent: (QUEUE_IMAGES && i === 0 ? "steer" : "followup") as "steer" | "followup",
state: "queued" as const,
state: QUEUE_STATES && i < 2 ? (i === 0 ? "blocked" as const : "uncertain" as const) : "queued" as const,
...(QUEUE_STATES && i < 2 ? { blockReason: "This queued instruction needs review before delivery. ".repeat(5) } : {}),
...(QUEUE_IMAGES && i === 0 ? { images: true } : {}),
// 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板
// (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes,
@@ -498,6 +499,7 @@ const SWITCH_MS = Number(query.get("switchms") ?? 0);
// ?queue= is how many lines are already waiting when the window opens.
const QUEUE = Number(query.get("queue") ?? 0);
const QUEUE_BODY = query.has("queuebody");
const QUEUE_STATES = query.has("queuestates");
// ?queueimages makes the first seeded line a steer that carries a picture.
const QUEUE_IMAGES = query.has("queueimages");
const JOBS = Number(query.get("jobs") ?? 0);
@@ -0,0 +1,61 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const url = process.env.PERF_URL ?? "http://127.0.0.1:4407/perf.html";
const shots = process.env.QUEUE_SHOTS ?? "/tmp/queue-14497-shots";
const baseline = process.env.QUEUE_BASELINE === "1";
mkdirSync(shots, { recursive: true });
const browser = await chromium.launch();
const failures = [];
for (const theme of ["light", "dark"]) {
for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: "zh-CN" });
const page = await context.newPage();
page.on("pageerror", (e) => failures.push(e.message));
await page.goto(`${url}?queue=12&queuebody&queuestates&turns=8`, { waitUntil: "networkidle" });
await page.locator(".qi").first().waitFor();
await page.evaluate((theme) => { document.documentElement.dataset.theme = theme; }, theme);
await page.waitForTimeout(500);
const geometry = await page.evaluate(() => {
const list = document.querySelector(".qitems");
const row = document.querySelector(".qi");
const style = getComputedStyle(row);
return { border: style.borderTopWidth, height: row.getBoundingClientRect().height,
scrolls: list.scrollHeight > list.clientHeight, visibleHeight: list.clientHeight, width: document.documentElement.scrollWidth,
background: style.backgroundColor, gap: getComputedStyle(list).gap };
});
console.log(`${baseline ? "before" : "after"}/${theme}/${width}`, geometry);
await page.screenshot({ path: `${shots}/${baseline ? "before" : "after"}-${theme}-${width}.png` });
if (baseline) {
assert.notEqual(geometry.border, "0px", "baseline must expose the standalone row border");
} else {
assert.equal(geometry.border, "0px");
assert.ok(geometry.height >= 36 && geometry.height <= 40, JSON.stringify(geometry));
assert.equal(geometry.gap, "2px");
assert.ok(geometry.scrolls);
assert.ok(geometry.width <= width);
assert.equal(await page.getByRole("combobox", { name: "任务输入" }).getAttribute("placeholder"), "继续输入以排队后续修改");
const row = page.locator(".qi").first();
assert.equal(await row.locator(".qwhy").getAttribute("title"), "This queued instruction needs review before delivery. ".repeat(5));
assert.ok(geometry.visibleHeight >= 180 && geometry.visibleHeight <= 208, JSON.stringify(geometry));
await row.getByRole("button", { name: "改", exact: true }).focus();
assert.equal(await row.getByRole("button", { name: "下移", exact: true }).evaluate((el) => getComputedStyle(el).opacity), "1");
await page.screenshot({ path: `${shots}/focus-${theme}-${width}.png` });
await row.getByRole("button", { name: "改", exact: true }).click();
await page.locator(".qedit").waitFor();
assert.ok(await page.locator(".qedit").inputValue());
await page.screenshot({ path: `${shots}/edit-${theme}-${width}.png` });
await page.locator(".qedit").press("Escape");
assert.equal(await page.locator(".qedit").count(), 0);
assert.equal(await row.getByRole("button", { name: "取回", exact: true }).getAttribute("title"), "取回");
await page.locator(".qitems").evaluate((el) => { el.scrollTop = el.scrollHeight; });
await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).focus();
assert.ok(await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).isVisible());
}
await context.close();
}
}
await browser.close();
assert.deepEqual(failures, []);
console.log("Queue layout browser matrix passed");
+2 -2
View File
@@ -38,7 +38,7 @@ check("播出来的行在", (await rows.count()) === 3, `${await rows.count()}
const first = rows.nth(0);
const wasFirst = await first.locator(".pv").innerText();
await first.locator('button:has-text("改")').click();
await first.getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(500);
const box = await page.locator(".queue .qedit").count();
@@ -50,7 +50,7 @@ check("这一行说了为什么", await why.isVisible().catch(() => false), (awa
// 说明是这一行的,不是面板的:换一行再点,上一行的说明得跟着走。一条粘住不动的
// 提示会让下一次失败看起来像上一次的余音。
const second = rows.nth(1);
await second.locator('button:has-text("改")').click();
await second.getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(500);
check("说明跟着被点的那一行走", (await first.locator(".qwhy[data-err]").count()) === 0);
check("新的那一行才带着说明", await second.locator(".qwhy[data-err]").isVisible().catch(() => false));
+2 -2
View File
@@ -73,7 +73,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
await page.goto(`${PAGE}?queue=2&queuebody`, { waitUntil: "networkidle" });
await page.waitForSelector(".queue .qi", { timeout: 15000 });
await page.waitForTimeout(600);
await page.locator('.queue .qi').first().locator('button:has-text("改")').click();
await page.locator('.queue .qi').first().getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(600);
const q = await page.evaluate(() => {
const el = document.querySelector(".queue .qedit");
@@ -92,7 +92,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
await page.goto(`${PAGE}?queue=4&queuebody`, { waitUntil: "networkidle" });
await page.waitForSelector(".queue .qi", { timeout: 15000 });
await page.waitForTimeout(600);
await page.locator(".queue .qi").nth(1).locator('button:has-text("改")').click();
await page.locator(".queue .qi").nth(1).getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(600);
if (fill) { await page.locator(".queue .qedit").fill(fill); await page.waitForTimeout(400); }
const g = await page.evaluate(() => {
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shots = process.env.SURFACE_SCREENSHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
const errors = [];
const surfaces = ["nav", "rail", "chrome", "side", "compose"];
const opacity = {
dark: { nav: 0.45, rail: 0.46, chrome: 0.8, side: 0.81, compose: 0.88 },
light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 },
};
try {
for (const scheme of ["light", "dark"]) for (const width of [1600, 390]) {
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 1000 }, reducedMotion: "reduce" });
await context.addInitScript((scheme) => localStorage.setItem("rx-theme", scheme), scheme);
const page = await context.newPage();
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/themes/bench-photo/background", (route) => route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="1600" height="1000" fill="#638c99"/><circle cx="1100" cy="400" r="400" fill="#ddbf88"/></svg>' }));
await page.goto(`${base}?pack=photo&ws=1&sess=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({ content: "* { transition: none !important; animation: none !important; caret-color: transparent !important }" });
// Details no longer mount .side in the dock layout; phone omits .nav.
// Exercise their actual stylesheet selectors without changing visible UI.
await page.evaluate((surfaces) => {
for (const name of surfaces) if (!document.querySelector(`.${name}`)) {
const probe = document.createElement("aside");
probe.className = name;
probe.style.cssText = "position:fixed;left:-9999px;width:10px;height:10px";
document.body.append(probe);
}
}, surfaces);
const styles = () => page.evaluate((surfaces) => Object.fromEntries(surfaces.map((name) => {
const style = getComputedStyle(document.querySelector(`.${name}`));
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d");
ctx.fillStyle = style.backgroundColor;
ctx.fillRect(0, 0, 1, 1);
return [name, { alpha: ctx.getImageData(0, 0, 1, 1).data[3] / 255, background: style.backgroundColor, blur: style.backdropFilter, shadow: style.textShadow }];
})), surfaces);
const paint = async (surfaceOpacity, backdrop = "photo") => page.evaluate(async ({ surfaceOpacity, backdrop, scheme }) => {
const { apply } = await import("/src/ui/theme.ts");
const tokens = {
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
};
apply({ id: "bench-photo", name: "Surface fixture", tokens, background: { image: backdrop === "photo", focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4, surfaceOpacity }, ...(backdrop === "sky" ? { sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } } : {}) }, scheme);
}, { surfaceOpacity, backdrop, scheme });
const baseline = await styles();
await paint({});
assert.deepEqual(await styles(), baseline, "omission keeps every computed paint exactly");
const empty = await page.screenshot({ animations: "disabled" });
await paint(undefined);
assert.deepEqual(await page.screenshot({ animations: "disabled" }), empty, "omission keeps pixels exactly");
await paint(opacity);
const custom = await styles();
for (const name of surfaces) {
assert.ok(Math.abs(custom[name].alpha - opacity[scheme][name]) <= 1 / 255, `${scheme} ${name}: ${custom[name].background}`);
assert.equal(custom[name].blur, baseline[name].blur);
assert.equal(custom[name].shadow, baseline[name].shadow);
}
if (shots) await page.screenshot({ path: `${shots}/${scheme}-${width}-after.png`, animations: "disabled" });
await paint(opacity, "sky");
for (const [name, value] of Object.entries(await styles())) assert.ok(Math.abs(value.alpha - opacity[scheme][name]) <= 1 / 255, `sky ${name}`);
await paint(opacity, "none");
const withoutBackdrop = await styles();
await paint(undefined, "none");
assert.deepEqual(await styles(), withoutBackdrop, "data-bg off ignores surface map");
await paint({ [scheme]: { nav: 0, compose: 1 } });
const boundaries = await styles();
assert.equal(boundaries.nav.alpha, 0);
assert.equal(boundaries.compose.alpha, 1);
for (const name of ["rail", "chrome", "side"]) assert.equal(boundaries[name].background, baseline[name].background, `omitted ${name}`);
await paint(undefined);
assert.deepEqual(await styles(), baseline, "switching to an old pack clears custom paint");
await page.evaluate(async () => (await import("/src/ui/theme.ts")).apply(null, "light"));
assert.equal(await page.locator("#pack-theme").count(), 0);
assert.equal(await page.evaluate(() => document.documentElement.dataset.bg), undefined);
console.log(`ok ${scheme} ${width}: five computed alphas; exact omitted pixels; sky, backdrop removal, boundaries and pack clearing`);
await context.close();
}
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
+84 -12
View File
@@ -1,21 +1,31 @@
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
// as the default palette stepped once, and as the recorded floors below (ratios
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
// Built-in soft deliberately lowers contrast without crossing AA, including
// small ghost text. Unset and the higher tiers retain their recorded palettes.
// Custom packs keep authored inks for soft; see TOKENS.md for that boundary.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
const INKS = Object.keys(STEPS);
const GROUNDS = ["--page", "--surface"];
const GROUNDS = ["--page", "--surface", "--raised", "--overlay", "--float", "--float-hi"];
const TIERS = ["soft", "normal", "strong"];
const FLOOR = {
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
};
const SNAPSHOT = {
light: {
"": ["#292b29", "#5e625c", "#666a63"],
normal: ["oklch(.245 .0218 85)", "oklch(.34 .0196 85)", "oklch(.405 .0196 85)"],
strong: ["oklch(.103 .0196 85)", "oklch(.26 .0184 85)", "oklch(.335 .0184 85)"],
},
dark: {
"": ["#e7e8e3", "#b4b7b0", "#8e9289"],
normal: ["oklch(.9588 .006756 115.3)", "oklch(.81 .01031 125.5)", "oklch(.728 .013 255)"],
strong: ["oklch(1 .006756 115.3)", "oklch(.893 .011 255)", "oklch(.806 .012 255)"],
},
};
const fails = [];
const check = (name, ok, detail = "") => {
@@ -29,7 +39,7 @@ page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS, SNAPSHOT }) => {
const root = document.documentElement;
const probe = document.createElement("div");
document.body.append(probe);
@@ -61,6 +71,15 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
root.dataset.theme = scheme;
delete root.dataset.contrast;
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
for (const [tier, expected] of Object.entries(SNAPSHOT[scheme])) {
if (tier) root.dataset.contrast = tier;
else delete root.dataset.contrast;
for (const [i, ink] of INKS.entries()) {
probe.style.color = expected[Math.min(i, 2)];
const ref = rgb(getComputedStyle(probe).color);
out[`${scheme}/${tier || "unset"}/${ink}/snapshot`] = { got: rgb(read(ink)).join(","), ref: ref.join(",") };
}
}
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
for (const tier of TIERS) {
const k = TIERS.indexOf(tier);
@@ -70,21 +89,74 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
const ref = rgb(getComputedStyle(probe).color);
const got = rgb(read(ink));
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), base: ratio(rgb(base[ink]), grounds[g]), got: ratio(got, grounds[g]) };
}
}
}
probe.remove();
return out;
}, { STEPS, INKS, GROUNDS, TIERS });
}, { STEPS, INKS, GROUNDS, TIERS, SNAPSHOT });
for (const [key, { ref, got }] of Object.entries(measured)) {
for (const [key, { ref, base, got }] of Object.entries(measured)) {
const [scheme, tier, ink, ground] = key.split("/");
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
if (ground === "snapshot") {
check(`${key} stays unchanged`, got === ref, `${got} vs ${ref}`);
continue;
}
if (tier === "soft") {
const lowers = ink === "--text" || ink === "--muted";
check(`${key} ${lowers ? "is genuinely softer than" : "retains the AA floor of"} unset`, lowers ? got < base - 0.1 : Math.abs(got - base) < 0.005, `${got.toFixed(2)} vs ${base.toFixed(2)}`);
} else {
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
}
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
}
const explicit = await page.evaluate((INKS) => {
const root = document.documentElement;
const p = document.createElement("div");
document.body.append(p);
const read = () => INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
const out = {};
for (const scheme of ["light", "dark"]) {
root.dataset.theme = scheme;
for (const tier of ["soft", "normal", "strong"]) {
root.dataset.contrast = tier;
out[`${scheme}/${tier}`] = read();
}
}
p.remove();
return out;
}, INKS);
for (const system of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: system, contrast: "more" });
for (const [key, expected] of Object.entries(explicit)) {
const got = await page.evaluate(({ key, INKS }) => {
const [scheme, tier] = key.split("/");
document.documentElement.dataset.theme = scheme;
document.documentElement.dataset.contrast = tier;
const p = document.createElement("div");
document.body.append(p);
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
p.remove();
return out;
}, { key, INKS });
check(`${key} outranks system ${system} and prefers-contrast: more`, JSON.stringify(got) === JSON.stringify(expected));
}
}
await page.emulateMedia({ colorScheme: "dark", contrast: "no-preference" });
const automaticDark = await page.evaluate((INKS) => {
delete document.documentElement.dataset.theme;
document.documentElement.dataset.contrast = "soft";
const p = document.createElement("div");
document.body.append(p);
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
p.remove();
return out;
}, INKS);
check("system dark soft matches explicit dark soft", JSON.stringify(automaticDark) === JSON.stringify(explicit["dark/soft"]));
for (const scheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
const same = await page.evaluate((scheme) => {
@@ -23,6 +23,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
await page.evaluate(() => document.getElementById("prefs-tools-web")?.click());
await page.waitForSelector("#set-web-access");
const r = await page.evaluate(() => {
const g = document.querySelector("#set-web-access");
@@ -0,0 +1,119 @@
import { mkdirSync } from "node:fs";
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.SHOTS_DIR;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
if (!ok) fails.push(name);
};
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
const measure = (page) => page.locator(".workbench-save").evaluate((button) => {
const style = getComputedStyle(button);
const bar = button.parentElement;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const rgba = (color) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
return [...ctx.getImageData(0, 0, 1, 1).data].map((v, i) => i === 3 ? v / 255 : v);
};
const over = (front, back) => front.slice(0, 3).map((v, i) => v * front[3] + back[i] * (1 - front[3]));
const ancestors = [];
for (let node = button.parentElement; node; node = node.parentElement) ancestors.unshift(node);
let ground = [255, 255, 255];
for (const node of ancestors) ground = over(rgba(getComputedStyle(node).backgroundColor), ground);
const opacity = Number(style.opacity);
const fill = over(rgba(style.backgroundColor), ground);
const ink = over(rgba(style.color), fill);
const lum = (rgb) => rgb.map((v) => {
v /= 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
const [hi, lo] = [lum(over([...ink, opacity], ground)), lum(over([...fill, opacity], ground))].sort((a, b) => b - a);
const text = [...button.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
const range = document.createRange();
range.selectNodeContents(text);
const width = button.getBoundingClientRect().width;
const natural = range.getBoundingClientRect().width + button.querySelector("svg").getBoundingClientRect().width
+ parseFloat(style.columnGap) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
+ parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
const rect = bar.getBoundingClientRect();
const children = [...bar.children].map((node) => node.getBoundingClientRect());
return {
width, natural, contrast: (hi + 0.05) / (lo + 0.05), disabled: button.disabled,
singleRow: Math.max(...children.map((r) => r.top)) < Math.min(...children.map((r) => r.bottom)),
contained: children.every((r) => r.left >= rect.left - 1 && r.right <= rect.right + 1),
};
});
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
for (const locale of ["zh", "en"]) {
for (const width of [1440, 390]) {
for (const desktop of [true, false]) {
const page = await browser.newPage({ viewport: { width, height: 1000 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(15000);
page.on("pageerror", (error) => fails.push(error.message));
// Native capabilities render both optional toolbar controls; no native action is invoked.
if (desktop) await page.addInitScript(() => {
window.reasonixHost = {
shell: "electron", platform: "win32", titleBar: false,
isWindowMaximised: async () => false, setUnreadBadge: async () => {},
revealPath: async () => null, revealWorkspace: async () => null,
};
});
const url = new URL(BASE);
url.searchParams.set("pref", locale);
url.searchParams.set("ws", "2");
url.searchParams.set("sess", "6");
url.searchParams.set("turns", "2");
await page.goto(url.href, { waitUntil: "networkidle" });
const workbench = page.locator('[data-action="pane.view"][data-value="browser"]');
if (!(await workbench.isVisible())) await page.locator('[data-action="chrome.more"]').click();
await workbench.click();
await page.locator('[data-action="workbench.launch-files"]').click();
await page.locator(".workbench-filelist button").filter({ hasText: "README.md" }).click();
await page.locator(".workbench-save").waitFor();
await page.evaluate(() => document.fonts.ready);
const tag = `${scheme}/${locale}/${width}/${desktop ? "desktop" : "browser"}`;
const verify = async (state) => {
await page.locator(".workbench-save").evaluate(async (button) => {
await Promise.all(button.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
const result = await measure(page);
check(`${tag}/${state}: content-sized Save`, result.width <= result.natural + 1, `${result.width.toFixed(1)}px vs ${result.natural.toFixed(1)}px content`);
check(`${tag}/${state}: controls share one row`, result.singleRow);
check(`${tag}/${state}: controls stay inside toolbar`, result.contained);
check(`${tag}/${state}: readable label`, result.contrast >= 4.5, `${result.contrast.toFixed(2)}:1`);
check(`${tag}/${state}: native disabled state`, result.disabled === (state !== "modified"));
if (SHOTS && desktop && locale === "zh") await page.screenshot({ path: `${SHOTS}/${scheme}-${width}-${state}.png` });
};
await verify("unchanged");
await page.locator('[data-action="workbench.mode"][data-value="file"]').click();
const editor = page.locator(".cm-content");
await editor.fill("# Save button regression\n\nChanged in the editor.");
await page.locator(".workbench-save:not(:disabled)").waitFor();
await verify("modified");
await page.locator(".workbench-save").click();
await page.locator(".workbench-save:disabled").waitFor();
await verify("saved");
await page.locator('[data-action="workbench.mode"][data-value="read"]').click();
check(`${tag}: saved content remains readable`, await page.locator(".workbench-read h1").textContent() === "Save button regression");
await page.close();
}
}
}
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`${fails.length} failures: ${fails.join("; ")}`);
process.exitCode = 1;
}
+3
View File
@@ -109,6 +109,7 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.page-sleep", kind: "view", target: "none", proof: "interaction" },
@@ -296,6 +297,7 @@ export const ACTIONS: UIAction[] = [
{ id: "toast.report", kind: "view", target: "none", proof: "interaction" },
{ id: "errbar.report", kind: "view", target: "none", proof: "interaction" },
{ id: "toast.dismiss", kind: "view", target: "entity", proof: "interaction" },
{ id: "toast.act", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "model.group", kind: "view", target: "entity", proof: "interaction" },
{ id: "model.group-clear", kind: "view", target: "none", proof: "interaction" },
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
@@ -593,6 +595,7 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.page", kind: "navigation", target: "none", proof: "interaction" },
// Finding a setting changes what is on screen and nothing else: it reaches
// no kernel state, and the jump it offers is settings.section, because
// arriving from a search is the same arrival as arriving from the nav.
@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { boot, STORAGE } from "./index";
import { say } from "./kernel";
import { toolErrorDetail, toolRefusalReason } from "../ui/cards/outcome";
import type { Tool } from "../port/wire";
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
// The calls the host stops before they run. The model keeps the English text;
// the person reading the card gets the reason in their own language.
const BLOCKED = [
"shell.mixed_mutation_verification",
"shell.inline_interpreter_not_last",
"batch.skipped_after_failed_change",
"batch.check_after_stopped_call",
];
describe("calls the host stops before they run", () => {
for (const language of ["zh", "en"]) {
it(`are worded in ${language}, and the card shows that instead of the English line`, () => {
localStorage.setItem(STORAGE, language);
boot();
for (const code of BLOCKED) {
const tool = { name: "bash", args: {}, err: "blocked: raw English wording", refusalCode: code } as unknown as Tool;
expect(say({ code }, ""), code).not.toBe("");
expect(toolRefusalReason(tool), code).not.toBe("");
expect(toolErrorDetail(tool), `${code}: the English line is not drawn when the reason is`).toBe("");
}
});
}
});
+3 -4
View File
@@ -577,6 +577,9 @@ export const EN: Record<string, string> = {
// ── 会话树 ───────────────────────────────────────────────────────
"新会话": "New session",
"这个会话的历史超过了安全读取上限,没有打开(文件完好,未改动)。可以只取最近的内容,另存为新会话继续。": "This session's history is over the safe read limit, so it was not opened (the file is intact and unchanged). You can carry only its most recent part over into a new session.",
"从最近的内容继续": "Continue from the most recent part",
"已从最近的内容另存为新会话,较早的 {n} 条没有带入;原会话文件保持不变。": "Saved the most recent part as a new session; {n} earlier messages were not carried over. The original session file is unchanged.",
"标签数已到上限 {n},已把「{title}」从标签栏收起,对话仍在列表里。": "The open-session limit ({n}) was reached, so \"{title}\" was folded out of the tab bar. The conversation is still in the list.",
"打开项目文件夹": "Open project folder",
"新会话 {n}": "New session {n}",
@@ -755,9 +758,6 @@ export const EN: Record<string, string> = {
"代码块、命令、差异与工具输出共用这一等宽字体,不跟随界面主题改变。":
"Code blocks, commands, diffs and tool output share this monospace font and do not change with the interface theme.",
"等宽字体": "Monospace font",
"更多个性化": "More personalization",
"已设置自定义壁纸": "Custom wallpaper set",
"壁纸与背景位置": "Wallpaper and background position",
"文字粗细": "Text weight",
"视觉效果": "Visual effects",
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.",
@@ -966,7 +966,6 @@ export const EN: Record<string, string> = {
"返回正文": "Back to the conversation",
"完成": "Done",
"快捷键": "Keyboard shortcuts",
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。": "These work anywhere in the window. Where a chord shows ⌘, Windows and Linux use Ctrl.",
"在对话里查找": "Find in the conversation",
"搜索会话": "Search conversations",
"在提问之间移动": "Move between your questions",
@@ -112,6 +112,8 @@ export const EN_COMPOSER: Record<string, string> = {
"正在发送…": "Sending…",
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
"描述任务…": "Describe a task…",
"继续输入以排队后续修改": "Keep typing to queue further changes",
"继续输入…": "Keep typing…",
"任务输入": "Task input",
"命令与技能": "Commands and skills",
"正在加载命令与技能…": "Loading commands and skills…",
@@ -13,6 +13,10 @@ export const EN_KERNEL: Record<string, string> = {
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
"无法读取目标文件:它在你配置的禁读列表里。": "Cannot read the target file: it is on the forbidden-read list you configured.",
"这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。": "This command puts a step the host cannot confirm leaves the workspace alone together with a verification check, so it was blocked and did not run. Split it into two calls: change first, then verify on its own.",
"这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。": "The inline interpreter in this command is not its last segment, so later steps decide the exit status and a failure inside the interpreter would read as success. It was blocked and did not run. Put it last, or run it on its own.",
"同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。": "An earlier change in the same batch of calls failed or was blocked, so this step did not run. Deal with that change first, then retry.",
"同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。": "An earlier call in the same batch was stopped before it ran, so this check would not measure the state you asked for and did not run. Re-run the stopped call first, or run this check on its own.",
"无法读取目标文件:它不在这个会话允许读取的文件夹里。": "Cannot read the target file: it is outside the folders this session may read.",
"读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。": "Reading a video needs ffmpeg (with ffprobe) installed and on the system path. Install it and try again.",
"不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。": "This video format is not recognised. MP4/MOV, MKV/WebM, AVI, FLV, ASF, MPEG and Ogg containers are supported.",
@@ -477,6 +481,8 @@ export const EN_KERNEL: Record<string, string> = {
"打不开这个会话": "Could not open that conversation",
"该路径位于会话目录之外": "That path is outside the conversations directory",
"没有这个会话": "No such conversation",
"这个会话的历史超过了安全读取上限,没有打开;文件完好未改动": "This conversation's history is over the safe read limit, so it was not opened; the file is intact and unchanged",
"没能取出这个会话最近的内容": "Could not take the most recent part of this conversation",
"该地址不接受此种请求方式": "That address does not accept this request method",
"请求体必须是 application/json": "The request body must be application/json",
"这台服务器未开放权限编辑": "This server does not open permission editing",
@@ -2,6 +2,23 @@
// alone — one catalogue is still the model, and this file is read as its
// continuation: same keying by Chinese source text, same grouping by screen.
export const EN_SETTINGS: Record<string, string> = {
"全局显示 / 隐藏快捷键": "Global show / hide shortcut",
"默认关闭。仅在 Studio 运行时生效;窗口聚焦时隐藏,否则显示并聚焦。不会启动已退出的应用,也不更改窗口内快捷键。": "Off by default. While Studio is running, hide its focused window or show and focus it otherwise. Does not launch a stopped app or change in-app shortcuts.",
"快捷键组合": "Shortcut binding",
"点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。": "Click Record and press a modifier and key combination, or edit the shortcut directly. Esc cancels recording; Clear immediately disables the shortcut. Wayland or OS-reserved bindings may not be supported.",
"请按修饰键与按键组合…": "Press a modifier and key combination…",
"录制快捷键": "Record shortcut",
"应用快捷键": "Apply shortcut",
"清除快捷键": "Clear shortcut",
"全局快捷键已关闭": "Global shortcut disabled",
"已注册:{value}": "Registered: {value}",
"快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。": "Invalid accelerator. Use a modifier and key combination, e.g. CommandOrControl+Shift+Space.",
"系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。": "The OS could not register this shortcut: it may be occupied, denied permission, or unsupported by this desktop.",
"当前环境不支持全局快捷键。": "Global shortcuts are not supported in this environment.",
"快捷键未能保存;原设置保持不变。": "Could not save the shortcut; the previous setting is unchanged.",
"Studio 正在退出,未更改快捷键。": "Studio is quitting; the shortcut was not changed.",
"无法读取或更改 shell 快捷键状态。请重新打开设置后再试。": "Could not read or change shell shortcut status. Reopen settings to try again.",
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。": "These keys work inside the window; ⌘ means Ctrl on Windows and Linux. Configure global show / hide separately above, in the desktop shell only.",
"加载中…": "Loading…",
"AI 文件交付卡片": "AI file delivery cards",
"启用 AI 文件交付卡片": "Enable AI file delivery cards",
@@ -127,6 +144,7 @@ export const EN_SETTINGS: Record<string, string> = {
"项目配置覆盖了这一项;此处保存的是全局设置。": "The project config overrides this; what you set here is saved globally.",
"尚无可用搜索模型": "No search models available",
"{model}(不可用)": "{model} (unavailable)",
"{model}(未声明读图)": "{model} (not declared to read images)",
"模型用途": "Model uses",
"用途": "Use",
"使用模型": "Model",
@@ -756,6 +774,8 @@ export const EN_SETTINGS: Record<string, string> = {
"摘要请求用了 {in} 输入、{out} 输出 token,缓存命中 {hit}。": "The summary request used {in} input and {out} output tokens, {hit} from cache.",
"无需压缩:{why}": "Nothing to compact: {why}",
"压缩失败:{why}": "Compaction failed: {why}",
"你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。": "{n} earlier message(s) of yours were too large to keep whole and now survive only through the summary. Prefix a message with [[keep]] to hold it verbatim.",
"你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。": "{n} message(s) you marked [[keep]] went past the room [[keep]] has, so the oldest now survive only through the summary.",
"子代理「{task}」在等 {by} 先结束:{why}": "Sub-agent \"{task}\" is waiting for {by} to finish: {why}",
"另一个子代理": "another sub-agent",
"两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace",
@@ -1003,6 +1023,14 @@ export const EN_SETTINGS: Record<string, string> = {
"关闭窗口后在托盘中继续运行": "Keep running in the tray when the window is closed",
"关闭窗口不会中断会话和后台任务;从托盘菜单退出才会完全关闭程序": "Closing the window does not stop sessions or background tasks; use Quit in the tray menu to exit completely",
"本轮执行": "This turn",
"批准与规则": "Approval and rules",
"沙箱与执行": "Sandbox and execution",
"联网与浏览器": "Web and browser",
"子代理与协作": "Sub-agents and collaboration",
"外观与主题": "Appearance and theme",
"会话显示": "Conversation display",
"导航与侧栏": "Navigation and sidebar",
"窗口与性能": "Window and performance",
"运行环境": "Where it runs",
"记录与用量": "What it remembers",
"这台机器": "This machine",
@@ -1352,6 +1380,9 @@ export const EN_SETTINGS: Record<string, string> = {
"图标栏": "Icon rail",
"导航入口": "Navigation entries",
"放大设置窗口": "Enlarge the settings window",
"隐藏共同前缀": "Hide the shared prefix",
"{name} 的模型名前缀": "Model name prefix of {name}",
"路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。": "The same prefix a routing service puts on every model (such as group/) is left out of how they are drawn. Display only: model ids do not change, an alias you typed wins, and it takes effect on save.",
"缩小设置窗口": "Shrink the settings window",
"图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。": "The shortcuts to pages in the icon rail and the sidebar: hide any of them or change their order. Sessions, feedback, account and settings are always kept.",
"还原排列": "Reset arrangement",
@@ -31,6 +31,14 @@ export const EN_USAGE: Record<string, string> = {
"美元 USD": "US dollar (USD)",
"无法读取币种设置。": "Could not read the currency setting.",
"用量与成本": "Usage & cost",
"上传量": "Upload volume",
"发给模型服务商的请求内容大小(不含连接本身的开销,也不含下载)。每次请求都会带上完整的对话,所以它取决于上下文有多长、请求有多少次,缓存命中只省费用、不省流量。":
"The size of the request bodies sent to model providers (without the connection's own overhead, and without downloads). Every request carries the whole conversation, so it depends on how long the context is and how many requests are made; cache hits save cost, not bandwidth.",
"还没有记录:从这个版本起开始统计。": "Nothing recorded yet: counting starts with this version.",
"今天上传": "Uploaded today",
"近 {n} 天上传": "Uploaded in the last {n} days",
"{size},{n} 次请求,平均每次 {avg}": "{size} in {n} requests, {avg} each on average",
"{size}({share})": "{size} ({share})",
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
"正在读取记录…": "Reading the records…",
@@ -62,6 +70,7 @@ export const EN_USAGE: Record<string, string> = {
"花费": "Spend",
"含估算": "includes estimates",
"未定价调用未计入": "unpriced calls not included",
"其中 {n} 笔未定价": "{n} calls unpriced",
"{day} 起 · {n} 天有记录": "from {day} · {n} days recorded",
"这段时间 {tok} tokens 里有 {rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "{rate}% of these {tok} tokens hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
"命中 {a} · 未命中 {b}": "{a} hit · {b} missed",
+6
View File
@@ -123,6 +123,10 @@ const SAID: Record<string, string> = {
"workspace.root_not_granted": "这个文件夹没有授权给当前工作区",
"workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。",
"workspace.read_forbidden": "无法读取目标文件:它在你配置的禁读列表里。",
"shell.mixed_mutation_verification": "这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。",
"shell.inline_interpreter_not_last": "这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。",
"batch.skipped_after_failed_change": "同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。",
"batch.check_after_stopped_call": "同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。",
"workspace.read_outside_scope": "无法读取目标文件:它不在这个会话允许读取的文件夹里。",
"video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。",
"video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。",
@@ -170,6 +174,8 @@ const SAID: Record<string, string> = {
"session.open_failed": "打不开这个会话",
"session.outside_dir": "该路径位于会话目录之外",
"session.unknown": "没有这个会话",
"session.replay_limit": "这个会话的历史超过了安全读取上限,没有打开;文件完好未改动",
"session.tail_failed": "没能取出这个会话最近的内容",
"request.method_not_allowed": "该地址不接受此种请求方式",
"request.bad_content_type": "请求体必须是 application/json",
"permissions.editing_disabled": "这台服务器未开放权限编辑",
@@ -17,6 +17,8 @@ const PAYLOAD: Record<string, string | undefined> = {
compaction_telemetry: JSON.stringify({ mode: "summarized", source_tokens: 122525, projection_tokens: 61192, input_tokens: 40710, output_tokens: 8186, cache_hit_tokens: 256, request_count: 1 }),
context_composition: JSON.stringify({ total: 561061, at: 419430, active: 400000, parts: [{ name: "tool_output", tokens: 500000 }] }),
subagent_waiting: JSON.stringify({ task: "fix the typo", by: "rewrite the docs", whole: true }),
user_turns_dropped: JSON.stringify({ count: 5, tokens: 1578, budget: 8000 }),
kept_turns_folded: JSON.stringify({ count: 2, tokens: 900, budget: 4000 }),
display_currency: "USD",
compact_declined: undefined,
compact_failed: "summary_failed",
@@ -70,6 +72,8 @@ describe("notice wording", () => {
["job_stalled", '{"id":"j","quietSeconds":"900"}'],
["job_stalled", '{"id":"j","quietSeconds":0}'],
["compaction_telemetry", '{"mode":"summarized"}'],
["user_turns_dropped", '{"count":0}'],
["kept_turns_folded", "not json"],
["compaction_telemetry", '{"mode":"","source_tokens":1,"projection_tokens":1}'],
["compaction_telemetry", '{"mode":"summarized","source_tokens":"122525","projection_tokens":1}'],
["compact_failed", "no_such_reason"],
@@ -7,6 +7,7 @@ import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
import { JOB_FAILED, JOB_FINISHED, JOB_STARTED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice";
import { NOTICE_TEXT } from "./notices";
import { KEPT_TURNS_FOLDED, USER_TURNS_DROPPED, turnsDroppedVars } from "./turns_dropped";
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
export type NoticeVars = Record<string, string | number>;
@@ -30,6 +31,8 @@ const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefine
[COMPACTION_TELEMETRY]: compactionNoticeVars,
[CONTEXT_COMPOSITION]: contextCompositionVars,
[SUBAGENT_WAITING]: subagentWaitingVars,
[USER_TURNS_DROPPED]: turnsDroppedVars,
[KEPT_TURNS_FOLDED]: turnsDroppedVars,
display_currency: (detail) => ({ mode: detail || "auto" }),
compact_declined: compactReason("compact_declined"),
compact_failed: compactReason("compact_failed"),
@@ -51,6 +51,8 @@ export const NOTICE_TEXT: Record<string, string> = {
compact_declined: "无需压缩:{why}",
compact_failed: "压缩失败:{why}",
context_composition: "上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}",
user_turns_dropped: "你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。",
kept_turns_folded: "你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。",
subagent_waiting: "子代理「{task}」在等 {by} 先结束:{why}",
compact_held: "自动压缩暂缓,上次尝试没有完成:{why}",
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
@@ -0,0 +1,16 @@
export const USER_TURNS_DROPPED = "user_turns_dropped";
export const KEPT_TURNS_FOLDED = "kept_turns_folded";
// The typed payload these notices carry as their detail, read as the variables
// their sentences are worded from. A detail that is not one reads as none, so the
// notice keeps the kernel's English.
export function turnsDroppedVars(detail: string | undefined): Record<string, string> | undefined {
if (!detail) return undefined;
let p: { count?: unknown };
try {
p = JSON.parse(detail) as { count?: unknown };
} catch {
return undefined;
}
return typeof p.count === "number" && p.count > 0 ? { n: String(p.count) } : undefined;
}
+1
View File
@@ -9,6 +9,7 @@ import "./styles/composition-picker.css";
import "./styles/dock.css";
import "./styles/studio.css";
import "./styles/dialogcenter.css";
import "./styles/settings-type.css";
import "./styles/stophint.css";
import "./styles/phonecomposer.css";
import "./styles/tips.css";
+24
View File
@@ -24,6 +24,14 @@ export interface GraphicsInfo {
compositing: string;
}
export interface GlobalShortcutInfo {
binding: string;
status: "disabled" | "registered" | "invalid" | "unavailable" | "unsupported";
error?: "invalid" | "unavailable" | "save" | "quitting";
}
const unsupportedShortcut: GlobalShortcutInfo = { binding: "", status: "unsupported" };
export interface TextContextMenuRequest {
editable: boolean;
selected: boolean;
@@ -52,6 +60,8 @@ export interface HostPort {
captureWindow(): Promise<Uint8Array | null>;
/** How the shell draws this launch; null where it has no say, as in a tab. */
graphics(): Promise<GraphicsInfo | null>;
globalShortcut(): Promise<GlobalShortcutInfo>;
setGlobalShortcut(binding: string): Promise<GlobalShortcutInfo>;
/** The shell's own processes as a type and two sizes each, for the memory a
* report may carry; empty where there is no shell to ask, as in a tab. */
processMemory(): Promise<FeedbackProcessMemory[]>;
@@ -181,6 +191,8 @@ interface ElectronBridge {
closeWindow(): Promise<void>;
captureWindow?(): Promise<Uint8Array | null>;
graphics?(): Promise<GraphicsInfo | null>;
globalShortcut?(): Promise<GlobalShortcutInfo | null>;
setGlobalShortcut?(binding: string): Promise<GlobalShortcutInfo | null>;
processMemory?(): Promise<unknown>;
openExternal(url: string): Promise<void>;
setUnreadBadge?(unread: boolean): Promise<void>;
@@ -250,6 +262,12 @@ class ElectronHost implements HostPort {
graphics() {
return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null);
}
globalShortcut() {
return this.api.globalShortcut?.().then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut);
}
setGlobalShortcut(binding: string) {
return this.api.setGlobalShortcut?.(binding).then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut);
}
processMemory() {
return this.api.processMemory?.().then((rows: unknown) => (Array.isArray(rows) ? (rows as FeedbackProcessMemory[]) : [])).catch(() => []) ?? Promise.resolve([]);
}
@@ -358,6 +376,12 @@ class BrowserHost implements HostPort {
graphics() {
return Promise.resolve(null);
}
globalShortcut() {
return Promise.resolve(unsupportedShortcut);
}
setGlobalShortcut() {
return Promise.resolve(unsupportedShortcut);
}
processMemory() {
return Promise.resolve([]);
}
@@ -0,0 +1,24 @@
import { afterEach, expect, it, vi } from "vitest";
// Host selection is cached at module load/use; each test needs a fresh module.
afterEach(() => vi.unstubAllGlobals());
it("does not grant browser or network viewers a host-machine shortcut", async () => {
vi.resetModules();
vi.stubGlobal("window", {});
const { host } = await import("./host");
expect(await host().globalShortcut()).toEqual({ binding: "", status: "unsupported" });
expect(await host().setGlobalShortcut("CommandOrControl+Shift+Space")).toEqual({ binding: "", status: "unsupported" });
});
it("asks the local Electron shell immediately, without browser storage", async () => {
vi.resetModules();
const state = { binding: "Control+Shift+Space", status: "registered" };
const apply = vi.fn(async () => state);
vi.stubGlobal("window", { reasonixHost: { shell: "electron", platform: "linux", globalShortcut: async () => state, setGlobalShortcut: apply } });
const { host } = await import("./host");
expect(await host().globalShortcut()).toEqual(state);
expect(await host().setGlobalShortcut(state.binding)).toEqual(state);
expect(apply).toHaveBeenCalledWith(state.binding);
});
+14
View File
@@ -14,6 +14,20 @@ export interface ThemeBackground {
taskOpacity: number;
overlayStrength: number;
textGlow?: "default" | "none";
surfaceOpacity?: ThemeSurfaceOpacities;
}
export interface ThemeSurfaceOpacities {
light?: ThemeSurfaceOpacity;
dark?: ThemeSurfaceOpacity;
}
export interface ThemeSurfaceOpacity {
nav?: number;
rail?: number;
chrome?: number;
side?: number;
compose?: number;
}
// A live backdrop the window draws, rather than a picture it places. Absent on
+9 -5
View File
@@ -3,7 +3,7 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { JobOutput, PlanAction } from "./session";
import { approvalKind } from "./mock_approval";
import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, UploadReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
@@ -11,7 +11,7 @@ import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
import { MEMORIES, mockMemoryCatalog } from "./mock_memory";
import { mockModels } from "./mock_models";
import { mockUsage } from "./mock_usage";
import { mockUploadUsage, mockUsage } from "./mock_usage";
export class MockPort extends MockWorkspace implements AgentPort {
private listeners = new Set<(ev: WireEvent) => void>();
@@ -112,9 +112,9 @@ export class MockPort extends MockWorkspace implements AgentPort {
private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m }));
async usage(query: number | UsageQuery): Promise<UsageReport> {
return mockUsage(query);
}
async usage(query: number | UsageQuery): Promise<UsageReport> { return mockUsage(query); }
uploadUsage(days: number): Promise<UploadReport> { return Promise.resolve(mockUploadUsage(days)); }
async memories(): Promise<MemoryCatalog> {
return mockMemoryCatalog(this.mem);
@@ -416,6 +416,10 @@ export class MockPort extends MockWorkspace implements AgentPort {
return `/mock/branch-${turn}.jsonl`;
}
async continueFromTail(path: string): Promise<{ path: string; dropped: number }> {
return { path: path.replace(/\.jsonl$/, "-tail.jsonl"), dropped: 12 };
}
// The second prompt onwards is scripted to have run bash, so mock mode can
// show the consent stage the real kernel demands on partial coverage.
async prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan> {
+18 -1
View File
@@ -1,4 +1,4 @@
import type { UsageHour, UsageQuery, UsageReport } from "./port";
import type { UploadReport, UsageHour, UsageQuery, UsageReport } from "./port";
import { USAGE_PRICED, USAGE_TOKENS } from "./mock_memory";
import { DEFAULT_USAGE_DAYS } from "./usage";
@@ -108,3 +108,20 @@ export function mockUsage(query: number | UsageQuery): UsageReport {
],
};
}
// A fortnight of uploads with quiet days, two providers and one heavy day.
export function mockUploadUsage(days: number): UploadReport {
const shown = Math.min(days, 14);
const daily = Array.from({ length: shown }, (_, i) => ({
day: new Date(Date.now() - (shown - 1 - i) * DAY_MS).toISOString().slice(0, 10),
bytes: [0, 18e6, 240e6, 410e6, 95e6, 0, 0, 120e6, 760e6, 330e6, 40e6, 0, 210e6, 150e6][i] ?? 0,
}));
const total = daily.reduce((n, d) => n + d.bytes, 0);
return {
from: daily[0]?.day ?? "", to: daily[daily.length - 1]?.day ?? "", bytes: total, requests: 1280, daily,
providers: [
{ provider: "deepseek", bytes: Math.round(total * 0.86), requests: 1100, percent: 86 },
{ provider: "relay-work", bytes: Math.round(total * 0.14), requests: 180, percent: 14 },
],
};
}
+2
View File
@@ -56,6 +56,8 @@ export interface ModelEntry {
modelAlias?: string;
// The vendor slug of the preset this provider came from; absent when none declared it.
mark?: string;
// The model as it reads with its entry's shared prefix left out; absent draws it as it is.
modelShort?: string;
// The service's own vendor slug; differs from mark on a relay, whose models are others'.
serviceMark?: string;
kind?: string;
+5 -5
View File
@@ -1,8 +1,7 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
export type * from "./composition";
import { DeliveryError, HttpError } from "./http_error";
import type { DeliveryFault } from "./http_error";
import { DeliveryError, HttpError, type DeliveryFault } from "./http_error";
import type { SideProgress } from "./side";
/** What `subscribe` hands back: the disposer, and where the transport has one, a
@@ -20,10 +19,10 @@ export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adju
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
import type { MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory";
import type { UsageQuery, UsageReport } from "./usage";
import type { UploadUsagePort, UsageQuery, UsageReport } from "./usage";
export { DEFAULT_USAGE_DAYS } from "./usage";
export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
export type { Money, UploadDay, UploadProvider, UploadReport, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
@@ -32,6 +31,7 @@ import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } fro
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
import type { SessionTailPort } from "./session_tail";
// The port is one contract; its subjects each keep their own file, the way the
// wire and the layers below already do. This is where a reader still finds
@@ -183,7 +183,7 @@ export interface TrayPrefs {
closeToTray: boolean;
}
export interface AgentPort extends WorkspacePreviewPort {
export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, UploadUsagePort {
providerSetup(): Promise<ProviderSetup | null>;
saveProviderKey(apiKey: string): Promise<void>;
// The models that answer the job asked about; "chat" when none is named.
@@ -12,6 +12,10 @@ export interface ProviderEntry {
baseUrl: string;
models: string[];
modelAliases?: Record<string, string>;
// Whether the prefix every model shares is left out of how they are drawn, and each
// model as it then reads; a model drawn as it is has no entry.
hideModelPrefix?: boolean;
modelShorts?: Record<string, string>;
default: string;
hasKey: boolean;
keyEnv?: string;
@@ -249,6 +253,8 @@ export interface ProviderEdit {
name: string;
// Omitted preserves aliases; blank or missing values reset listed models.
modelAliases?: Record<string, string>;
// Omitted leaves the choice alone.
hideModelPrefix?: boolean;
// Omitted leaves the wire format alone.
kind?: string;
baseUrl?: string;
@@ -0,0 +1,5 @@
export interface SessionTailPort {
// Copies the newest part of a session too large to open into a new session;
// dropped is how many earlier messages were left out. The source is untouched.
continueFromTail(path: string): Promise<{ path: string; dropped: number }>;
}
+7 -1
View File
@@ -1,6 +1,6 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, UploadReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -83,6 +83,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
}
return this.get<UsageReport>("/usage?" + q);
}
uploadUsage(days: number) {
return this.get<UploadReport>(`/usage/upload?days=${days}`);
}
memories() {
return this.get<MemoryCatalog>("/memory");
}
@@ -476,6 +479,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
async branchAt(turn: number, name: string) {
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
}
continueFromTail(path: string) {
return this.post0<{ path: string; dropped: number }>("/session/tail", { path });
}
availableUndo() { return this.get<RewindUndo | null>("/rewind/undo"); }
undoRewind(transactionId: string) {
+31
View File
@@ -78,6 +78,10 @@ export interface UsageReport {
// Set when a counted call's money is unknown (no rate card, or a quote that
// could not price its usage). cost is then a floor, not the bill.
costIncomplete?: boolean;
// How many of those counted calls are under that floor, and the distinct
// reasons the rows recorded (the kernel's incomplete-reason vocabulary).
unpricedCalls?: number;
incompleteReasons?: string[];
active_days: number;
top_model: string;
top_provider: string;
@@ -88,3 +92,30 @@ export interface UsageReport {
models: UsageModel[];
providers: UsageProvider[];
}
export interface UploadDay {
day: string;
bytes: number;
}
export interface UploadProvider {
provider: string;
bytes: number;
requests: number;
percent: number;
}
// What model providers were sent: request bodies as they went out, before the
// connection adds its own overhead. Downloads are not counted.
export interface UploadReport {
from: string;
to: string;
bytes: number;
requests: number;
daily: UploadDay[];
providers: UploadProvider[];
}
export interface UploadUsagePort {
uploadUsage(days: number): Promise<UploadReport>;
}
+3 -1
View File
@@ -26,7 +26,7 @@ import { nextId } from "./ids";
import { worked } from "./worked";
import { chipLabel, IDLE, RUNNING } from "./chip";
import { foldStall } from "./stall";
import { nameQueued, withoutPendingRow } from "./queued";
import { nameQueued, QUEUE_HELD, withoutPendingRow } from "./queued";
import { capToolOutput } from "./outputcap";
import { dropTool, foldProgress, foldTool, isSubagentProgress, mergeReads, notePhase, noteRan } from "./fold";
import { rebuild } from "./rebuild";
@@ -225,6 +225,7 @@ export type SessionEvent =
| { kind: "__decided"; id: string; verdict?: string; answers?: string[][]; values?: Record<string, unknown> }
| { kind: "__forgot"; id: string }
| { kind: "__runtime_seen"; id: string }
| { kind: "__queue_resumed" }
// The transcript has drawn these cards, so their one entrance is spent.
// Render says when, not the animation: a card can leave the document before
// its animation ends, and reduced motion runs no animation at all.
@@ -242,6 +243,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
return left.length === s.entranceOwed.length ? s : { ...s, entranceOwed: left };
}
if (ev.kind === "__runtime_seen") return { ...s, runtime: s.runtime.filter((n) => n.id !== ev.id) };
if (ev.kind === "__queue_resumed") return s.runtime.some((n) => n.code === QUEUE_HELD) ? { ...s, runtime: s.runtime.filter((n) => n.code !== QUEUE_HELD) } : s;
if (ev.kind === "rewind_changed") return { ...s, rewindMoved: s.rewindMoved + 1 };
if (ev.kind === "inbox_changed") return { ...s, queueMoved: s.queueMoved + 1 };
if (ev.kind === "browser_tabs_changed") return { ...s, browserTabsMoved: s.browserTabsMoved + 1 };
@@ -8,6 +8,8 @@ export interface Toast {
text: string;
// A note tells the person what the window did on its own; it is not a failure.
tone?: "note";
// A way out the notice offers; running it dismisses the notice.
action?: { label: string; run: () => void };
}
export const MAX_TOASTS = 3;
@@ -33,6 +35,12 @@ export function pushNote(text: string): void {
publish([...toasts, { id: ++serial, text, tone: "note" as const }].slice(-MAX_TOASTS));
}
/** A note with one button that does what the note offers. */
export function pushOffer(text: string, action: { label: string; run: () => void }): void {
if (!text.trim() || toasts.some((toast) => toast.text === text)) return;
publish([...toasts, { id: ++serial, text, tone: "note" as const, action }].slice(-MAX_TOASTS));
}
export function dismissToast(id: number): void {
publish(toasts.filter((toast) => toast.id !== id));
}
+17 -13
View File
@@ -267,21 +267,22 @@ and selected states, while long titles truncate within the remaining width.
that land", so the state is a chip on the row. The count and the byte total
each refuse alone: a header showing one of them is wrong about the other
exactly when it matters.
- The number of entries is capped (64); their height is not. The head stays put
and the rows grow; total height comes from `.composeaux`, and the rule here
only says that the rows scroll rather than the head scrolling away with them.
- `steer_consumed` means this turn has already read the line into its context:
it cannot be taken back, and `AckDequeue` removes it when the turn ends. It
stops being a card and becomes a record — still visible, because a crash before
the write has to leave it on screen, but no longer a card's height. Its border
goes transparent rather than away, or the column loses its alignment.
- Its actions are quiet but present: a row of controls that exists only under the
pointer reads as a row that does nothing.
- The queue is the upper half of the composer, sharing its surface and outer
frame. Transparent 38px rows have 12px horizontal padding and 2px gaps; only
status pills carry status colour. A quiet separator leads into the input.
- The head stays put while roughly five rows fit before internal scrolling,
with edge fades. `.composeaux` still owns the shared height budget.
- `steer_consumed` and `running` entries leave this pending strip: they have
already reached the transcript and are not input waiting to be sent.
- Edit and withdraw use icons with unchanged accessible names and titles.
Withdraw restores input, never discards it. Move controls reveal on hover or
focus and remain visible on touch; their keyboard order is unchanged.
- Everything stacked above the input shares one ceiling, `--aux-budget`, so a
full queue scrolls inside it instead of taking the transcript.
- The studio composer's chrome is taller than the one the budget was set
against, so studio lowers it (28vh, 19vh when short): with 64 queued lines the
transcript keeps 40% of the pane, which `perf/budget.mjs` holds.
- Without notices, a queue gets a 44vh auxiliary budget (28vh when short),
capped at 420px (260px when short), so about five compact rows fit. Notices
restore the smaller 28vh/19vh budget to protect transcript space; the input
stays visible under the 64-entry pressure case in `perf/budget.mjs`.
- The queued line is the only thing on screen that has not happened yet, so its
cancel sits beside "queued" and stays visible. A cancel revealed on hover is no
cancel.
@@ -342,6 +343,9 @@ and selected states, while long titles truncate within the remaining width.
the pane's tab-row line so the two rows read as one: the strip is
`--pane-bar-h` less the card's top inset and border. `perf/dock.mjs` holds
the two lines together.
- The file toolbar gives spare width to the truncated path; optional controls
and Save keep their intrinsic widths. Enabled Save uses accent foreground ink;
disabled Save uses opaque muted ink on a neutral surface.
- The workbench is hidden, not unmounted. It holds the open files and a browser
per tab, each with its own history, and a glance at the conversation must not
cost them. `.scroll[data-pane="browser"][hidden]` was already written for it.
+73 -15
View File
@@ -28,8 +28,10 @@ the gate is named.
- The selector ties the contrast tiers (`:root[data-theme][data-contrast]`) and
beats them by order. With no pack there is no element: the tiers alone carry
the inks, and the reader's contrast is theirs.
- A pack's own inks take the reader's contrast as a relative-colour lightness
offset inside that rule; the built-in inks need none.
- A pack's own inks take normal/strong as relative-colour lightness offsets
inside that rule. Unset and soft preserve the author's baseline: arbitrary
inks and grounds may already meet AA only narrowly, so a blind negative
offset is not safe. This is not an accessibility guarantee for user packs.
- `--ok --warn --err --net --deleg --add --del --focus` are not in the pack
vocabulary and are never written.
- Decoration that is not a status has its own tokens, which a pack may set
@@ -305,6 +307,35 @@ pack states a shape, never a number the layout has to agree with.
negative tracking squeezes a Han character's em box, not the air between
letters.
## Settings text
- Every text run in the settings pages is one of nine roles, declared in
`settings-type.css`.
- Sizes are `--fs-meta`, `--fs-ui`, `--fs-lead`, `--fs-title` and
`--fs-display`; weights are `--w-reg` and `--w-semi`; neutral colours are
`--text` and `--muted`.
- Semantic colours (error, success, warning, accent) mark state and are not
part of the count.
- Roles: group title (lead, semi, text); page title (title, semi, text);
emphasis (ui, semi, text); label (ui, reg, text); description (ui, reg,
muted); caption (meta, reg, muted); control (meta, reg, text); metric
(display, semi, text). The usage hero figure keeps its own larger size.
- Inside `.prefs-col` the file re-points `--fs-micro` to `--fs-meta`.
- It also re-points `--faint` and `--ghost` to `--muted`, and `--w-med` to
`--w-semi`, so a rule written against any of them lands on a role unedited.
- The settings pages therefore have two neutral text tiers where the rest of
the interface has three. `--muted` is the darker of the tiers it replaces,
so the AA floor below still holds.
- A walk over the 16 pages found 43 combinations of size, weight and colour
across 10 sizes, 4 weights and 11 colours. It is now 23 across 5 sizes, 3
weights and 9 colours, the 7 non-neutral ones being semantic.
- Monospace is a family, not a role: a path in code type takes the role of the
text around it.
- `tools/settings-type-sweep.mjs` reads computed styles from a running build
and fails on a size outside the scale.
- `settings-type.test.ts` holds the stylesheet to tokens, so a literal size or
colour cannot be added to it.
## Weight and contrast
- Default and plain-palette light Studio replies use `--w-read: 400` for prose only. Headings,
@@ -319,26 +350,53 @@ pack states a shape, never a number the layout has to agree with.
pack sheet. The backdrop transcript rule defaults to its 2px shadow and 7px
halo when this variable is absent. The choice affects text only; the frosted
backing remains. Reduced effects overrides the text shadow.
- `background.surfaceOpacity.{light,dark}` paints `--bg-{nav,rail,chrome,side,compose}-background`
through the pack sheet, only for a backdrop. Omitted values keep existing paint.
- Shell opacity mixes `--page`; composer opacity mixes `--raised`. Existing
Studio scheme-specific overrides and blur remain the defaults.
- Stroke weight is an axis beside contrast, not part of it: however bright the
colour, 400 Han at 11px is thin. Han strokes are dense and blur at a size
Latin still holds, so the Chinese interface starts at medium.
- The `heavy` tier steps further for Chinese, whose default is already the Latin
bold tier, and stops at 700: above it a Han character's closed strokes fill in.
- Text contrast has three tiers; backgrounds and semantic colours do not
participate. The floor is AA body text: `--ghost` must reach 4.5:1 on the
darkest ground it actually lands on, because it appears at 10–11px and the
large-text exemption starts at 18.66px. That ground is `--page` — the rail,
the side column and the chrome all lay it, where `--ghost` appears two orders
of magnitude more often than on `--raised`. The default tier starts at that
line and the other two step up; the body end does not move, because near-white
on a dark ground haloes and narrowing the span is what "softer" means.
Enforced by `perf/contrast.mjs`.
- `--ghost` is `--faint`'s value in every tier: a lighter one fails the floor.
- `perf/tiers.mjs` holds every tier: each ink is at least as legible as the
default palette stepped once (3.0/10.1, 3.5/7.5 and 3.5/7.0 lightness points
for text, muted and faint) and as the floors recorded there, on both grounds.
- The proposed decision for #12019 retains **soft** as a lower-contrast built-in
body/muted palette. Unlike the previous contract, the body end now moves.
- Unset, normal and strong remain unchanged. Pack-authored inks keep their
baseline for soft, as described under Theme packs.
- Every text ink, including 10–11px faint/ghost, must clear 4.5:1 on its actual
grounds; these sizes do not qualify for the large-text exemption. Applicable
non-text graphics still need 3:1. No semantic colours or graphics are softened.
- Faint/ghost keep the default AA floor: light is only 4.532:1 on `--overlay`,
dark only 4.504:1 on `--float-hi`. Softening them would fail AA despite passing
on page. `--ghost` equals `--faint` in every tier.
- Light soft previously made faint/ghost stronger than unset; it now preserves
the actual default instead.
- `perf/tiers.mjs` measures Chromium colours on page, surface, raised, overlay,
float and float-hi. It pins unset/normal/strong and requires softer body/muted,
stable faint/ghost, and AA. The higher tiers keep their stepped-palette floors.
- `perf/contrast.mjs` and `tools/contrast-sweep.mjs` also audit rendered text and
composited grounds.
- `prefers-contrast: more` is followed when the user has not chosen a tier.
Light and dark are judged separately: a manual dark choice under a light
system scheme would otherwise paint the light theme's dark body text onto a
dark ground.
### Soft contrast decision measurements
Chromium, canonical Studio palette, WCAG sRGB relative luminance. Each cell is
**page / surface**, rounded to three decimals; thresholds use unrounded ratios.
These are small-text measurements, not large-text exemptions.
| Scheme / tier | text | muted | faint | ghost |
| --- | --- | --- | --- | --- |
| Light / unset (unchanged) | 12.496 / 13.653 | 5.445 / 5.949 | 4.831 / 5.279 | 4.831 / 5.279 |
| Light / previous soft | 12.496 / 13.653 | 7.695 / 8.407 | 5.815 / 6.353 | 5.815 / 6.353 |
| Light / soft | 9.324 / 10.187 | 5.140 / 5.616 | 4.831 / 5.279 | 4.831 / 5.279 |
| Light / normal (unchanged) | 14.198 / 15.513 | 10.316 / 11.271 | 7.933 / 8.668 | 7.933 / 8.668 |
| Light / strong (unchanged) | 18.018 / 19.686 | 13.669 / 14.934 | 10.510 / 11.483 | 10.510 / 11.483 |
| Dark / unset and previous soft | 14.730 / 14.314 | 8.934 / 8.682 | 5.722 / 5.560 | 5.722 / 5.560 |
| Dark / soft | 10.822 / 10.516 | 7.411 / 7.202 | 5.722 / 5.560 | 5.722 / 5.560 |
| Dark / normal (unchanged) | 16.125 / 15.670 | 10.065 / 9.781 | 7.565 / 7.352 | 7.565 / 7.352 |
| Dark / strong (unchanged) | 18.099 / 17.588 | 13.163 / 12.792 | 9.916 / 9.636 | 9.916 / 9.636 |
+23 -25
View File
@@ -62,24 +62,24 @@
:root[data-sky] .app { background: transparent }
:root[data-bg] .app { background: transparent }
.rail, .side { background: var(--page) }
.panes { background: var(--surface) }
:root[data-bg] .main, :root[data-bg] .panes { background: transparent }
:root[data-bg] .rail, :root[data-bg] .side,
:root[data-bg] .nav, :root[data-bg] .chrome {
background: color-mix(in srgb, var(--page) 62%, transparent);
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent),
0 0 6px color-mix(in srgb, var(--page) 70%, transparent);
}
:root[data-bg] .nav { background: var(--bg-nav-background, color-mix(in srgb, var(--page) 62%, transparent)) }
:root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 62%, transparent)) }
:root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 62%, transparent)) }
:root[data-bg] .side { background: var(--bg-side-background, color-mix(in srgb, var(--page) 62%, transparent)) }
:root[data-bg] .flow:not([data-empty]) {
text-shadow: var(--flow-text-shadow, 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
0 0 7px color-mix(in srgb, var(--page) 72%, transparent));
}
:root[data-bg] .compose {
background: color-mix(in srgb, var(--raised) 80%, transparent);
background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 80%, transparent));
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
}
@@ -405,8 +405,8 @@
.pane[data-run="done"] .rmark { color: var(--ok) }
.queue {
display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px;
min-height: 0;
display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px;
min-height: 0; padding-bottom: 10px; border-bottom: 1px solid var(--hair);
font: var(--w-reg) var(--fs-meta) var(--mono);
animation: qin var(--m-card) both;
}
@@ -446,18 +446,17 @@
.qhold:disabled { opacity: .45; cursor: default }
.qitems {
display: grid; gap: 4px; min-height: 0;
display: grid; gap: 2px; min-height: 0; max-height: 208px;
overflow-y: auto; overscroll-behavior: contain;
mask-image: linear-gradient(to bottom, transparent, #000 5px, #000 calc(100% - 5px), transparent);
}
.qi {
display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 9px;
border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised);
display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 38px; padding: 4px 12px;
border: 0; border-radius: var(--r-sm); background: transparent;
color: var(--text); animation: qstep var(--t-card) var(--ease-out) both;
transition:
padding var(--t-card) var(--ease-out), font-size var(--t-card) var(--ease-out),
opacity var(--t-card) var(--ease-out), border-color var(--t-card) var(--ease-out),
background var(--t-card) var(--ease-out);
transition: opacity var(--t-card) var(--ease-out), background var(--t-card) var(--ease-out);
}
.qi:hover, .qi:focus-within { background: var(--overlay) }
.qdot {
width: 5px; height: 5px; flex: none; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent);
@@ -470,7 +469,7 @@
}
.pv { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.rf { flex: none; color: var(--faint) }
.qwhy { flex: none; color: var(--warn-ink) }
.qwhy { flex: 0 1 24%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted) }
.qwhy[data-err] { color: var(--err-ink) }
.qedit {
flex: 1; min-width: 0; padding: 2px 5px; border: 1px solid var(--focus); border-radius: var(--r-xs);
@@ -485,7 +484,7 @@
.qacts { display: flex; align-items: center; gap: 1px; flex: none }
.qacts button {
min-width: 17px; height: 19px; padding: 0 4px; border: none; border-radius: var(--r-xs);
min-width: 24px; height: 26px; padding: 0 4px; border: none; border-radius: var(--r-xs);
display: inline-flex; align-items: center; justify-content: center;
background: none; color: var(--faint); font: inherit; cursor: pointer;
transition: color var(--m-line) , background var(--m-line) ;
@@ -498,14 +497,15 @@
.qacts button.qnow:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent-ink) }
.qacts button:hover:not(:disabled) { background: var(--overlay); color: var(--text) }
.qacts button:disabled { opacity: .3; cursor: default }
.qacts .qmove { opacity: 0; width: 0; min-width: 0; padding: 0; overflow: hidden }
.qi:hover .qmove, .qi:focus-within .qmove { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px }
@media (hover: none) { .qacts .qmove { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px } }
.qi .x {
flex: none; padding: 2px 7px; border: none; border-radius: var(--r-pill); background: none;
color: var(--faint); font: var(--w-med) var(--fs-micro) var(--ui); line-height: 1.3; cursor: pointer;
flex: none; width: 26px; height: 26px; padding: 2px; border: none; border-radius: var(--r-xs); background: none;
color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); line-height: 1.3; cursor: pointer;
transition: color var(--m-line) , background var(--m-line) ;
}
.qi .x:hover { color: var(--err-ink); background: color-mix(in srgb, var(--err) 12%, transparent) }
.qi .x { position: relative }
.qi .x::after { content: ""; position: absolute; inset: -5px -2px }
.qi .x:hover, .qi .x:focus-visible { color: var(--text); background: var(--overlay) }
@keyframes bumpup { 0% { transform: translateY(2px); opacity: .5 } 100% { transform: none; opacity: 1 } }
@@ -2512,7 +2512,7 @@
background: var(--overlay); font-weight: var(--w-med);
}
.prefs-nav button[aria-selected="true"] svg { color: var(--text) }
.prefs-nav button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
.prefs-nav button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px } .prefs-nav button[data-open] { font-weight: var(--w-med) } .prefs-nav button.prefs-page { min-height: 30px; padding-left: 34px }
.prefs-nav .nv {
flex: 0 99 auto; min-width: 0; max-width: 74px; overflow: hidden; text-overflow: ellipsis;
@@ -2643,7 +2643,7 @@
}
.prefs-nav::-webkit-scrollbar { display: none }
.prefs-nav .navsec, .prefs-nav .nv { display: none }
.prefs-nav button { flex: none; min-height: 32px; padding: 6px 10px }
.prefs-nav button, .prefs-nav button.prefs-page { flex: none; min-height: 32px; padding: 6px 10px }
.prefs-nav button::before {
left: 50%; top: auto; bottom: 0; width: 0; height: 2px;
transform: translateX(-50%); transition: width var(--t-card) var(--ease-spring);
@@ -4085,8 +4085,6 @@
.theme-more > summary b { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) }
.theme-more > summary em { font: var(--w-reg) var(--fs-meta) var(--ui); font-style: normal; color: var(--faint) }
.theme-more > .palettes { padding: 5px 0 15px }
.personal { margin-top: 12px }
.personal > .grp { border-top: 1px solid var(--hair); margin-top: 0 }
.pal { display: flex; flex-direction: column; gap: 7px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer }
.pal-art {
position: relative; display: flex; aspect-ratio: 16 / 10; overflow: hidden;
@@ -1,3 +1,3 @@
:root:not([data-fold~="scene"]) :is(.prefs, .fbk-veil, .image-preview, .cpv, .agent-tx-scrim) {
border-bottom: var(--chrome-h, 39px) solid transparent;
} :root:not([data-fold~="scene"]) .prefs:has(> .prefs-sheet[data-wide]) { border-bottom-width: 0; } :root .prefs:has(> .prefs-sheet[data-wide]) { inset: 0; } .prefs-sheet[data-wide] { border-width: 0; border-radius: 0; } [data-titlebar="app"] .prefs-sheet[data-wide] .prefs-hd { -webkit-app-region: drag; } [data-titlebar="app"][data-platform="darwin"] .prefs-sheet[data-wide] .prefs-hd { padding-left: calc(var(--lights-w, 96px) + 8px); } .prefs-sheet[data-wide] .prefs-hd .winctl { margin-right: -16px; margin-left: 0; }
} :root:not([data-fold~="scene"]) .prefs:has(> .prefs-sheet[data-wide]) { border-bottom-width: 0; } :root .prefs:has(> .prefs-sheet[data-wide]) { inset: 0; } .prefs-sheet[data-wide] { border-width: 0; border-radius: 0; } [data-titlebar="app"] .prefs-sheet[data-wide] .prefs-hd { -webkit-app-region: drag; } [data-titlebar="app"] .prefs-sheet[data-wide] .prefs-hd :is(button, a[href], input, .winctl) { -webkit-app-region: no-drag; } [data-titlebar="app"][data-platform="darwin"] .prefs-sheet[data-wide] .prefs-hd { padding-left: calc(var(--lights-w, 96px) + 8px); } .prefs-sheet[data-wide] .prefs-hd .winctl { margin-right: -16px; margin-left: 0; }
@@ -0,0 +1,16 @@
import { expect, it } from "vitest";
const CSS = import.meta.glob("./dialogcenter.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const css = CSS["./dialogcenter.css"]!;
// Electron treats everything inside a drag region as part of the title bar, so
// the settings header that takes over the title bar swallows clicks on its own
// buttons and search field unless each is carved back out.
const rulesOf = (re: RegExp) => [...css.matchAll(re)].map((m) => m[1]!).join("\n");
it("makes the full-window settings header a drag region and carves its controls back out", () => {
const drag = css.match(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd\s*\{[^}]*-webkit-app-region:\s*drag/);
expect(drag).not.toBeNull();
const carved = rulesOf(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd :is\(([^)]*)\)\s*\{[^}]*-webkit-app-region:\s*no-drag[^}]*\}/g);
for (const control of ["button", "input", ".winctl"]) expect(carved).toContain(control);
});
@@ -0,0 +1,8 @@
.prefs-col { --fs-micro: var(--fs-meta); --faint: var(--muted); --ghost: var(--muted); --w-med: var(--w-semi); }
.prefs-col :is(strong, b, h4, th, .nm, .who, .lbl, .kind, .me-name, .spon-link, .paperpick, .rmtadd, .uhero-scope, .ttl, .comm-name, .btn:not(.sm)) { font-size: var(--fs-ui); font-weight: var(--w-semi); color: var(--text); }
.prefs-col .grp-hd h3 { font-size: var(--fs-lead); font-weight: var(--w-semi); color: var(--text); }
.prefs-col :is(button.act, label.act, .btn.sm) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--text); }
.prefs-col :is(.prefs-intro-overline, .k, .uhero-k, .uhero-estk, .composition-builtin-badge) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--muted); }
.prefs-col :is(.v, .n, text, .composition-builtin-description) { font-weight: var(--w-reg); }
.prefs-col :is(.cur, div.v) { font-size: var(--fs-display); font-weight: var(--w-semi); }
.prefs-col .mi-mono { font-size: var(--fs-meta); }
@@ -0,0 +1,23 @@
import { expect, it } from "vitest";
const CSS = import.meta.glob("./settings-type.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const css = CSS["./settings-type.css"]!;
const declarations = [...css.matchAll(/([a-z-]+)\s*:\s*([^;}]+)[;}]/g)].map((m) => ({ prop: m[1]!, value: m[2]!.trim() }));
it("states every size, weight and colour as a token", () => {
const sized = declarations.filter((d) => ["font-size", "font-weight", "color"].includes(d.prop));
expect(sized.length).toBeGreaterThan(10);
for (const d of sized) expect(d.value, `${d.prop}: ${d.value}`).toMatch(/^var\(--[a-z-]+\)$/);
});
it("keeps the settings scope on two neutral text tiers and one emphasis weight", () => {
const scope = css.match(/\.prefs-col\s*\{([^}]*)\}/)?.[1] ?? "";
expect(scope).toMatch(/--faint:\s*var\(--muted\)/);
expect(scope).toMatch(/--ghost:\s*var\(--muted\)/);
expect(scope).toMatch(/--fs-micro:\s*var\(--fs-meta\)/);
expect(scope).toMatch(/--w-med:\s*var\(--w-semi\)/);
});
it("only ever names a neutral text colour from the two tiers", () => {
for (const d of declarations.filter((x) => x.prop === "color")) expect(d.value).toMatch(/^var\(--(text|muted)\)$/);
});
+17 -19
View File
@@ -1084,7 +1084,8 @@ body { background: var(--page) !important; }
.compose::after { border-color: var(--border); }
.compose:hover::after { border-color: var(--border); }
.compose { --aux-budget: min(calc(28vh / var(--zoom, 1)), 280px); }
[data-fold~="short"] .compose { --aux-budget: min(calc(19vh / var(--zoom, 1)), 150px); }
.compose:has(.queue):not(:has(.composenotes > *)) { --aux-budget: min(calc(44vh / var(--zoom, 1)), 420px); }
[data-fold~="short"] .compose { --aux-budget: min(calc(19vh / var(--zoom, 1)), 150px); } [data-fold~="short"] .compose:has(.queue):not(:has(.composenotes > *)) { --aux-budget: min(calc(28vh / var(--zoom, 1)), 260px); }
.compose:focus-within { box-shadow: 0 0 0 3px rgb(197 201 192 / .055), 0 20px 48px -32px #000; }
.compose:focus-within::after { border-color: #4a4e47; }
.compose .prompt { display: none; }
@@ -1173,7 +1174,7 @@ body { background: var(--page) !important; }
.btn.send { width: 34px; height: 34px; border: 0; border-radius: 50%; background: var(--accent); color: var(--accent-fg); }
.btn.send:hover { background: var(--accent); transform: translateY(-1px); }
.btn.send:disabled { background: var(--hair); color: var(--ghost); }
.composeaux,
.composeaux { width: 100%; margin-inline: 0; }
.composenotes { width: min(var(--column), calc(100% - var(--srail-w) - 42px)); margin-inline: auto; }
.composenotes { color: var(--ghost); }
.studio-meterrail {
@@ -1298,7 +1299,7 @@ body { background: var(--page) !important; }
}
/* The inspector is contextual, not a permanent dashboard. */
.side { background: var(--page); border-left: 1px solid var(--hair); }
.side { background: var(--bg-side-background, var(--page)); border-left: 1px solid var(--hair); }
.side .scroll { background: var(--page); }
.side .meter { border-color: var(--hair); }
@@ -1391,7 +1392,7 @@ body { background: var(--page) !important; }
.workbench-search svg { width: 15px; height: 15px; color: var(--faint); }
.workbench-search input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font: 500 calc(12px * var(--fs-scale, 1))/1 var(--ui); }
.workbench-search button { width: 20px; height: 20px; padding: 0; border: 0; display: grid; place-items: center; background: transparent; color: var(--muted); }
.workbench-changes { flex: none; max-height: 34%; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; border-bottom: 1px solid var(--hair); scrollbar-gutter: stable; }
.workbench-changes { flex: none; max-height: 34%; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; border-bottom: 1px solid var(--hair); scrollbar-gutter: stable; } .workbench-git { flex: 0 1 auto; min-height: 0; max-height: 65%; overflow-y: auto; scrollbar-gutter: stable; }
.workbench-changes .workbench-explorer-head { position: sticky; top: 0; z-index: 1; border-bottom: 0; background: var(--page); }
.workbench-changes > button { flex: none; }
.workbench-changes:has(.commit-card:not([data-phase="idle"])) { max-height: 80%; }
@@ -1422,14 +1423,14 @@ body { background: var(--page) !important; }
.workbench-filelist span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workbench-filelist em { color: var(--warn-ink); font-style: normal; }
.workbench-no-files { margin: 18px 12px; color: var(--faint); font: 500 calc(10px * var(--fs-scale, 1))/1.4 var(--ui); text-align: center; }
.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); }
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); }
.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); }
.workbench-filebar strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); }
.workbench-filebar > :not(strong) { flex-shrink: 0; }
.workbench-filebar [role="group"] { display: flex; padding: 2px; background: var(--page); border: 1px solid var(--hair); border-radius: 5px; }
.workbench-filebar button { min-height: 25px; padding: 0 8px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font: 600 calc(10px * var(--fs-scale, 1))/1 var(--ui); }
.workbench-filebar button[aria-pressed="true"] { background: var(--raised); color: var(--text); }
.workbench-filebar .workbench-save { display: flex; align-items: center; gap: 5px; }
.workbench-filebar .workbench-save { background: var(--accent); color: white; }
.workbench-filebar .workbench-save:disabled { opacity: .35; }
.workbench-filebar .workbench-save { display: flex; align-items: center; gap: 5px; background: var(--accent); color: var(--accent-fg); }
.workbench-filebar .workbench-save:disabled { background: var(--overlay); color: var(--muted); cursor: default; }
.workbench-filebar .workbench-readonly { padding: 3px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); white-space: nowrap; }
.workbench-filebar svg { width: 13px; height: 13px; }
.workbench-editor { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--face-code); }
@@ -1485,7 +1486,6 @@ body { background: var(--page) !important; }
.app[data-rail="off"] .rail { pointer-events: none; }
.flow,
.compose,
.composeaux,
.composenotes { width: calc(100% - 24px); }
.flow { left: 0; padding-inline: 0; }
.chrome .crumb-proj,
@@ -1582,7 +1582,7 @@ body { background: var(--page) !important; }
}
:root[data-theme="light"] .btn.send { background: var(--accent); color: var(--accent-fg); }
:root[data-theme="light"] .btn.send:hover { background: #111210; }
:root[data-theme="light"] .side,
:root[data-theme="light"] .side { background: var(--bg-side-background, var(--overlay)); border-color: var(--border); }
:root[data-theme="light"] .side .scroll { background: var(--overlay); border-color: var(--border); }
:root[data-theme="light"] .prefs-sheet,
:root[data-theme="light"] .prefs-hd,
@@ -1839,9 +1839,8 @@ body { background: var(--page) !important; }
:root[data-bg] .pane:has([data-pane="flow"] .flow[data-empty]) {
background-color: transparent !important;
}
:root[data-bg] :is(.rail, .chrome) {
background: color-mix(in srgb, var(--page) 80%, transparent) !important;
}
:root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; }
:root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; }
.hero .rmark {
filter: drop-shadow(0 0 28px var(--studio-light-primary));
@@ -3266,9 +3265,8 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
.studio-workspace-kind { color: var(--muted); }
.studio-user-foot .acctrow:hover,
.studio-settings:hover { background: var(--accent-wash); color: var(--text); }
:root[data-theme="light"][data-bg] :is(.rail, .chrome) {
background: color-mix(in srgb, var(--page) 86%, transparent) !important;
}
:root[data-theme="light"][data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; }
:root[data-theme="light"][data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; }
:root[data-theme="light"][data-bg] .railfoot {
box-shadow: 0 -1px color-mix(in srgb, var(--border) 62%, transparent);
}
@@ -3283,10 +3281,10 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
background: var(--accent-wash);
color: var(--text);
}
.side,
.side .scroll,
.studio-browser,
.studio-browser-toolbar { background: var(--surface); border-color: var(--hair); color: var(--text); }
.side { background: var(--bg-side-background, var(--surface)); border-color: var(--hair); color: var(--text); }
.studio-browser-address { background: var(--raised); border-color: var(--border); }
.studio-browser-address input { color: var(--text); }
/* A wallpaper remains visible, while cards keep enough of the selected pack's
@@ -3297,7 +3295,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
-webkit-backdrop-filter: blur(11px) saturate(1.04);
backdrop-filter: blur(11px) saturate(1.04);
}
:root[data-bg] .compose { background: color-mix(in srgb, var(--raised) 88%, transparent) !important; }
:root[data-bg] .compose { background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 88%, transparent)) !important; }
:root[data-bg] .flow:not([data-empty]) { isolation: isolate; }
:root[data-bg] .flow:not([data-empty])::before {
content: "";
+16 -4
View File
@@ -221,9 +221,12 @@
:root[data-weight="heavy"]:lang(zh) { --w-reg: 600; --w-med: 700; --w-semi: 700 }
:root, :root[data-contrast="soft"] {
:root {
--text: #292b29; --muted: oklch(.41 .0207 85); --faint: oklch(.475 .0207 85); --ghost: oklch(.475 .0207 85);
}
:root[data-contrast="soft"] {
--text: #3d3f3b; --muted: #62665d; --faint: #666a63; --ghost: #666a63;
}
:root[data-contrast="normal"] {
--text: oklch(.245 .0218 85); --muted: oklch(.34 .0196 85); --faint: oklch(.405 .0196 85); --ghost: oklch(.405 .0196 85);
}
@@ -232,9 +235,12 @@
}
@media (prefers-color-scheme: dark) {
:root, :root[data-contrast="soft"] {
:root {
--text: #e7e8e3; --muted: #b4b7b0; --faint: #8e9289; --ghost: #8e9289;
}
:root[data-contrast="soft"] {
--text: #c6c9c1; --muted: #a3a79e; --faint: #8e9289; --ghost: #8e9289;
}
:root[data-contrast="normal"] {
--text: oklch(.9588 .006756 115.3); --muted: oklch(.81 .01031 125.5); --faint: oklch(.728 .013 255); --ghost: oklch(.728 .013 255);
}
@@ -243,18 +249,24 @@
}
}
:root[data-theme="dark"], :root[data-theme="dark"][data-contrast="soft"] {
:root[data-theme="dark"] {
--text: #e7e8e3; --muted: #b4b7b0; --faint: #8e9289; --ghost: #8e9289;
}
:root[data-theme="dark"][data-contrast="soft"] {
--text: #c6c9c1; --muted: #a3a79e; --faint: #8e9289; --ghost: #8e9289;
}
:root[data-theme="dark"][data-contrast="normal"] {
--text: oklch(.9588 .006756 115.3); --muted: oklch(.81 .01031 125.5); --faint: oklch(.728 .013 255); --ghost: oklch(.728 .013 255);
}
:root[data-theme="dark"][data-contrast="strong"] {
--text: oklch(1 .006756 115.3); --muted: oklch(.893 .011 255); --faint: oklch(.806 .012 255); --ghost: oklch(.806 .012 255);
}
:root[data-theme="light"], :root[data-theme="light"][data-contrast="soft"] {
:root[data-theme="light"] {
--text: #292b29; --muted: oklch(.41 .0207 85); --faint: oklch(.475 .0207 85); --ghost: oklch(.475 .0207 85);
}
:root[data-theme="light"][data-contrast="soft"] {
--text: #3d3f3b; --muted: #62665d; --faint: #666a63; --ghost: #666a63;
}
:root[data-theme="light"][data-contrast="normal"] {
--text: oklch(.245 .0218 85); --muted: oklch(.34 .0196 85); --faint: oklch(.405 .0196 85); --ghost: oklch(.405 .0196 85);
}
@@ -0,0 +1,14 @@
import { expect, it } from "vitest";
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const css = CSS["./studio.css"]!;
// A workspace of several repositories wraps their sections in one container. Left
// unbounded it grows to the full height of every change and pushes the file list
// out of the explorer, so it has to cap its height and scroll on its own.
it("caps the multi-repository Git panel and lets it scroll", () => {
const rule = css.match(/\.workbench-git\s*\{([^}]*)\}/)?.[1] ?? "";
expect(rule).toMatch(/max-height:\s*\d+%/);
expect(rule).toMatch(/overflow-y:\s*auto/);
expect(rule).toMatch(/min-height:\s*0/);
});
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { t } from "../i18n";
import { MockPort } from "../port/mock";
import type { Appearance as Look } from "../port/port";
import { Appearance } from "./Appearance";
beforeEach(() => {
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
const selected: Look = {
wallpaper: { kind: "image", url: "/selected.jpg", opacity: 0.5, dim: 0.55, focusX: 0.25, focusY: 0.75 },
};
function draw(initial: Look = selected) {
const port = new MockPort();
const upload = vi.spyOn(port, "uploadWallpaper").mockResolvedValue(selected);
const clear = vi.spyOn(port, "clearWallpaper").mockResolvedValue();
const save = vi.fn();
function Settings() {
const [look, setLook] = useState(initial);
return <Appearance port={port} theme="light" contrast="" weight="" onTheme={() => {}} onContrast={() => {}}
onWeight={() => {}} reloadThemes={() => {}} look={look} onLook={(next) => { save(next); setLook(next); }} />;
}
return { ...render(<Settings />), upload, clear, save };
}
it("exposes the wallpaper section and its single selected preview without opening a disclosure", async () => {
const { container } = draw();
const section = container.querySelector('#set-wallpaper[data-setting="wallpaper"]')!;
expect(section.closest("details")).toBeNull();
const preview = screen.getByRole("img", { name: t("壁纸") });
expect(section.contains(preview)).toBe(true);
expect(container.querySelectorAll(".paperview")).toHaveLength(1);
expect(preview.style.backgroundImage).toContain("/selected.jpg");
expect(preview.style.backgroundPosition).toBe("25% 75%");
expect((preview.querySelector("i") as HTMLElement).style.opacity).toBe("0.5");
expect((preview.querySelector("b") as HTMLElement).style.opacity).toBe("0.55");
await waitFor(() => expect(container.querySelector(".palettes")).not.toBeNull());
});
it("keeps upload, replacement, adjustment and removal on the same appearance state", async () => {
const { container, upload, clear, save } = draw({});
const input = container.querySelector('input[data-action="wallpaper.change"]') as HTMLInputElement;
const first = new File(["image"], "wallpaper.png", { type: "image/png" });
expect(container.querySelector(".paperview")).toBeNull();
fireEvent.change(input, { target: { files: [first] } });
await waitFor(() => expect(upload).toHaveBeenCalledWith(first));
await screen.findByRole("img", { name: t("壁纸") });
expect(input.value).toBe("");
const second = new File(["replacement"], "replacement.jpg", { type: "image/jpeg" });
fireEvent.change(input, { target: { files: [second] } });
await waitFor(() => expect(upload).toHaveBeenCalledWith(second));
for (const [name, field, value] of [["浓度", "opacity", "0.8"], ["压暗", "dim", "0.2"], ["横向焦点", "focusX", "0.6"], ["纵向焦点", "focusY", "0.4"]]) {
fireEvent.change(screen.getByRole("slider", { name: new RegExp(t(name)) }), { target: { value } });
expect(save.mock.lastCall?.[0].wallpaper[field]).toBe(Number(value));
}
expect(screen.getByRole("img", { name: t("壁纸") }).style.backgroundPosition).toBe("60% 40%");
fireEvent.click(container.querySelector('[data-action="wallpaper.remove"]')!);
await waitFor(() => expect(clear).toHaveBeenCalledOnce());
await waitFor(() => expect(container.querySelector(".paperview")).toBeNull());
expect(save.mock.lastCall?.[0].wallpaper).toBeUndefined();
expect(screen.getByRole("button", { name: t("选择图片或视频…") })).toBeTruthy();
});
+4 -8
View File
@@ -22,6 +22,7 @@ import { SessionQuickSection } from "./SessionQuickSection";
import { NavEntriesSection } from "./NavEntriesSection";
import { NavRailSection } from "./NavRailSection";
import { GraphicsSection } from "./GraphicsSection";
import { GlobalShortcutSection } from "./GlobalShortcutSection";
import { SendShortcut } from "./SendShortcut";
import { KeysSheet } from "./KeysSheet";
import { SessionTabsSetting } from "./SessionTabsSetting";
@@ -378,13 +379,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<ApplyNote id="size" />
</section>
<details className="advset personal">
<summary>
<span className="tx">
<span className="lb">{t("更多个性化")}</span>
<span className="ds">{look.wallpaper ? t("已设置自定义壁纸") : t("壁纸与背景位置")}</span>
</span>
</summary>
<section className="grp" id="set-wallpaper" data-setting="wallpaper">
<div className="grp-hd">
<h3>{t("壁纸")}</h3>
@@ -422,6 +416,8 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
{look.wallpaper && (
<div
className="paperview"
role="img"
aria-label={t("壁纸")}
ref={setView}
style={{
backgroundImage: look.wallpaper.kind === "video" ? undefined : `url("${look.wallpaper.url}")`,
@@ -527,7 +523,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</div>
<ApplyNote id="wallpaper" />
</section>
</details>
<section className="grp" id="set-language" data-setting="language">
<div className="grp-hd">
@@ -561,6 +556,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<NewAnswerSection />
<GraphicsSection />
<SendShortcut />
<GlobalShortcutSection />
<KeysSheet />
<EscStopSetting />
<PageSleepSetting />
+5 -7
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { UNCONFIGURED_REF } from "./ConnectNote";
import { reason } from "../i18n/kernel";
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
@@ -11,7 +10,7 @@ import { WorktreeChip } from "./WorktreeChip";
import { useWorktreeSetup, WorktreeSetupFailure } from "./useWorktreeSetup";
import { WorktreeSetupPage } from "./WorktreeSetupPage";
import { Policy } from "./Policy";
import { chosenGroups, groupHeadings, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu";
import { chosenGroups, groupHeadings, modelLabel, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu";
import { ModelGroupChips } from "./ModelGroupChips";
import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
import { CompletionMenu, useCompletion } from "./Completion";
@@ -44,6 +43,7 @@ interface Props {
port: AgentPort;
status: SessionStatus | null;
running: boolean;
queued?: boolean;
// Text a card asked to be quoted, and a counter that makes the same text
// twice two requests. Quoting the same reply again is an ordinary thing to
// do, and comparing the string alone would drop the second one.
@@ -79,7 +79,7 @@ interface Props {
draftKey?: string;
}
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
const touch = touchKeyboard();
const providerOrder = useProviderOrder();
const modelGroups = useModelFilter();
@@ -217,7 +217,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
pending.current = null;
}, []);
useFitHeight(box, text, placeCaret);
const placeholder = usePlaceholderFit(box, [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
const placeholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
// Attachments ride into the turn as path references, exactly as they do from
// the CLI — the host saved the bytes, the turn parser resolves the token. A
@@ -389,9 +389,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
// composer answering for an endpoint that never spoke.
const declared = efforts.length > 0;
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
const modelLb = status?.modelRef === UNCONFIGURED_REF ? t("未连接")
: models.find((m) => m.ref === status?.modelRef)?.modelAlias?.trim()
|| (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—");
const modelLb = modelLabel(models, status);
// A model switch rebuilds the runtime kernel-side; other controls here may
// land at once. Each click needs its own pending state: greying the whole
// shelf says nothing can change while only one control waits on the kernel.
+18 -2
View File
@@ -46,6 +46,7 @@ export function EditConn({
const [models, setModels] = useState<string[]>(seededModels);
const [picked, setPicked] = useState<string[]>(entry.models);
const [aliases, setAliases] = useState<Record<string, string>>(entry.modelAliases ?? {});
const [hidePrefix, setHidePrefix] = useState(entry.hideModelPrefix === true);
const [vision, setVision] = useState<string[]>(seededVision);
const [visionSettable, setVisionSettable] = useState<string[] | undefined>(
entry.visionSettable ? [...new Set([...entry.visionSettable, ...(initialCheck?.vision ?? [])])] : undefined,
@@ -228,6 +229,7 @@ export function EditConn({
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
models: picked,
...(hidePrefix !== (entry.hideModelPrefix === true) && { hideModelPrefix: hidePrefix }),
modelAliases: Object.fromEntries(Object.entries(aliases).filter(([m, value]) => picked.includes(m) && value.trim()).map(([m, value]) => [m, value.trim()])),
default: picked.includes(def) ? def : picked[0] ?? "",
vision: vision.filter((m) => picked.includes(m)),
@@ -356,13 +358,26 @@ export function EditConn({
</label>
</div>
{(picked.length > 1 || picked.some((m) => entry.modelLimits?.[m])) && (
<ModelLimits models={picked} value={limitText} onChange={setLimitText} inherited={inheritedLimitsFor} />
<ModelLimits models={picked} value={limitText} onChange={setLimitText} inherited={inheritedLimitsFor} labels={entry.modelShorts} />
)}
{picked.length > 0 && <ModelPrices models={picked} value={priceText} onChange={setPriceText} official={entry.officialPricing === true} stored={entry.modelPrices ?? {}}
{picked.length > 0 && <ModelPrices models={picked} value={priceText} onChange={setPriceText} official={entry.officialPricing === true} stored={entry.modelPrices ?? {}} labels={entry.modelShorts}
onOpenSource={(url) => void Promise.resolve(port.openExternal(url)).catch(() => {})} />}
{picked.length > 0 && <div className="fields"><BillingModeField value={billing} onChange={setBilling} /></div>}
</div>
<div className="vway" data-field="hide-model-prefix">
<span className="lb">{t("隐藏共同前缀")}</span>
<div className="seg" role="group" aria-label={t("{name} 的模型名前缀", { name: entry.displayName?.trim() || entry.name })}>
{[true, false].map((on) => (
<button key={String(on)} type="button" data-action="provider.draft" data-value={`hide-model-prefix:${on}`}
aria-pressed={hidePrefix === on} disabled={busy !== ""} onClick={() => setHidePrefix(on)}>
{t(on ? "开" : "关")}
</button>
))}
</div>
<span className="why">{t("路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。")}</span>
</div>
<div className="mlist">
<div className="mlhead">
<span className="ttl">{t("模型")}</span>
@@ -403,6 +418,7 @@ export function EditConn({
visionLocked={visionLocked}
onCheck={checkModel}
aliases={aliases}
labels={entry.modelShorts}
onAlias={(m, value) => setAliases((current) => ({ ...current, [m]: value }))}
checkDisabled={busy !== "" || testing}
onToggle={(m) => toggle(picked, setPicked, m)}
@@ -11,7 +11,10 @@ import { HttpError, type AgentPort } from "../port/port";
import { FEEDBACK_CODE, type FeedbackEnv } from "../port/feedback";
import { host } from "../port/host";
beforeEach(dropDraft);
beforeEach(() => {
localStorage.clear();
dropDraft();
});
afterEach(() => {
cleanup();
@@ -59,6 +62,52 @@ async function fill(text = "侧栏在缩放窗口后丢失选中项", nick = "ad
}
describe("feedback form", () => {
it("remembers submitted contact across fresh forms and lets an empty submission clear it", async () => {
const contact = () => screen.getByRole<HTMLInputElement>("textbox", { name: /联系方式/ });
setup();
await fill();
await userEvent.type(contact(), "[email protected]");
await userEvent.click(send());
await screen.findByText("已收到你的反馈");
cleanup();
dropDraft();
setup();
await fill();
expect(contact().value).toBe("[email protected]");
await userEvent.clear(contact());
await userEvent.click(send());
await screen.findByText("已收到你的反馈");
cleanup();
dropDraft();
setup();
await ready();
expect(contact().value).toBe("");
});
it("keeps an unsent draft ahead of remembered contact without persisting a failed submission", async () => {
const contact = () => screen.getByRole<HTMLInputElement>("textbox", { name: /联系方式/ });
const { port } = setup();
await fill();
await userEvent.type(contact(), "[email protected]");
await userEvent.click(send());
await screen.findByText("已收到你的反馈");
await userEvent.click(screen.getByRole("button", { name: "再写一条" }));
await userEvent.type(body(), "another report");
await userEvent.clear(contact());
vi.spyOn(port, "sendFeedback").mockRejectedValueOnce(new Error("offline"));
await userEvent.click(send());
await screen.findByRole("button", { name: "重试发送" });
cleanup();
setup();
await ready();
expect(contact().value).toBe("");
cleanup();
dropDraft();
setup();
await ready();
expect(contact().value).toBe("[email protected]");
});
it("holds Send back until there is text and a nickname, and counts UTF-8 bytes", async () => {
setup();
await ready();
@@ -8,7 +8,7 @@ import { host } from "../port/host";
import type { AgentPort } from "../port/port";
import { CopyButton } from "./CopyButton";
import { StudioIcon } from "./StudioIcon";
import { dropDraft, heldDraft, holdDraft } from "./feedbackdraft";
import { dropDraft, heldDraft, holdDraft, rememberContact } from "./feedbackdraft";
import { feedbackFailure, type FeedbackFailure } from "./feedbackfailure";
import { FeedbackShot } from "./FeedbackShot";
import { LazyMarkdown } from "./LazyMarkdown";
@@ -210,6 +210,7 @@ export function FeedbackForm({ port, pasteRoot, active, onMine, onClose, onFile,
...(shellMemory.length > 0 ? { memory: shellMemory } : {}),
});
shotEpoch.current++;
rememberContact(contact.trim());
dropDraft();
setSent(receipt);
setPhase("sent");
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { t } from "../i18n";
import { host, type GlobalShortcutInfo } from "../port/host";
import { Group } from "./Group";
import { acceleratorLabel } from "./keys";
const STATUS = {
disabled: "全局快捷键已关闭",
registered: "已注册:{value}",
invalid: "快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。",
unavailable: "系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。",
unsupported: "当前环境不支持全局快捷键。",
};
const ERRORS = {
invalid: STATUS.invalid,
unavailable: STATUS.unavailable,
save: "快捷键未能保存;原设置保持不变。",
quitting: "Studio 正在退出,未更改快捷键。",
};
function capturedBinding(event: KeyboardEvent<HTMLInputElement>): string {
if (event.nativeEvent.isComposing || event.repeat || event.getModifierState("AltGraph")) return "";
const modifiers = [
event.ctrlKey && "Control", event.altKey && "Alt", event.shiftKey && "Shift", event.metaKey && "Super",
].filter(Boolean);
if (!event.ctrlKey && !event.altKey && !event.metaKey) return "";
const names: Record<string, string> = { " ": "Space", "+": "Plus", ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right" };
const punctuation: Record<string, string> = { Comma: ",", Period: ".", Slash: "/", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Backslash: "\\", Backquote: "`", Minus: "-", Equal: "=" };
const position = /^(Key[A-Z]|Digit[0-9])$/.test(event.code) ? event.code.replace(/^(Key|Digit)/, "") : punctuation[event.code];
const key = names[event.key] ?? (/^[a-z0-9]$/i.test(event.key) ? event.key.toUpperCase() : position ?? event.key);
if (!/^(Space|Plus|Up|Down|Left|Right|Enter|Escape|Backspace|Delete|Insert|Home|End|PageUp|PageDown|F([1-9]|1[0-9]|2[0-4])|[A-Z0-9,./;'\[\]\\`=-])$/.test(key)) return "";
return [...modifiers, key].join("+");
}
export function GlobalShortcutSection() {
const [now, setNow] = useState<GlobalShortcutInfo | null>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const [recording, setRecording] = useState(false);
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
let live = true;
host().globalShortcut().then(value => {
if (live) { setNow(value); setDraft(value.binding); }
}).catch(() => live && setFailed(true));
return () => { live = false; };
}, []);
if (!host().inShell() || now?.status === "unsupported") return null;
async function apply(binding: string) {
setBusy(true);
setFailed(false);
try {
const value = await host().setGlobalShortcut(binding);
setNow(value);
if (!value.error) setDraft(value.binding);
} catch {
setFailed(true);
} finally {
setBusy(false);
}
}
return (
<Group id="global-shortcut" title={t("全局显示 / 隐藏快捷键")} hint={t("默认关闭。仅在 Studio 运行时生效;窗口聚焦时隐藏,否则显示并聚焦。不会启动已退出的应用,也不更改窗口内快捷键。") }>
<form data-action="window.global-shortcut" data-value="apply" onSubmit={event => { event.preventDefault(); void apply(draft); }}>
<label className="lrow">
<span className="tx"><span className="lb">{t("快捷键组合")}</span></span>
<input ref={input} className="fontown" data-action-change="window.global-shortcut" data-action-keydown="window.global-shortcut" data-value="binding" aria-label={t("快捷键组合")} maxLength={128} placeholder={recording ? t("请按修饰键与按键组合…") : "CommandOrControl+Shift+Space"} value={draft} readOnly={recording} onChange={event => setDraft(event.target.value)} onBlur={() => setRecording(false)} onKeyDown={event => {
if (!recording || event.key === "Tab") return;
event.preventDefault(); event.stopPropagation();
if (event.key === "Escape" && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { setRecording(false); return; }
const binding = capturedBinding(event);
if (binding) { setDraft(binding); setRecording(false); }
}} disabled={!now || busy} />
</label>
{draft && <p className="note"><kbd>{acceleratorLabel(draft)}</kbd></p>}
<p className="note">{t("点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。")}</p>
<div className="seg" data-text>
<button type="button" data-action="window.global-shortcut" data-value="record" disabled={!now || busy} onClick={() => { setRecording(true); input.current?.focus(); }}>{t("录制快捷键")}</button>
<button type="submit" data-action="window.global-shortcut" data-value="apply" disabled={!now || busy}>{t("应用快捷键")}</button>
<button type="button" data-action="window.global-shortcut" data-value="disable" disabled={!now || busy || (!now.binding && !draft)} onClick={() => void apply("")}>{t("清除快捷键")}</button>
</div>
</form>
{now && <p className="note" role="status">{t(STATUS[now.status], { value: acceleratorLabel(now.binding) })}</p>}
{now?.error && <p className="note" role="alert">{t(ERRORS[now.error])}</p>}
{failed && <p className="note" role="alert">{t("无法读取或更改 shell 快捷键状态。请重新打开设置后再试。")}</p>}
</Group>
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ export function KeysSheet() {
<Group
id="keys"
title={t("快捷键")}
hint={t("下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。")}
hint={t("下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。")}
>
{KEYS.map(([keys, what]) => (
<div className="lrow keys-row" key={keys + what}>
+4 -2
View File
@@ -51,6 +51,8 @@ interface Props {
// be a dead control. One endpoint can serve both kinds, so it is not a
// property of the connection.
visionLocked?: (m: string) => boolean;
// How a model reads with its shared prefix left out; the id stays in the tooltip.
labels?: Record<string, string>;
facts?: Record<string, ModelFact>;
onCheck?: (m: string) => void;
checkDisabled?: boolean;
@@ -64,7 +66,7 @@ interface Props {
}
export function ModelChoice({
models, aliases, onAlias, picked, vision, def, onDefault, onVision, visionLocked, facts, onCheck, checkDisabled, onToggle, onAdd,
models, aliases, onAlias, picked, vision, def, onDefault, onVision, visionLocked, labels, facts, onCheck, checkDisabled, onToggle, onAdd,
onFetch, fetching, fetchDisabled, fetchFail,
}: Props) {
const [q, setQ] = useState("");
@@ -132,7 +134,7 @@ export function ModelChoice({
<i />
</button>
<span className="mid">
<span className="nm">{m}</span>
<span className="nm" title={labels?.[m] ? m : undefined}>{labels?.[m] ?? m}</span>
{onAlias && <input className="malias" value={aliasFor(m)} spellCheck={false}
data-action="provider.draft" data-value="model-alias" data-target={m}
aria-label={t("{name} 的显示别名", { name: m })}
+4 -2
View File
@@ -31,9 +31,11 @@ export function limitsToSend(picked: string[], text: Record<string, LimitText>,
}
export function ModelLimits({
models, value, onChange, inherited,
models, value, onChange, inherited, labels,
}: {
models: string[];
// How a model reads when its shared prefix is left out; absent draws it as it is.
labels?: Record<string, string>;
value: Record<string, LimitText>;
onChange: (next: Record<string, LimitText>) => void;
inherited: (model: string) => ModelLimit | null | undefined;
@@ -53,7 +55,7 @@ export function ModelLimits({
const own = cur.win !== "" || cur.out !== "";
return (
<li key={model} className="ml-row" data-model={model} data-own={own || undefined}>
<span className="me-name" title={model}>{shortId(model)}</span>
<span className="me-name" title={model}>{labels?.[model] ?? shortId(model)}</span>
<span className="unit-field">
<input
data-action="provider.draft" data-value="model-window"
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import type { ModelEntry, ProviderEntry } from "../port/port";
import type { Port } from "./Providers";
import { EditConn } from "./EditConn";
import { ModelLimits } from "./ModelLimits";
import { ModelUsage } from "./ModelUsage";
import { modelMenu } from "./modelmenu";
afterEach(cleanup);
const routed = [
{ ref: "mag/group/deepseek-v4.1-flash", provider: "mag", vendor: "m.example", model: "group/deepseek-v4.1-flash", modelShort: "deepseek-v4.1-flash", kind: "openai" },
{ ref: "mag/group/glm-5", provider: "mag", vendor: "m.example", model: "group/glm-5", modelShort: "glm-5", modelAlias: "My GLM", kind: "openai" },
] as ModelEntry[];
it("draws the model menu and the usage list without the shared prefix, a typed alias still first, and keeps the ref exact", () => {
const items = modelMenu(routed);
const rows = items.filter((it) => !it.header && !it.plain);
expect(rows.map((r) => r.label)).toEqual(["deepseek-v4.1-flash", "My GLM"]);
expect(rows.map((r) => r.value)).toEqual(["mag/group/deepseek-v4.1-flash", "mag/group/glm-5"]);
expect(rows[0].desc).toBeUndefined();
expect(rows[1].desc).toBe("group/glm-5");
render(<ModelUsage models={routed} roles={null} main="mag/group/deepseek-v4.1-flash" busy="" protocol={{}} onMain={vi.fn()} onRole={vi.fn()} />);
const options = [...(screen.getByRole("combobox", { name: "默认模型" }) as HTMLSelectElement).options].map((o) => o.textContent);
expect(options).toEqual(["deepseek-v4.1-flash", "My GLM"]);
});
it("shows the short name on a limits row and keeps the full id in the tooltip", () => {
render(<ModelLimits models={["group/deepseek-v4.1-flash", "other"]} value={{}} onChange={vi.fn()} inherited={() => null}
labels={{ "group/deepseek-v4.1-flash": "deepseek-v4.1-flash" }} />);
const row = document.querySelector('[data-model="group/deepseek-v4.1-flash"]')!;
const name = within(row as HTMLElement).getByText(/deepseek/);
expect(name.textContent).toBe("deepseek-v4.1-flash");
expect(name.getAttribute("title")).toBe("group/deepseek-v4.1-flash");
expect(document.querySelector('[data-model="other"] .me-name')?.textContent).toBe("other");
});
function drawEdit(entry: Partial<ProviderEntry> = {}) {
const editProvider = vi.fn().mockResolvedValue(undefined);
const full = {
name: "mag", kind: "openai", baseUrl: "https://m.example/v1",
models: ["group/a", "group/b"], default: "group/a", hasKey: true, inUse: false, preset: false, ...entry,
} as ProviderEntry;
const port = { editProvider, protocols: vi.fn(async () => []), checkProvider: vi.fn(async () => ({ ok: true, models: [] })) } as unknown as Port;
render(<EditConn entry={full} port={port} busy="" setBusy={() => {}} onDone={vi.fn()} onRevert={() => {}} />);
return { editProvider, group: screen.getByRole("group", { name: "mag 的模型名前缀" }) };
}
it("starts off, turns on with a click, and sends the choice with the model ids untouched", async () => {
const { editProvider, group } = drawEdit();
expect(within(group).getByRole("button", { name: "关" }).getAttribute("aria-pressed")).toBe("true");
await userEvent.click(within(group).getByRole("button", { name: "开" }));
await userEvent.click(screen.getByRole("button", { name: /保存/ }));
expect(editProvider.mock.calls[0]?.[0]).toMatchObject({ hideModelPrefix: true, models: ["group/a", "group/b"] });
});
it("shows a stored choice as on and sends it off when turned off", async () => {
const { editProvider, group } = drawEdit({ hideModelPrefix: true, modelShorts: { "group/a": "a", "group/b": "b" } });
expect(within(group).getByRole("button", { name: "开" }).getAttribute("aria-pressed")).toBe("true");
await userEvent.click(within(group).getByRole("button", { name: "关" }));
await userEvent.click(screen.getByRole("button", { name: /保存/ }));
expect(editProvider.mock.calls[0]?.[0]).toMatchObject({ hideModelPrefix: false });
});
+3 -2
View File
@@ -89,9 +89,10 @@ export function pricesToSend(picked: string[], text: Record<string, PriceText>,
}
export function ModelPrices({
models, value, onChange, official = false, stored = {}, onOpenSource,
models, value, onChange, official = false, stored = {}, onOpenSource, labels,
}: {
models: string[];
labels?: Record<string, string>;
value: Record<string, PriceText>;
onChange: (next: Record<string, PriceText>) => void;
official?: boolean;
@@ -114,7 +115,7 @@ export function ModelPrices({
const freeCached = foreign && cur.hit !== "" && Number(cur.hit) === 0 && Number(cur.inp) > 0;
return (
<li key={model} className="ml-row mp-row" data-model={model} data-own={own || undefined}>
<span className="me-name" title={model}>{shortId(model)}</span>
<span className="me-name" title={model}>{labels?.[model] ?? shortId(model)}</span>
<span className="unit-field">
<input data-action="provider.draft" data-value="model-price-input" aria-label={t("{model} 的输入单价", { model })}
aria-invalid={incomplete(cur) && cur.inp === "" ? true : undefined} inputMode="decimal" value={cur.inp} placeholder={t("未知")}
@@ -87,3 +87,19 @@ it("keeps naming effort fixed off even when the model exposes effort levels", as
await userEvent.selectOptions(screen.getByRole("combobox", { name: "子代理的思考强度" }), "max");
expect(onRole).toHaveBeenCalledWith("subagent", models[1].ref, "max");
});
it("offers the vision row only models that read images, and no other row loses its models", () => {
draw();
const vision = [...(screen.getByRole("combobox", { name: "看图" }) as HTMLSelectElement).options].map((o) => o.textContent);
expect(vision).toEqual(["跟随主模型", "deepseek-flash · 读图"]);
const subagent = [...(screen.getByRole("combobox", { name: "子代理" }) as HTMLSelectElement).options].map((o) => o.textContent);
expect(subagent).toContain("deepseek-pro");
});
it("keeps a vision model chosen earlier visible when it is not declared to read images", () => {
const roles: RoleAssignments = { ...ROLES, roles: { ...ROLES.roles, vision: "deepseek/deepseek-pro" } };
render(<ModelUsage models={MODELS} roles={roles} main="deepseek/deepseek-flash" busy="" protocol={{}} onMain={vi.fn()} onRole={vi.fn()} />);
const select = screen.getByRole("combobox", { name: "看图" }) as HTMLSelectElement;
expect(select.value).toBe("deepseek/deepseek-pro");
expect(select.selectedOptions[0]?.textContent).toBe("deepseek/deepseek-pro(未声明读图)");
});
+8 -5
View File
@@ -5,7 +5,7 @@ import { activeKind, contextLabel, groupVendors, type Vendor } from "./Models";
import { orderAccounts, useProviderOrder } from "../state/providerorder";
type RoleKey = keyof RoleAssignments["roles"];
type Answers = "chat" | "decision" | "search";
type Answers = "chat" | "decision" | "search" | "vision";
// Decision is the one job that cannot follow the main model: it asks a question
// set, which a chat model has no answer for, so its row offers the decision
@@ -19,8 +19,10 @@ const ROLES: [RoleKey, string, string, Answers][] = [
["decision", "决策", "system_one 询问的后端", "decision"],
];
const shown = (m: ModelEntry) => m.modelAlias?.trim() || m.modelShort || m.model;
const answersOf = (m: ModelEntry): Answers => (m.answers === "decision" ? "decision" : "chat");
const answersMatch = (m: ModelEntry, answers: Answers) => answers === "search" ? m.webSearch === true : answersOf(m) === answers;
const answersMatch = (m: ModelEntry, answers: Answers) =>
answers === "search" ? m.webSearch === true : answers === "vision" ? m.vision === true && answersOf(m) === "chat" : answersOf(m) === answers;
// Only what the config or the catalog declares: an inferred "reads images" sends
// the user to a request the endpoint rejects.
@@ -90,13 +92,13 @@ export function ModelUsage({ models, roles, main, busy, protocol, effort = "auto
const set = roles.roles[key];
const roleModel = byRef(set || (key === "title" || answers === "decision" ? "" : main));
const roleEffort = roles.efforts?.[key] || "auto";
const offered = models.filter((m) => answersOf(m) === answers);
const offered = models.filter((m) => answersMatch(m, answers));
const none = answers === "decision" && offered.length === 0;
return (
<div className="usage-row" role="row" key={key}>
<span className="usage-job" role="rowheader"><b>{t(name)}</b><small>{t(tag)}</small></span>
<span role="cell">
<Choices vendors={vendors} protocol={protocol} main={main} value={set} answers={answers}
<Choices vendors={vendors} protocol={protocol} main={main} value={set} answers={key === "vision" ? "vision" : answers}
label={t(name)} role disabled={busy !== "" || none}
empty={t(key === "title" ? "不使用" : answers === "chat" ? "跟随主模型" : none ? "尚无可用来源" : "不使用")}
onPick={(ref) => onRole(key, ref, "auto")} />
@@ -217,10 +219,11 @@ function Choices({
onChange={(e) => onPick(e.target.value)}>
{empty !== undefined && <option value="">{empty}</option>}
{answers === "search" && value && !groups.some((g) => g.rows.some((m) => m.ref === value)) && <option value={value}>{t("{model}(不可用)", { model: value })}</option>}
{answers === "vision" && value && !groups.some((g) => g.rows.some((m) => m.ref === value)) && <option value={value}>{t("{model}(未声明读图)", { model: value })}</option>}
{groups.map(({ v, rows }) => (
<optgroup key={v.key} label={v.label}>
{rows.map((m) => (
<option key={m.ref} value={m.ref}>{m.vision ? `${m.model} · ${t("读图")}` : m.model}</option>
<option key={m.ref} value={m.ref}>{m.vision ? `${shown(m)} · ${t("读图")}` : shown(m)}</option>
))}
</optgroup>
))}
+1 -1
View File
@@ -636,7 +636,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
{alert && <div className="cmpalert">{alert}</div>}
<RootMissingBanner items={s.items} onPick={onOpenProject} />
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
<Composer worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<MeterRail
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost} turnCost={s.metrics.coverage === "none" ? NO_COST : s.metrics.turn}
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
+8 -9
View File
@@ -190,7 +190,6 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
? t("尚未送达,可以改,也可以取回")
: t("正在送出…")}
</span>
{queue.paused && <span className="qflag" data-hold="">{t("已暂停")}</span>}
{queue.readonly && <span className="qflag" data-ro="">{t("只读")}</span>}
{/* The kernel pauses itself when it recovers entries: something was
said that no one has since re-confirmed. */}
@@ -218,7 +217,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
const coded = it.blockCode ? BLOCK_WHY[it.blockCode] : undefined;
const why = coded ? t(coded) : it.blockReason;
return (
<div key={it.id} className="qi" data-state={it.state}>
<div key={it.id} className="qi" data-state={it.state} title={why || undefined}>
{/* The chip is the answer to "did that land". Its wording says
which turn the line goes into; the title says when. */}
{ordinary(it) ? (
@@ -257,7 +256,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
<Overflow className="pv" text={it.preview} />
)}
{!!it.refs?.length && <span className="rf">{t("冻结 {n} 文件", { n: it.refs.length })}</span>}
{why && <span className="qwhy">{why}</span>}
{why && (it.state === "blocked" || it.state === "uncertain") && <span className="qwhy" title={why}>{why}</span>}
{unread?.id === it.id && (
<span className="qwhy" data-err="" role="alert">
{t("无法读取该条的正文,未打开编辑:{why}", { why: unread.why })}
@@ -266,15 +265,15 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
{live && (
<span className="qacts">
{items.length > 1 && <>
<button data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>
<StudioIcon name="arrow" />
</button>
<button data-action="queue.move" data-target={it.id} data-value="down" onClick={() => onMove(it.id, i + 1)} disabled={i === items.length - 1} title={t("下移")} aria-label={t("下移")}>
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="down" onClick={() => onMove(it.id, i + 1)} disabled={i === items.length - 1} title={t("下移")} aria-label={t("下移")}>
<StudioIcon name="arrow" data-flip="" />
</button>
</>}
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")}>
{t("改")}
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")} aria-label={t("改")}>
<StudioIcon name="edit" />
</button>
{/* Waiting is the normal case and needs no button. This is the
other one: the turn in front of this line is not worth
@@ -304,8 +303,8 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
</span>
)}
{live && (
<button className="x" data-action="queue.cancel" data-target={it.id} onClick={() => onCancel(it.id)} title={t("取回")}>
{t("取回")}
<button className="x" data-action="queue.cancel" data-target={it.id} onClick={() => onCancel(it.id)} title={t("取回")} aria-label={t("取回")}>
<StudioIcon name="rewind" />
</button>
)}
</div>
+17 -4
View File
@@ -11,8 +11,11 @@ import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry }
import { useSettingsSearch } from "./useSettingsSearch";
import { useSettingLanding } from "./useSettingLanding";
import { bytes, tokens as fmtTokens } from "../i18n/format";
import { STORAGE_TALLY_DELAY, cachedStorageTotal, storageTotal } from "./storageTally";
import { SECTION_NAME } from "./prefsnav";
import { SettingsNav } from "./SettingsNav";
import { SettingsPageStyle } from "./SettingsPageStyle";
import { firstPage, pageOf } from "./settingPages";
import { Group, SCOPE_SAID } from "./Group";
import { PlanApproval, DefaultMode } from "./PlanApproval";
import { PeersSection } from "./PeersSection";
@@ -92,6 +95,11 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
const localPort = localWorkspacePort ?? homePort;
const [openedSection, openedAnchor = ""] = (opened ?? "").split(":", 2);
const [at, setAt] = useState<Section>((openedSection as Section) || "session");
const [pages, setPages] = useState<Partial<Record<Section, string>>>(() => {
const first = pageOf((openedSection as Section) || "session", openedAnchor);
return first ? { [(openedSection as Section) || "session"]: first } : {};
});
const pageNow = pages[at] ?? firstPage(at);
// 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到
// 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。
const [query, setQuery] = useState("");
@@ -184,12 +192,14 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
let alive = true;
setTally({});
const put = (k: Section, v: string) => { if (alive) setTally((prev) => ({ ...prev, [k]: v })); };
homePort.storage().then((st) => put("storage", bytes(st.roots.reduce((n, r) => n + (r.bytes || 0), 0)))).catch(() => {});
const known = cachedStorageTotal(homePort);
if (known !== undefined) put("storage", bytes(known));
const walk = known !== undefined ? undefined : setTimeout(() => { storageTotal(homePort).then((n) => put("storage", bytes(n))).catch(() => {}); }, STORAGE_TALLY_DELAY);
// The same window Usage itself opens on, or the list and the page it opens
// would report two different totals for one word.
homePort.usage(DEFAULT_DAYS).then((u) => put("usage", fmtTokens(u.tokens))).catch(() => {});
homePort.versions().then((v) => put("versions", v.current || "dev")).catch(() => {});
return () => { alive = false; };
return () => { alive = false; clearTimeout(walk); };
}, [homePort]);
// Focus lands once, when the pane opens, and goes back where it came from on
@@ -313,6 +323,8 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
const go = (section: Section, anchor: string) => {
navigation.leave(() => {
setAt(section);
const target = pageOf(section, anchor);
if (target) setPages((p) => ({ ...p, [section]: target }));
setQuery("");
setLanded(anchor);
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
@@ -398,9 +410,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(() => { setLanded(""); setAt(section); }); }} />
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} page={pageNow} onPage={(section, page) => navigation.leave(() => { setLanded(""); setPages((p) => ({ ...p, [section]: page })); })} />
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={pageNow ? `prefs-${at}-${pageNow}` : `prefs-${at}`} data-sec={at}>
<Boundary
fallback={
<div className="find" data-lvl="err" role="alert">
@@ -412,6 +424,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
{/* Keyed on the section: replacing this column is the navigation,
and it is the one thing here whose mount really is the cause. */}
<div className="prefs-col" key={at}>
<SettingsPageStyle section={at} page={pageNow} />
<SettingsHeading section={at} value={nav[at]} danger={danger(at)} />
{/* Every refused write on every page lands in one state, so it is
said in one place. Copied onto the pages somebody remembered, it
+17 -4
View File
@@ -2,6 +2,7 @@ import { Fragment } from "react";
import { t } from "../i18n";
import { ICON, NAV, type Section } from "./prefsnav";
import { arrowTabs } from "./tablist";
import { pagesOf } from "./settingPages";
import { UpdateDot } from "./UpdateDot";
interface Props {
@@ -11,9 +12,11 @@ interface Props {
danger: (id: Section) => boolean;
disabled: boolean;
onSelect: (id: Section) => void;
page?: string;
onPage?: (section: Section, page: string) => void;
}
export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: Props) {
export function SettingsNav({ at, values, shown, danger, disabled, onSelect, page, onPage }: Props) {
return (
<nav className="prefs-nav" role="tablist" aria-label={t("设置分类")} onKeyDown={arrowTabs}>
{NAV.map(([group, items]) => {
@@ -23,10 +26,11 @@ export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: P
<Fragment key={group}>
<div className="navsec">{t(group)}</div>
{rows.map(([id, name]) => (
<button key={id} id={`prefs-${id}`} role="tab" title={t(name)}
<Fragment key={id}>
<button id={`prefs-${id}`} role="tab" title={t(name)}
data-action="settings.section" data-value={id}
tabIndex={at === id ? 0 : -1}
aria-selected={at === id} disabled={disabled} onClick={() => onSelect(id)}>
tabIndex={at === id && pagesOf(id).length === 0 ? 0 : -1} data-open={at === id && pagesOf(id).length > 0 ? "" : undefined}
aria-selected={at === id && pagesOf(id).length === 0} disabled={disabled} onClick={() => onSelect(id)}>
<svg viewBox="0 0 16 16" aria-hidden="true">{ICON[id]}</svg>
{id === "versions" && <UpdateDot />}
<span className="nm">{t(name)}</span>
@@ -34,6 +38,15 @@ export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: P
{values[id]}
</span>
</button>
{at === id && pagesOf(id).map((p) => (
<button key={p.id} id={`prefs-${id}-${p.id}`} role="tab" className="prefs-page" title={t(p.label)}
data-action="settings.page" data-value={`${id}:${p.id}`}
tabIndex={page === p.id ? 0 : -1} aria-selected={page === p.id} disabled={disabled}
onClick={() => onPage?.(id, p.id)}>
<span className="nm">{t(p.label)}</span>
</button>
))}
</Fragment>
))}
</Fragment>
);
@@ -0,0 +1,16 @@
import { useLayoutEffect, useRef } from "react";
import type { Section } from "./prefsnav";
import { hiddenAnchors } from "./settingPages";
export function SettingsPageStyle({ section, page }: { section: Section; page: string | undefined }) {
const style = useRef<HTMLStyleElement>(null);
useLayoutEffect(() => {
const panel = style.current?.closest<HTMLElement>(".prefs-main");
if (panel) panel.scrollTop = 0;
}, [section, page]);
const hidden = page ? hiddenAnchors(section, page) : [];
if (hidden.length === 0) return null;
const blocks = hidden.map((id) => `.prefs-col [data-setting="${id}"]`).join(",");
const wrappers = hidden.map((id) => `.prefs-col details:has(> [data-setting="${id}"])`).join(",");
return <style ref={style}>{`${blocks}{display:none}${wrappers}{display:none}`}</style>;
}
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { clearToasts, pushNote, pushToast } from "../state/toasts";
import { clearToasts, pushNote, pushOffer, pushToast } from "../state/toasts";
import { ToastStack } from "./ToastStack";
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
@@ -35,4 +35,21 @@ describe("a note in the window's corner", () => {
expect(failure.getAttribute("role")).toBe("alert");
expect(failure.querySelector('[data-action="toast.report"]')).not.toBeNull();
});
it("runs what a note offers when its button is pressed, and then goes away", () => {
const run = vi.fn();
render(<ToastStack onFeedback={() => {}} />);
act(() => pushOffer("太大了", { label: "取最近的内容", run }));
const note = screen.getByText("太大了").closest(".toast")!;
expect(note.getAttribute("role")).toBe("status");
fireEvent.click(note.querySelector('[data-action="toast.act"]')!);
expect(run).toHaveBeenCalledTimes(1);
expect(screen.queryByText("太大了")).toBeNull();
});
it("offers no button when the note has nothing to offer", () => {
render(<ToastStack onFeedback={() => {}} />);
act(() => pushNote("fyi"));
expect(document.querySelector('[data-action="toast.act"]')).toBeNull();
});
});
@@ -23,6 +23,7 @@ export function ToastStack({ onFeedback, port, idle = false, look = {}, onLook =
{toasts.map((toast) => toast.tone === "note" ? (
<div className="toast note" role="status" key={toast.id}>
<span>{toast.text}</span>
{toast.action && <button data-action="toast.act" data-target={String(toast.id)} onClick={() => { dismissToast(toast.id); toast.action?.run(); }}>{toast.action.label}</button>}
<button data-action="toast.dismiss" data-target={String(toast.id)} onClick={() => dismissToast(toast.id)}>{t("知道了")}</button>
</div>
) : (
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "./testkit";
import { UploadUsage } from "./UploadUsage";
import { HttpError, type UploadReport } from "../port/port";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const report = (over: Partial<UploadReport> = {}): UploadReport => ({
from: "2026-09-28", to: "2026-10-27", bytes: 3 * 2 ** 30, requests: 1500,
daily: [{ day: "2026-10-26", bytes: 0 }, { day: "2026-10-27", bytes: 200 * 2 ** 20 }],
providers: [
{ provider: "deepseek", bytes: 2.7 * 2 ** 30, requests: 1300, percent: 90 },
{ provider: "relay", bytes: 0.3 * 2 ** 30, requests: 200, percent: 10 },
],
...over,
});
it("says what was uploaded today and in the month, with the average request, and which provider took it", async () => {
render(<UploadUsage port={{ uploadUsage: async () => report() }} />);
await screen.findByText("deepseek");
expect(screen.getByText(/^200(\.0)? MB$/)).toBeTruthy();
expect(screen.getByText(/^3(\.0)? GB,1,?500 次请求,平均每次 2(\.\d)? MB$/)).toBeTruthy();
expect(screen.getByText(/2\.7 GB(90%)/)).toBeTruthy();
expect(screen.getByText("relay")).toBeTruthy();
});
it("asks for the last thirty days", async () => {
const uploadUsage = vi.fn(async () => report());
render(<UploadUsage port={{ uploadUsage }} />);
await screen.findByText("deepseek");
expect(uploadUsage).toHaveBeenCalledWith(30);
});
it("says counting has only just started instead of drawing zeros", async () => {
render(<UploadUsage port={{ uploadUsage: async () => report({ bytes: 0, requests: 0, daily: [], providers: [] }) }} />);
await screen.findByText("还没有记录:从这个版本起开始统计。");
expect(screen.queryByText("今天上传")).toBeNull();
});
it("shows why it could not be read", async () => {
render(<UploadUsage port={{ uploadUsage: async () => { throw new HttpError(500, "x", { code: "internal.failed" }); } }} />);
expect(await screen.findByRole("alert")).toBeTruthy();
});
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { bytes, count, pct } from "../i18n/format";
import { reason } from "../i18n/kernel";
import type { AgentPort, UploadReport } from "../port/port";
import { Group } from "./Group";
const DAYS = 30;
// What model providers were sent in the last month. Every request carries the
// whole conversation, so this grows with context size times request count, not
// with how much was typed; the numbers are request bodies, before the
// connection's own overhead, and downloads are not counted.
export function UploadUsage({ port }: { port: Pick<AgentPort, "uploadUsage"> }) {
const [report, setReport] = useState<UploadReport | null>(null);
const [failed, setFailed] = useState("");
useEffect(() => {
let live = true;
port.uploadUsage(DAYS).then((r) => live && setReport(r), (e) => live && setFailed(reason(e)));
return () => { live = false; };
}, [port]);
const today = report?.daily[report.daily.length - 1]?.bytes ?? 0;
return (
<Group id="upload-usage"
title={t("上传量")}
hint={t("发给模型服务商的请求内容大小(不含连接本身的开销,也不含下载)。每次请求都会带上完整的对话,所以它取决于上下文有多长、请求有多少次,缓存命中只省费用、不省流量。")}
>
{failed && <p className="note" role="alert">{failed}</p>}
{report && report.requests === 0 && <p className="note">{t("还没有记录:从这个版本起开始统计。")}</p>}
{report && report.requests > 0 && (
<>
<div className="lrow"><span className="ds">{t("今天上传")}</span><span className="sc">{bytes(today)}</span></div>
<div className="lrow">
<span className="ds">{t("近 {n} 天上传", { n: DAYS })}</span>
<span className="sc">{t("{size},{n} 次请求,平均每次 {avg}", { size: bytes(report.bytes), n: count(report.requests), avg: bytes(Math.round(report.bytes / report.requests)) })}</span>
</div>
{report.providers.map((p) => (
<div className="lrow" key={p.provider}>
<span className="ds">{p.provider}</span>
<span className="sc">{t("{size}({share})", { size: bytes(p.bytes), share: pct(p.percent / 100) })}</span>
</div>
))}
</>
)}
</Group>
);
}
+6 -1
View File
@@ -393,7 +393,12 @@ export function Usage({ port }: { port: AgentPort }) {
<div className="uhero-v">{moneyList(report.cost)}</div>
{pricedFrom && <span className="uhero-scope">{t("{day} 起 · {n} 天有记录", { day: pricedFrom.slice(5), n: priced.length })}</span>}
{report.costEstimated && <span className="uhero-scope">{t("含估算")}</span>}
{report.costIncomplete && <span className="uhero-scope">{t("未定价调用未计入")}</span>}
{report.costIncomplete && (
<span className="uhero-scope">
{t("未定价调用未计入")}
{report.unpricedCalls ? ` · ${t("其中 {n} 笔未定价", { n: report.unpricedCalls })}` : null}
</span>
)}
</div>
{report.estimateCost?.length ? (
<div className="uhero-est">
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import "./testkit";
import { boot, STORAGE } from "../i18n";
import type { AgentPort, UsageReport } from "../port/port";
import { Usage } from "./Usage";
// The kernel's total already knew its money was incomplete; this is the line
// that says how many calls it could not price. A total that names only the
// floor leaves the reader unable to ask which calls were under it.
const setLocale = (lang: "zh" | "en") => { localStorage.setItem(STORAGE, lang); boot(); };
afterEach(() => { cleanup(); vi.restoreAllMocks(); setLocale("zh"); });
const REPORT: UsageReport = {
from: "2026-08-10", to: "2026-08-12", tokens: 3_000, requests: 9, turns: 6, cache_hit: 2_400, cache_miss: 600,
cost: [{ amount: "4", currency: "CNY" }],
// Four calls priced, five the range could not price, for two reasons.
costIncomplete: true, unpricedCalls: 5, incompleteReasons: ["missing_price_or_usage", "no_price"],
active_days: 3, top_model: "relay/a", top_provider: "relay", models: [], providers: [], daily: [],
};
async function open(report: UsageReport) {
const port = { usage: vi.fn().mockResolvedValue(report), providers: vi.fn().mockResolvedValue([]), models: vi.fn().mockResolvedValue([]) } as unknown as AgentPort;
const view = render(<Usage port={port} />);
await waitFor(() => expect(view.container.querySelector(".uhero")).toBeTruthy());
return view;
}
// The scopes beside the figure: the caveats a person reads before trusting it.
function scopes(container: HTMLElement): string {
return Array.from(container.querySelectorAll(".uhero-scope")).map((el) => el.textContent ?? "").join(" | ");
}
describe("the unpriced-call count on the usage page", () => {
it("says how many calls the total could not price, in Chinese", async () => {
const { container } = await open(REPORT);
expect(scopes(container)).toContain("未定价调用未计入");
expect(scopes(container)).toContain("其中 5 笔未定价");
});
it("says it in English too", async () => {
setLocale("en");
const { container } = await open(REPORT);
expect(scopes(container)).toContain("unpriced calls not included");
expect(scopes(container)).toContain("5 calls unpriced");
});
it("shows nothing extra for a fully priced range, in either language", async () => {
const priced: UsageReport = { ...REPORT, costIncomplete: false, unpricedCalls: 0, incompleteReasons: undefined };
for (const lang of ["zh", "en"] as const) {
setLocale(lang);
const { container, unmount } = await open(priced);
expect(scopes(container)).not.toContain(lang === "zh" ? "未定价" : "unpriced");
unmount();
}
});
it("does not invent a zero count for a payload that predates the field", async () => {
const { container } = await open({ ...REPORT, unpricedCalls: undefined });
expect(scopes(container)).toContain("未定价调用未计入");
expect(scopes(container)).not.toContain("其中 0 笔未定价");
});
});
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import { DisplayCurrency } from "./DisplayCurrency";
import { Group } from "./Group";
import { UploadUsage } from "./UploadUsage";
import { Usage } from "./Usage";
// The report is keyed on the currency choice: its amounts are read once, so a
@@ -18,6 +19,7 @@ export function UsageSettings({ port, onChanged }: { port: AgentPort; onChanged:
>
<Usage key={rev} port={port} />
</Group>
<UploadUsage port={port} />
</>
);
}
@@ -370,7 +370,7 @@ describe("WorkbenchPanel", () => {
expect(screen.getByRole("tab", { name: "百度热搜" }).getAttribute("aria-selected")).toBe("true");
});
it("keeps the column while another tab is open, and folds it with the last", async () => {
it("keeps the column while another tab is open, and offers a new tab with the last", async () => {
const user = userEvent.setup();
const onCloseManual = vi.fn();
const pages = [
@@ -382,7 +382,8 @@ describe("WorkbenchPanel", () => {
expect(onCloseManual).not.toHaveBeenCalled();
expect(screen.getByRole("tab", { name: "My page" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "关闭 My page" }));
expect(onCloseManual).toHaveBeenCalledTimes(1);
expect(onCloseManual).not.toHaveBeenCalled();
expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
});
it("keeps the column when its last tab closes while the explorer is open", async () => {
@@ -0,0 +1,17 @@
import { afterEach, expect, it, vi } from "vitest";
afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
it.each([
["Macintosh", "⌘⇧Space", "⌃⌥↑"],
["Windows", "Ctrl+Shift+Space", "Ctrl+Alt+↑"],
["Linux", "Ctrl+Shift+Space", "Ctrl+Alt+↑"],
])("presents accelerators in %s notation", async (userAgent, primary, explicit) => {
vi.stubGlobal("navigator", { userAgent });
vi.resetModules();
// Platform detection happens at module load, after this test supplies the agent.
const { acceleratorLabel } = await import("./keys");
expect(acceleratorLabel("CommandOrControl+Shift+Space")).toBe(primary);
expect(acceleratorLabel("Control+Alt+Up")).toBe(explicit);
expect(acceleratorLabel("")).toBe("");
});
@@ -40,6 +40,10 @@ export function toolErrorDetail(tool: Tool): string {
case "video.ffmpeg_missing":
case "video.unsupported_container":
case "video.too_large":
case "shell.mixed_mutation_verification":
case "shell.inline_interpreter_not_last":
case "batch.skipped_after_failed_change":
case "batch.check_after_stopped_call":
if (toolRefusalReason(tool)) return "";
}
return tool.err ?? "";
+21 -9
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { listenAction } from "./listen";
@@ -39,21 +39,33 @@ export function useDismiss(
}, [open, box, close, also]);
}
const escapeLayers: object[] = [];
/** Escape closes this layer, and stops there.
*
* The settings sheet listens for Escape on the window, so an inline form that
* does not claim the key first goes down with the whole sheet — and takes
* whatever was typed into it. Claiming it in the capture phase is what makes
* the innermost open thing the one that answers.
* Layers answer innermost first: each one opened registers on a stack in the
* order it mounted, and only the top answers, so a screenshot viewer over a
* dialog closes on its own and leaves the dialog for the next press. Same-target
* listeners run in registration order and stopPropagation does not end that,
* which is why a stack, not the event, decides.
*/
export function useEscape(open: boolean, close: () => void) {
const closeRef = useRef(close);
closeRef.current = close;
useEffect(() => {
if (!open) return;
const layer = {};
escapeLayers.push(layer);
const esc = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (e.key !== "Escape" || escapeLayers[escapeLayers.length - 1] !== layer) return;
e.stopPropagation();
close();
closeRef.current();
};
return listenAction(window, "keydown", { action: "layer.dismiss", listener: esc as EventListener }, true);
}, [open, close]);
const off = listenAction(window, "keydown", { action: "layer.dismiss", listener: esc as EventListener }, true);
return () => {
off();
const at = escapeLayers.indexOf(layer);
if (at >= 0) escapeLayers.splice(at, 1);
};
}, [open]);
}
+17 -3
View File
@@ -10,10 +10,16 @@ export interface FeedbackDraft {
const EMPTY: FeedbackDraft = { category: "bug", body: "", contact: "", shots: [] };
let held: FeedbackDraft = EMPTY;
const CONTACT_KEY = "rx-feedback-contact";
let held: FeedbackDraft | null = null;
export function heldDraft(): FeedbackDraft {
return held;
if (held) return held;
try {
return { ...EMPTY, contact: localStorage.getItem(CONTACT_KEY) ?? "" };
} catch {
return EMPTY;
}
}
export function holdDraft(next: FeedbackDraft): void {
@@ -21,5 +27,13 @@ export function holdDraft(next: FeedbackDraft): void {
}
export function dropDraft(): void {
held = EMPTY;
held = null;
}
export function rememberContact(contact: string): void {
try {
localStorage.setItem(CONTACT_KEY, contact);
} catch {
// Unavailable storage must not turn an accepted report into a failed send.
}
}
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { boot, STORAGE, t } from "../i18n";
import type { GlobalShortcutInfo } from "../port/host";
import { acceleratorLabel } from "./keys";
const fixture = vi.hoisted(() => ({ shell: true, now: { binding: "", status: "disabled" } as GlobalShortcutInfo, apply: vi.fn(), read: vi.fn() }));
vi.mock("../port/host", () => ({ host: () => ({ inShell: () => fixture.shell, globalShortcut: fixture.read, setGlobalShortcut: fixture.apply }) }));
import { GlobalShortcutSection } from "./GlobalShortcutSection";
beforeEach(() => {
localStorage.clear(); localStorage.setItem(STORAGE, "zh"); boot();
fixture.shell = true; fixture.now = { binding: "", status: "disabled" };
fixture.read.mockImplementation(async () => fixture.now);
fixture.apply.mockReset();
});
afterEach(cleanup);
it("is absent in a browser and cannot configure a remote host", async () => {
fixture.shell = false; fixture.now = { binding: "", status: "unsupported" };
render(<GlobalShortcutSection />);
await Promise.resolve();
expect(screen.queryByRole("textbox")).toBeNull();
expect(fixture.apply).not.toHaveBeenCalled();
});
it("applies immediately through the shell, then disables without localStorage state", async () => {
fixture.apply.mockImplementation(async (binding: string) => ({ binding, status: binding ? "registered" : "disabled" }));
render(<GlobalShortcutSection />);
await screen.findByText(t("全局快捷键已关闭"));
await userEvent.type(screen.getByRole("textbox"), "Control+Shift+Space");
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
expect(fixture.apply).toHaveBeenLastCalledWith("Control+Shift+Space");
expect((await screen.findByRole("status")).textContent).toContain(acceleratorLabel("Control+Shift+Space"));
expect(localStorage.length).toBe(1);
await userEvent.click(screen.getByRole("button", { name: t("清除快捷键") }));
expect(fixture.apply).toHaveBeenLastCalledWith("");
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("");
expect(screen.getByRole("status").textContent).toBe(t("全局快捷键已关闭"));
});
it.each(["invalid", "unavailable", "save"] as const)("shows %s rebind failure beside the still-active old binding", async error => {
fixture.now = { binding: "Control+Space", status: "registered" };
fixture.apply.mockResolvedValue({ ...fixture.now, error });
render(<GlobalShortcutSection />);
await screen.findByText(t("已注册:{value}", { value: acceleratorLabel("Control+Space") }));
await userEvent.clear(screen.getByRole("textbox"));
await userEvent.type(screen.getByRole("textbox"), "occupied");
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.getByRole("status").textContent).toContain(acceleratorLabel("Control+Space"));
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("occupied");
});
it("reports startup failure and lets the person retry the saved binding", async () => {
fixture.now = { binding: "Control+Space", status: "unavailable" };
fixture.apply.mockResolvedValue({ binding: "Control+Space", status: "registered" });
render(<GlobalShortcutSection />);
await screen.findByText(t("系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。"));
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
expect(fixture.apply).toHaveBeenCalledWith("Control+Space");
expect(screen.getByRole("status").textContent).toContain(acceleratorLabel("Control+Space"));
});
it("does not call an IPC transport failure a successful registration", async () => {
fixture.apply.mockRejectedValue(new Error("shell unavailable"));
render(<GlobalShortcutSection />);
await screen.findByText(t("全局快捷键已关闭"));
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe(t("全局快捷键已关闭"));
});
it("records a modifier chord without submitting it, presents it, and clears it", async () => {
fixture.apply.mockImplementation(async (binding: string) => ({ binding, status: binding ? "registered" : "disabled" }));
render(<GlobalShortcutSection />);
await screen.findByText(t("全局快捷键已关闭"));
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(document.activeElement).toBe(input);
expect(input.readOnly).toBe(true);
fireEvent.keyDown(input, { key: "c", code: "KeyC" });
fireEvent.keyDown(input, { key: "A", code: "KeyA", shiftKey: true });
fireEvent.keyDown(input, { key: " ", code: "Space", shiftKey: true });
expect(input.value).toBe("");
expect(input.readOnly).toBe(true);
fireEvent.keyDown(input, { key: "Control", ctrlKey: true });
expect(input.value).toBe("");
fireEvent.keyDown(input, { key: " ", code: "Space", ctrlKey: true, shiftKey: true });
expect(input.value).toBe("Control+Shift+Space");
expect(input.readOnly).toBe(false);
expect(screen.getByText(acceleratorLabel("Control+Shift+Space")).tagName).toBe("KBD");
expect(fixture.apply).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
expect(fixture.apply).toHaveBeenLastCalledWith("Control+Shift+Space");
await userEvent.click(screen.getByRole("button", { name: t("清除快捷键") }));
expect(fixture.apply).toHaveBeenLastCalledWith("");
expect(input.value).toBe("");
});
it("can cancel capture with Escape or Tab without changing the existing choice", async () => {
fixture.now = { binding: "Control+Space", status: "registered" };
render(<GlobalShortcutSection />);
await screen.findByRole("status");
const input = screen.getByRole("textbox") as HTMLInputElement;
for (const key of ["Escape", "Tab"]) {
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
await userEvent.keyboard(`{${key}}`);
expect(input.readOnly).toBe(false);
expect(input.value).toBe("Control+Space");
}
expect(fixture.apply).not.toHaveBeenCalled();
});
it("records Command, Option, navigation and plus keys without Electron grammar", async () => {
render(<GlobalShortcutSection />);
await screen.findByRole("status");
const input = screen.getByRole("textbox") as HTMLInputElement;
for (const [key, binding] of [["ArrowUp", "Alt+Super+Up"], ["+", "Alt+Super+Plus"]]) {
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
fireEvent.keyDown(input, { key, altKey: true, metaKey: true });
expect(input.value).toBe(binding);
}
expect(fixture.apply).not.toHaveBeenCalled();
});
it.each([
[{ key: "˚", code: "KeyK", altKey: true }, "Alt+K"],
[{ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }, "Control+Shift+1"],
[{ key: "?", code: "Slash", ctrlKey: true, shiftKey: true }, "Control+Shift+/"],
])("records a chord when the layout produces $key", async (event, binding) => {
render(<GlobalShortcutSection />);
await screen.findByRole("status");
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.keyDown(input, event);
expect(input.value).toBe(binding);
expect(fixture.apply).not.toHaveBeenCalled();
});
@@ -13,7 +13,7 @@ import type { SessionStatus } from "../port/port";
const answer: { now: GraphicsInfo | null } = { now: null };
vi.mock("../port/host", async (original) => {
const real = await original<typeof import("../port/host")>();
return { ...real, host: () => ({ ...real.host(), graphics: () => Promise.resolve(answer.now) }) };
return { ...real, host: () => Object.assign(Object.create(real.host()), { graphics: () => Promise.resolve(answer.now) }) };
});
afterEach(() => {
@@ -23,7 +23,7 @@ afterEach(() => {
boot();
});
async function search(q: string) {
async function search(q: string, anchor = "graphics") {
render(<Settings
hub={new MockHub() as never} port={new MockPort()}
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
@@ -34,7 +34,7 @@ async function search(q: string) {
/>);
await act(async () => {});
await userEvent.type(screen.getByRole("textbox", { name: t("搜索设置") }), q);
return screen.queryAllByRole("option").some((o) => (o as HTMLElement).dataset.target === "graphics");
return screen.queryAllByRole("option").some((o) => (o as HTMLElement).dataset.target === anchor);
}
it("does not offer the graphics setting in a browser tab", async () => {
@@ -45,3 +45,7 @@ it("offers the graphics setting where the shell answers", async () => {
answer.now = { launchedOff: false, savedOff: false, compositing: "enabled" };
expect(await search("gpu")).toBe(true);
});
it("does not offer a local shell global shortcut in a browser or network viewer", async () => {
expect(await search("global", "global-shortcut")).toBe(false);
});
+7
View File
@@ -15,6 +15,13 @@ export function chord(key: string): string {
return mac ? `⌘${key}` : `Ctrl ${key}`;
}
export function acceleratorLabel(binding: string): string {
const names: Record<string, string> = mac
? { CommandOrControl: "⌘", CmdOrCtrl: "⌘", Command: "⌘", Cmd: "⌘", Control: "⌃", Ctrl: "⌃", Alt: "⌥", Option: "⌥", Shift: "⇧", Super: "⌘", Meta: "⌘", Space: "Space", Up: "↑", Down: "↓", Left: "←", Right: "→" }
: { CommandOrControl: "Ctrl", CmdOrCtrl: "Ctrl", Control: "Ctrl", Command: "Super", Cmd: "Super", Meta: "Super", Option: "Alt", Up: "↑", Down: "↓", Left: "←", Right: "→" };
return binding.split("+").map(part => names[part] ?? part).join(mac ? "" : "+");
}
// asksDelete reads the key that deletes whatever holds focus: Delete everywhere,
// and ⌘⌫ on macOS, whose main key sends Backspace.
export function asksDelete(ev: { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean {
+17 -1
View File
@@ -1,7 +1,8 @@
import { expect, it } from "vitest";
import type { ModelEntry } from "../port/port";
import { accountKey } from "./vendors";
import { modelMenu } from "./modelmenu";
import { modelLabel, modelMenu } from "./modelmenu";
import { UNCONFIGURED_REF } from "./ConnectNote";
it("groups the composer model menu by saved account order", () => {
const models: ModelEntry[] = [
@@ -91,3 +92,18 @@ it("puts a relay's own mark on its heading and leaves its model rows to the lett
expect(items.find((it) => it.header)?.mark).toBe("openrouter");
expect(items.find((it) => it.value === "or/x")?.mark).toBeUndefined();
});
it("names the session's model by alias, then short form, then the id without its provider", () => {
const models: ModelEntry[] = [
{ ref: "relay/grp/aliased", provider: "relay", vendor: "relay.example", model: "grp/aliased", modelAlias: " 快模型 ", modelShort: "aliased" },
{ ref: "relay/grp/short", provider: "relay", vendor: "relay.example", model: "grp/short", modelShort: "short" },
{ ref: "relay/plain", provider: "relay", vendor: "relay.example", model: "plain" },
];
expect(modelLabel(models, { modelRef: "relay/grp/aliased" })).toBe("快模型");
expect(modelLabel(models, { modelRef: "relay/grp/short" })).toBe("short");
expect(modelLabel(models, { modelRef: "relay/plain" })).toBe("plain");
expect(modelLabel(models, { modelRef: "gone/other/model", label: "x" })).toBe("other/model");
expect(modelLabel(models, { label: "from-status" })).toBe("from-status");
expect(modelLabel(models, null)).toBe("—");
expect(modelLabel(models, { modelRef: UNCONFIGURED_REF })).toBe("未连接");
});
+10 -1
View File
@@ -3,6 +3,7 @@ import type { ModelEntry } from "../port/port";
import type { MenuItem } from "./Menu";
import { groupVendors } from "./Models";
import { t } from "../i18n";
import { UNCONFIGURED_REF } from "./ConnectNote";
import { KIND_LABEL } from "./vendors";
import { orderAccounts } from "../state/providerorder";
@@ -13,6 +14,14 @@ export function modelTooltip(models: ModelEntry[], current: string | undefined,
return account ? `${account.label} / ${label}` : current ?? label;
}
// modelLabel is how the composer names the session's model: the alias, else the
// entry's short form, else the id without its provider.
export function modelLabel(models: ModelEntry[], status: { modelRef?: string; label?: string } | null | undefined): string {
if (status?.modelRef === UNCONFIGURED_REF) return t("未连接");
const m = models.find((x) => x.ref === status?.modelRef);
return m?.modelAlias?.trim() || m?.modelShort || (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—");
}
// Every account gets a heading with its endpoint: the provider name is the
// config's word for an entry, not the user's for an endpoint, and two doors onto
// one account share it. Rows carry the wire format beside the model name.
@@ -27,7 +36,7 @@ export function modelMenu(models: ModelEntry[], order: readonly string[] = [], c
for (const m of a.byKind[kind]) {
out.push({
value: m.ref,
label: m.modelAlias?.trim() || m.model,
label: m.modelAlias?.trim() || m.modelShort || m.model,
desc: m.modelAlias?.trim() ? m.model : undefined,
meta: t(KIND_LABEL[kind] ?? kind),
badge: m.vision ? t("读图") : undefined,
@@ -60,7 +60,8 @@ const toggleSidebar = () => userEvent.click(document.querySelector<HTMLElement>(
async function openAppearance() {
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
await userEvent.click(await screen.findByRole("tab", { name: /^外观/ }));
await userEvent.click(await screen.findByRole("tab", { name: /导航与侧栏/ }));
await screen.findByRole("group", { name: "图标栏" });
}
const choice = (name: string) => screen.getByRole("button", { name });
@@ -304,6 +304,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "versions", anchor: "sponsor", title: "赞助", scope: "machine", apply: "none", keywords: ["捐助", "支付宝", "微信支付", "收款码", "paypal", "donate", "sponsor", "support", "alipay", "wechat"] },
{ section: "memory", anchor: "memory", title: "记忆", scope: "chosen", apply: "immediate", keywords: ["记住", "忘记", "事实"] },
{ section: "usage", anchor: "currency", title: "费用显示币种", scope: "machine", apply: "immediate", keywords: ["币种", "货币", "人民币", "美元", "currency", "CNY", "USD", "汇率"] },
{ section: "usage", anchor: "upload-usage", title: "上传量", scope: "machine", apply: "none", keywords: ["流量", "上传", "带宽", "请求", "upload", "traffic", "bandwidth"] },
{ section: "usage", anchor: "usage", title: "用量与成本", scope: "machine", apply: "none", keywords: ["token", "花费", "缓存命中"] },
{ section: "storage", anchor: "storage", title: "存储", scope: "machine", apply: "restart", keywords: ["搬家", "迁移", "磁盘", "位置"] },
{ section: "advanced", anchor: "elsewhere", title: "本版本尚未提供", scope: "machine", apply: "none", keywords: ["配置文件"] },
@@ -319,6 +320,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "appearance", anchor: "legacy-sessions", title: "1.x 旧会话", scope: "machine", apply: "immediate", keywords: ["1.x", "旧会话", "迁移", "隐藏", "legacy", "sessions", "sidebar"] },
{ section: "appearance", anchor: "live-grace", title: "进行中列表保留已完成会话", scope: "machine", apply: "immediate", keywords: ["进行中", "完成", "保留", "过滤", "running", "grace", "sidebar"] },
{ section: "appearance", anchor: "send-shortcut", title: "发送快捷键", scope: "machine", apply: "immediate", keywords: ["发送", "回车", "换行", "enter", "ctrl", "cmd"] },
{ section: "appearance", anchor: "global-shortcut", title: "全局显示 / 隐藏快捷键", scope: "machine", apply: "immediate", keywords: ["global", "show", "hide", "shortcut", "keyboard", "全局", "显示", "隐藏"] },
{ section: "appearance", anchor: "keys", title: "快捷键", scope: "machine", apply: "immediate", keywords: ["shortcut", "keyboard", "ctrl", "cmd", "esc", "home", "查找", "搜索", "运行分析", "工作台"] },
{ section: "appearance", anchor: "esc-stop", title: "Esc 停止任务", scope: "machine", apply: "immediate", keywords: ["esc", "停止", "中断", "确认", "误触", "stop"] },
{ section: "appearance", anchor: "page-sleep", title: "空闲网页休眠", scope: "machine", apply: "immediate", keywords: ["休眠", "睡眠", "空闲", "内存", "收起", "browser", "page", "sleep", "memory"] },
@@ -58,6 +58,17 @@ describe("queued submission and withdrawal", () => {
expect(screen.getAllByText("待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送")).toHaveLength(1);
});
it("takes the held-queue notice back once the queue resumes", async () => {
const port = await open();
vi.spyOn(port, "steer").mockImplementation(async (text) => ({ ...(await port.queueFollowup(text)), paused: true }));
type(BODY);
fireEvent.click(screen.getByRole("button", { name: "插话" }));
await screen.findByText("待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送");
await act(async () => { await port.setQueuePaused(true); });
await act(async () => { await port.setQueuePaused(false); });
await waitFor(() => expect(screen.queryByText("待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送")).toBeNull());
});
it("says the queue is paused when an idle skill send is held by it", async () => {
const port = new MockPort();
render(<Pane {...props} port={port} />);
@@ -41,6 +41,36 @@ const paint = (q: QueueSnapshot, running: boolean) =>
const draw = (q: QueueSnapshot) => paint(q, true);
const drawIdle = (q: QueueSnapshot) => paint(q, false);
describe("compact queue presentation", () => {
it("keeps action identities and order when edit and withdraw become icons", () => {
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
expect(html).toContain('title="编辑" aria-label="改"');
expect(html).toContain('title="取回" aria-label="取回"');
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
expect(actions).toEqual(["queue.pause", "queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel",
"queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel"]);
expect(html).not.toContain("draggable");
});
it("says paused once and preserves the resume control", () => {
const html = draw(snapshot({ paused: true }));
expect(html).toContain("发送已暂停");
expect(html).not.toContain('data-hold');
expect(html).toContain("继续派发");
});
it("keeps long reasons in titles and limits inline reasons to held entries", () => {
const why = "long delivery explanation";
const queued = draw(snapshot({ items: [item({ state: "queued", blockReason: why })] }));
expect(queued).toContain(`title="${why}"`);
expect(queued).not.toContain('class="qwhy"');
for (const state of ["blocked", "uncertain"] as const) {
expect(draw(snapshot({ items: [item({ state, blockReason: why })] })))
.toContain(`class="qwhy" title="${why}"`);
}
});
});
describe("why a held entry stopped", () => {
const held = (over: Partial<QueueItem>) => draw(snapshot({ paused: true, items: [item({ state: "uncertain", ...over })] }));
+7 -1
View File
@@ -19,6 +19,8 @@ interface Inputs {
* another window's lines in front of this one. */
export function useQueueActions({ port, dispatch, fail, moved, sessionState = "settled", sessionPath }: Inputs) {
const [queue, setQueue] = useState<QueueSnapshot | null>(null);
const dispatchRef = useRef(dispatch);
dispatchRef.current = dispatch;
// The last line taken back, numbered so the same text twice is two requests.
const [restored, setRestored] = useState({ n: 0, text: "" });
const onRestoreText = useCallback((text: string) => setRestored((r) => ({ n: r.n + 1, text })), []);
@@ -29,7 +31,11 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionState = "s
useEffect(() => {
if (sessionState === "pending" && !moved) return;
let current = true;
port.queue().then((q) => current && setQueue(q)).catch(() => current && setQueue(null));
port.queue().then((q) => {
if (!current) return;
setQueue(q);
if (!q.paused) dispatchRef.current({ kind: "__queue_resumed" } as never);
}).catch(() => current && setQueue(null));
return () => { current = false; };
}, [port, moved, sessionState, sessionPath]);
const taking = useRef(new Set<string>());
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { SETTINGS } from "./prefsnav";
import { PAGES, pageOf, firstPage, hiddenAnchors } from "./settingPages";
describe("the pages of a long settings section", () => {
const paged = Object.keys(PAGES) as (keyof typeof PAGES)[];
it("has at least two pages wherever it has any", () => {
for (const section of paged) expect(PAGES[section]!.length, section).toBeGreaterThanOrEqual(2);
});
it("files every setting of a paged section on exactly one page", () => {
for (const section of paged) {
const anchors = SETTINGS.filter((e) => e.section === section).map((e) => e.anchor);
const filed = PAGES[section]!.flatMap((p) => p.anchors);
expect([...filed].sort(), `${section}: every row on one page, no page naming a row it does not have`).toEqual([...anchors].sort());
}
});
it("answers which page an anchor is on, and the first page of a section", () => {
expect(pageOf("tools", "sandbox")).toBe("sandbox");
expect(pageOf("tools", "approval")).toBe(firstPage("tools"));
expect(pageOf("session", "preset")).toBeUndefined();
expect(firstPage("session")).toBeUndefined();
});
it("hides everything a page does not own and nothing it does", () => {
const hidden = hiddenAnchors("tools", "sandbox");
expect(hidden).toContain("approval");
expect(hidden).not.toContain("sandbox");
expect(hidden).not.toContain("shell");
expect(hiddenAnchors("session", "anything")).toEqual([]);
});
});
@@ -0,0 +1,36 @@
import type { Section } from "./prefsnav";
export interface SettingPage {
id: string;
label: string;
anchors: readonly string[];
}
export const PAGES: Partial<Record<Section, readonly SettingPage[]>> = {
tools: [
{ id: "approval", label: "批准与规则", anchors: ["approval", "plan-approval", "external-origin", "rules", "tool-list", "present-files"] },
{ id: "sandbox", label: "沙箱与执行", anchors: ["sandbox", "shell"] },
{ id: "web", label: "联网与浏览器", anchors: ["web-access", "browser-tools"] },
{ id: "subagents", label: "子代理与协作", anchors: ["subagent-claims", "subagent-steer", "write-lease"] },
],
appearance: [
{ id: "look", label: "外观与主题", anchors: ["mode", "scheme", "font", "size", "weight", "contrast", "effects", "wallpaper", "language", "advanced-appearance", "graphics"] },
{ id: "navigation", label: "导航与侧栏", anchors: ["navrail", "naventries", "session-quick-actions", "legacy-sessions", "live-grace", "open-session-limit", "live-stream-budget", "session-tabs"] },
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "folding"] },
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "global-shortcut", "keys", "esc-stop"] },
{ id: "window", label: "窗口与性能", anchors: ["window", "notify", "page-sleep"] },
],
};
export const pagesOf = (section: Section): readonly SettingPage[] => PAGES[section] ?? [];
export const firstPage = (section: Section): string | undefined => PAGES[section]?.[0]?.id;
export const pageOf = (section: Section, anchor: string): string | undefined =>
PAGES[section]?.find((p) => p.anchors.includes(anchor))?.id;
export const hiddenAnchors = (section: Section, page: string): string[] => {
const pages = PAGES[section];
if (!pages) return [];
return pages.filter((p) => p.id !== page).flatMap((p) => [...p.anchors]);
};
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more