Compare commits

...
Author SHA1 Message Date
YHH d338338d54 Merge pull request #12022 from esengine/docs/studio-2.28.0-notes
docs(release): Studio 2.28.0 notes
2026-10-04 16:58:29 -07:00
yhh 8df12a049a docs(release): Studio 2.28.0 notes 2026-10-04 16:51:12 -07:00
YHH ab25352696 Merge pull request #12018 from esengine/fix/bash-rule-wrapper-peeling
fix(permission): match Bash deny and ask rules through transparent command wrappers
2026-10-04 16:33:38 -07:00
yhh 1fa63d16f0 fix(permission): match Bash deny and ask rules through transparent command wrappers
A deny or ask rule such as Bash(rm:*) compared only the words the model wrote,
so `env X=1 rm`, `sudo rm`, `command rm`, `nohup rm`, `nice rm`, `time rm` and
`/bin/rm` were judged by fallback mode instead of the rule.

shellparse.PeelWrappers reads one simple command from the shell AST and
follows a fixed set of wrappers (env, sudo, doas, command, builtin, exec,
nohup, time, nice) down to the program they run, naming a path-spelled program
by its base name. Deny and ask matching test every layer; allow and session
rules are unchanged. A wrapper whose target cannot be named statically needs a
human instead of falling through to the mode.
2026-10-04 16:24:22 -07:00
YHH e40b3b070f Merge pull request #12006 from esengine/fix/model-list-two-keys-one-endpoint
fix(serve): keep models from two accounts at one endpoint in the model list
2026-10-04 14:12:44 -07:00
yhh fc2887582e fix(serve): keep models from two accounts at one endpoint in the model list
The model list collapsed entries by endpoint + model, so two providers
sharing a base URL with different API keys listed each model once and
the second account's refs vanished from the picker.
2026-10-04 13:58:32 -07:00
YHH 8f8785c4c4 Merge pull request #11697 from KHG420/codex/studio-plugin-profile-diagnostics
fix(studio): diagnose rejected plugin profile declarations
2026-10-04 13:58:02 -07:00
xiexian e6d5dab212 fix(studio): diagnose rejected plugin profile declarations 2026-10-05 04:39:40 +08:00
YHH 403ae88dd2 Merge pull request #12004 from esengine/fix/boot-flaky-tests
test(boot): deflake URL-interaction and fullsidecar effect tests
2026-10-04 13:25:38 -07:00
YHH f7eb4e26a6 Merge pull request #12002 from esengine/fix/flaky-instances-differ-test
test(context-retrieval): assert live runs are seeded from entropy, not that two answers differ
2026-10-04 13:19:31 -07:00
yhh a4e456a43e test(boot): wait for lazy MCP swap before URL-interaction run; give sidecar build a 5m bound and name its timeout 2026-10-04 13:16:21 -07:00
YHH 42455e61fb Merge pull request #11844 from KHG420/codex/studio-sdk-tool-call-identifiers
fix(sdk): validate provider tool call identifiers
2026-10-04 13:12:36 -07:00
esengine 677e3eabe2 test(context-retrieval): assert live runs are seeded from entropy, not that two answers differ
TestInstancesDifferBetweenRuns instantiated every task twice and failed
whenever two independent draws collided. Three tasks answer with a bounded
number (i70 quota 1200..9800, i47 lease 15..300, i112 lease 20..420), so a
run fails with probability 1/8601 + 1/286 + 1/401, about 0.61%.

liveRand now delegates to liveRandFrom(io.Reader). The test asserts the
invariant at the seed: two live generators do not share a stream, the same
entropy yields identical instances and different entropy yields different
values.
2026-10-04 12:59:18 -07:00
YHH 090060507c Merge pull request #11742 from KHG420/codex/studio-unicode-agent-inventory
fix(studio): match package agent inventory to runtime names
2026-10-04 12:56:01 -07:00
xiexian cb1b36d726 fix(studio): match package agent inventory to runtime names 2026-10-05 03:41:01 +08:00
YHH 255001cead Merge pull request #11843 from KHG420/codex/studio-sdk-usage-counts
fix(sdk): reject negative provider token usage
2026-10-04 12:30:56 -07:00
YHH 7ed449841b Merge pull request #12000 from esengine/fix/state-root-walk-windows-delete
test(config): treat a directory mid-deletion on Windows as vanished in the state-root walk
2026-10-04 12:30:30 -07:00
yhh 28455421fb test(config): treat a directory mid-deletion on Windows as vanished in the state-root walk
The walk tolerated only not-exist, but a directory being removed on Windows
fails to open with a sharing violation or access denied. Those errors now
count as vanished only when a re-stat shows the path gone.
2026-10-04 12:16:04 -07:00
YHH 8cfb5f75c7 Merge pull request #11702 from KHG420/codex/studio-directory-agent-inventory
fix(studio): guard agent inventory source residency
2026-10-04 12:00:42 -07:00
xiexian 116d0b4176 fix(studio): guard agent inventory source residency 2026-10-05 02:42:01 +08:00
YHH fc1f7fdcfa Merge pull request #11837 from KHG420/codex/studio-sdk-chunk-types
fix(sdk): reject unregistered provider chunk types
2026-10-04 11:33:32 -07:00
xiexian 9d5f2a7dd9 fix(sdk): validate provider tool call identifiers 2026-10-05 02:28:31 +08:00
xiexian 63754d56d2 fix(sdk): reject negative provider token usage 2026-10-05 02:25:05 +08:00
xiexian 0e06829846 fix(sdk): reject unregistered provider chunk types 2026-10-05 02:22:10 +08:00
YHH 54f4fbb19f Merge pull request #11667 from Harbor404/fix/studio-11605-copy-session-info
feat(studio): copy session handoff information
2026-10-04 11:02:22 -07:00
YHH 03147a71cd Merge pull request #11839 from KHG420/codex/studio-sdk-echo-provider
feat(sdk): add an installable offline provider example
2026-10-04 11:01:31 -07:00
YHH 65a06434fb Merge pull request #11997 from esengine/fix/sdk-unused-time-import
fix(sdk): drop the import the merged intercept-budget change left unused
2026-10-04 10:45:05 -07:00
yhh fdba7c3210 fix(sdk): drop the import the merged intercept-budget change left unused 2026-10-04 10:36:19 -07:00
YHH 254140a866 Merge pull request #11795 from KHG420/codex/studio-sdk-provider-start-cancel
fix(sdk): cancel provider streams while opening
2026-10-04 10:20:50 -07:00
YHH b02849f54f Merge pull request #11719 from KHG420/codex/studio-runtime-doctor-executable
fix(cli): detect non-executable plugin runtimes in doctor
2026-10-04 10:13:26 -07:00
YHH 66bfceb85b Merge pull request #11789 from KHG420/codex/studio-sdk-externalized-intercept-budget
fix(sdk): include externalized reads in the intercept budget
2026-10-04 10:10:08 -07:00
xiexian 39fe0acdc5 fix(sdk): cancel provider streams while opening 2026-10-05 01:04:37 +08:00
YHH 931b9aea42 Merge pull request #11968 from esengine/docs/cli-migration-fixes
docs(migrating): match the verified 2.x CLI behaviour for 1.x users
2026-10-04 09:45:01 -07:00
YHH cb2485a0b8 Merge pull request #11990 from esengine/fix/model-check-shows-status
fix(providers): show HTTP status and endpoint text when a model check is refused
2026-10-04 09:43:44 -07:00
YHH 172d05191a Merge pull request #11852 from chuiwenwei/test/studio-verification-stress-solutions
test(e2ebench): prove every declared-solvable verification-stress task can pass
2026-10-04 09:41:03 -07:00
YHH 8c87da114b Merge pull request #11982 from esengine/fix/link-open-failed-page-no-second-browser
fix(studio): a link whose page failed to load no longer also opens the system browser
2026-10-04 09:35:37 -07:00
yhh 8634cf3da2 fix(providers): show the HTTP status and the endpoint's words when a model check is refused
Cause: the per-model check classified any 4xx it could not name as
unknown/rejected and the row printed only "request refused, not confirmed".
The kernel already carried httpStatus; the settings UI dropped it, and the
endpoint's own error text was never sent, so a local Ollama 404 (base URL
without /v1, model id without its tag), a 400 for a model that cannot take
tools, and a missing key on a System One entry all read identically.

Blast radius: POST /providers/check/model gains an optional `detail` field:
the endpoint's error text as one printable line. It is built in this order:
control characters and ANSI sequences replaced, the probe key masked in raw,
URL-escaped and JSON-escaped form (skipped for keys under 8 characters),
secrets.RedactCredentials, then cut to 300 runes. Folding and the key
replacement see the whole text; only the pattern scrub is bounded (8 KiB cut
on a word boundary, after folding). The model row shows an HTTP badge and the text inline. Status and
reason are unchanged and never derived from the text.

Neighbouring behaviours tested: Ollama-shaped upstream (correct, base without
/v1, tagless id, System One kind); 400/404/405/422 classification; detail
equals the upstream text, masks raw/escaped/plain keys, Bearer, ?token=,
Cookie, neutralises newlines/ANSI/NUL, cuts after redaction, cuts at the scan cap with the key split at every offset, with and
without leading whitespace, bounds a 4 MB body, leaves short keys alone (each mutation-checked); add and edit flows
share one fact helper; full ui vitest suite and the serve package.

Why this layer: the serve handler owns the typed result and the row is the only
renderer of it; classification stays where the failure is first known.
2026-10-04 09:31:09 -07:00
YHH 0a66bd417d Merge pull request #11791 from KHG420/codex/studio-sdk-stream-context-lifetime
fix(sdk): cancel provider contexts when streams finish
2026-10-04 09:25:48 -07:00
YHH 72030d75a3 Merge pull request #11806 from KHG420/codex/studio-market-blocked-previews
fix(studio): keep blocked market previews out of confirmation
2026-10-04 09:21:30 -07:00
YHH 5dfcc2d086 Merge pull request #11987 from esengine/fix/acp-resume-status-notif
test(acp): resume invariant is no transcript replay, not no notification
2026-10-04 09:16:32 -07:00
yhh 42d4ab374c test(acp): resume invariant is no transcript replay, not no notification 2026-10-04 09:06:20 -07:00
YHH 2921ddff26 Merge pull request #11969 from esengine/fix/tui-unknown-slash-as-message
fix(tui): send an unknown slash command to the model with a visible notice
2026-10-04 09:06:09 -07:00
YHH 750ed087db Merge pull request #11981 from esengine/fix/list-folder-of-first-turn
fix(serve): list the folder a first turn is held in
2026-10-04 08:58:31 -07:00
YHH a2a5855f23 Merge pull request #11973 from esengine/fix/compaction-block-release
fix(compaction): release a transient summary failure once the input has grown
2026-10-04 08:56:26 -07:00
yhh 9052fd32f6 fix(studio): a link whose page failed to load no longer also opens the system browser
POST /browser/open answers 400 when the navigation fails even though the
window's tab already exists, and the link handler read every refusal as
"nothing opened" and sent the same address to the machine's browser.
The refusal now names the tab it left behind and the handler only falls
back when there is none.
2026-10-04 08:54:43 -07:00
yhh 3a3b474b10 fix(serve): list the folder a first turn is held in
A pane adopted at launch is not a folder anyone picked, so nothing listed it
once a later launch opened another one. Its conversations stayed on disk with
no sidebar row to open them from. The folder is now listed behind the existing
ones when the pane's first turn mints its session path.
2026-10-04 08:48:09 -07:00
YHH 8fb66a86da Merge pull request #11976 from esengine/fix/queue-withdraw-races
fix(studio): queue withdraw races (double click, stale pull, late receipt)
2026-10-04 08:47:03 -07:00
YHH 2766f23beb Merge pull request #11979 from esengine/fix/state-root-walk-vanishing-dir
test(config): the state-root guard skips directories removed mid-walk
2026-10-04 08:42:19 -07:00
yhh 58a94b30d6 fix(studio): keep taking back a queued line from erring, ghosting or flickering back
A queued line taken back could fail with a spurious error on a repeated click,
leave a pending transcript row when its receipt landed after the withdrawal,
and reappear in the panel when an older queue pull answered last.
2026-10-04 08:36:56 -07:00
esengine 7db5d0af31 fix(compaction): release a transient summary failure once the input has grown
A truncated, timed-out or failed summary request blocked automatic
compaction for the whole generation, so context kept growing past the
fold trigger with no further attempt. Block release now reads the typed
failure code: transient request failures are released after the input
grows by one retry step; every other class stays blocked.
2026-10-04 08:32:39 -07:00
YHH 779fe8a04d Merge pull request #11972 from esengine/fix/compaction-summary-effort
fix(compaction): summary request asks for the lowest approved reasoning depth
2026-10-04 08:31:42 -07:00
yhh 25eff5352d test(config): the state-root guard skips directories removed mid-walk 2026-10-04 08:31:16 -07:00
YHH 0a96b4bf8e Merge pull request #11975 from esengine/fix/compaction-notice-codes
fix(compaction): typed codes for /compact notices and aborted cards, localised by the frontends
2026-10-04 08:26:55 -07:00
esengine c24586d34d fix(compaction): summary request asks for the lowest approved reasoning depth
The digest request inherited the session's reasoning effort, so reasoning
tokens spent the same 16384-token cap the digest had to fit in and a
finish_reason=length dropped the whole summary. It now carries
EffortOverride=low; adapters apply it only when the endpoint's own effort
vocabulary admits it, so endpoints without a low tier keep their configured
depth.

Refs #11970
2026-10-04 08:21:01 -07:00
esengine 3c96792e13 fix(compaction): host notices and aborted cards carry a typed code the frontends word
/compact notices were raw English sentences built from err.Error(), and an
aborted card with no code read "compaction done / folded nothing". Add
compacted/compact_declined/compact_failed notice codes (detail = the
compaction code), give cancellation, unclassified failures and a busy
session their own codes, and word them in the desktop and TUI catalogues.

Refs #11970
2026-10-04 08:16:44 -07:00
esengine 294d179cb5 fix(tui): send an unknown slash command to the model with a visible notice
The terminal UI asked the kernel to refuse any slash line the registry does
not answer, so a typed path or half-remembered command dead-ended with
"unknown command". 1.x sends it as an ordinary message and says so (#5756).

The TUI client no longer sets refuseUnknownSlash; the controller's existing
default path runs the line through the normal turn pipeline. That notice is
now localised (en/zh/zh-TW) and carries the unknown_command code.

A built-in named first is that built-in whatever follows it, as in 1.x: the
kernel matches /context, /new and /clear by first word, and the TUI does the
same for /help, /clear, /setup, /version, /mouse, /resume and its display
commands, so none of them is sent as prose when given arguments.

Refs #11493, #5756
2026-10-04 08:08:01 -07:00
YHH adde7dd6a7 Merge pull request #11928 from esengine/feat/first-run-key-gate
feat(tui): a keyless first run says why and refuses the turn locally
2026-10-04 07:55:35 -07:00
esengine 610e28f6d7 feat(tui): a keyless first run says why and refuses the turn locally
With no API key the connection panel opened silently and a typed message went
to the provider, which answered 401 with a pointer to a shell command. The
panel now names the model that lacks a key, and /submit refuses a turn for it
with provider.key_missing before anything is sent. The TUI returns the line to
the composer and points at /setup.
2026-10-04 07:45:09 -07:00
YHH 27d6e6f2ad Merge pull request #11967 from esengine/fix/usage-test-no-time-local
test(serve): pass the usage window its zone instead of writing time.Local
2026-10-04 07:43:59 -07:00
esengine 7f453a62f3 docs(migrating): match the verified 2.x CLI behaviour for 1.x users
Correct the YOLO shortcut, the --inline form and the unknown slash command row, and list the permission, slash command and compaction differences.
2026-10-04 07:42:42 -07:00
esengine 5fcf468fb0 test(serve): pass the usage window its zone instead of writing time.Local
The usage range test assigned the process-global time.Local while goroutines from earlier tests still read it, so the race detector failed the package. usageWindow now delegates to usageWindowIn, which takes the zone trailing windows count days in; the test passes a fixed zone and never touches the global.
2026-10-04 07:33:03 -07:00
YHH abd863407f Merge pull request #11964 from esengine/fix/backup-malformed
fix(backup): accept the private credential slot a provider key lives in
2026-10-04 07:10:43 -07:00
esengine 2d43186219 fix(backup): accept the private credential slot a provider key lives in
A provider whose natural key slot is taken gets REASONIX_CONNECTION_<id>_KEY. Collect wrote that provider and its key, but restore validation denied every REASONIX_ name, so the backup sealed fine and then failed to open as malformed.
2026-10-04 06:59:49 -07:00
YHH d30d9cec70 Merge pull request #11960 from esengine/fix/11955-11956
fix(studio): feedback opens with no pane, visible refusals, backup button explains itself
2026-10-04 06:59:33 -07:00
esengine 542a6076a3 fix(studio): feedback opens with no pane, refusals are visible, backup button says why it is disabled
Settings and feedback are answered by a pane's kernel. With no pane open, feedback silently did nothing and the settings refusal never cleared. Opening either now starts a session in the current folder, a refusal is shown for both, and it is withdrawn once a folder or pane exists. The cloud-backup button now states what it is waiting for.
2026-10-04 06:50:58 -07:00
YHH 692aecd6b7 Merge pull request #11343 from par73e/fix/studio-acp-mcp-health-9904
fix(studio): report per-server MCP health over ACP
2026-10-04 06:49:46 -07:00
par73e c5b3dbbfe0 fix(studio): complete MCP health review fixes
Preserve ACP frame-order assertions while skipping only MCP status updates,
and use t.Context in the serve regression test.

Report servers requiring launch approval as pending rather than failed in
controller, ACP and /mcp snapshots. Keep pending rows disabled without a retry
action while retaining their recorded transport and sanitized error.

Document connecting and awaiting-approval notifications, style the pending
indicator in both themes, and derive frontend state coverage from Go constants.
Add controller, endpoint, ACP and row-rendering regression coverage.
2026-10-04 21:15:01 +08:00
par73e d550f754fc fix(studio): address review on per-server MCP health
- Map the new `pending` state in the desktop panel with an i18n label. A
  repository-declared server nobody has answered for is off, but not because
  the user switched it off, and the row rendered the raw state string.
- Announce what actually happened. RecordFailure and
  RecordLaunchApprovalRequired now share one store path and name their own
  state, so a project MCP awaiting authorization stops reporting a failed
  connection.
- Correct the MCPSurfaceReady contract: it fires on every change to what /mcp
  answers, not once per server. Audited every consumer before changing it —
  the ACP sink republishes a full snapshot, the serve broadcaster numbers the
  frame and the SPA refetches /mcp, the headless stream passes it through — so
  repeats are harmless and the documentation was the stale part.
- Keep the spawn claim beside the rest of the deferred-spawn bookkeeping
  instead of a file of its own.
- Add the /mcp endpoint regression test the refactor was missing (live
  transport and source, recorded status code, in-flight spawn, cached
  description and tools), and a panel test tying every state label to the
  English catalogue.
2026-10-04 21:06:00 +08:00
par73e b3a5541069 fix(studio): expose per-server MCP health over ACP 2026-10-04 21:05:59 +08:00
YHH 63195e95a5 Merge pull request #11935 from esengine/feat/tui-mcp-panel
feat(tui): /mcp opens a panel to enable, reconnect and inspect MCP servers
2026-10-04 06:02:53 -07:00
esengine 878fc8a53d feat(tui): /mcp opens a panel to enable, reconnect and inspect MCP servers 2026-10-04 05:51:34 -07:00
YHH f7d6d25930 Merge pull request #11929 from esengine/feat/tui-model-provider-pickers
feat(tui): /model and /provider open a searchable picker and switch the session
2026-10-04 05:50:26 -07:00
YHH 0594ee9e0c Merge pull request #11923 from esengine/fix/credential-roundtrip-11901
fix(config): a stored API key reads back byte-for-byte
2026-10-04 05:42:25 -07:00
esengine 290e787c47 feat(tui): /model and /provider open a searchable picker and switch the session 2026-10-04 05:38:56 -07:00
YHH dbb7b8498f Merge pull request #11950 from esengine/feat/settings-display-currency
feat(settings): choose the cost display currency (Auto / CNY / USD)
2026-10-04 05:32:04 -07:00
esengine 4e7067a17b fix(config): a stored API key reads back byte-for-byte (#11901)
SetCredential and SetCredentialIfRevision built a KEY=value line and parsed it
with the dotenv reader, so ${...} was expanded and wrapping quotes stripped
before the key was ever written. The writer also left a value containing $
bare, which the reader then expanded. Store the assignment directly and pick
the first encoding the reader returns unchanged; a value no encoding can hold
(for example a trailing backslash next to a quote or $) is refused before
anything is written, so one entry can never make the whole file unreadable.
2026-10-04 05:31:41 -07:00
YHH ac16c15950 Merge pull request #11934 from esengine/feat/tui-skills-panel
feat(tui): /skills opens a panel to search, filter by source and toggle skills
2026-10-04 05:29:20 -07:00
esengine c8f57aeb69 feat(tui): /skills opens a panel to search, filter by source and toggle skills 2026-10-04 05:16:09 -07:00
YHH 3134b0f18c Merge pull request #11953 from esengine/fix/response-language-11951
fix(control): constrain the visible answer language per turn in auto mode
2026-10-04 05:14:40 -07:00
YHH 6ab2051411 Merge pull request #11949 from esengine/dependabot/github_actions/actions-dfc3086b10
build(deps): bump pnpm/action-setup from 6.0.9 to 6.1.0 in the actions group
2026-10-04 05:12:21 -07:00
yhh 6a9a821998 feat(settings): choose the cost display currency (Auto / CNY / USD)
Adds GET/POST /display-currency over the controller's new
DisplayCurrencySettings/SaveDisplayCurrency (shared with /currency), a
Settings > Usage control, and makes the quote sink follow the
display_currency notice so rounds after a change are priced in it.
2026-10-04 05:08:29 -07:00
YHH 3083ef303d Merge pull request #11945 from esengine/dependabot/go_modules/go-284eff489b
build(deps): bump the go group with 2 updates
2026-10-04 05:07:02 -07:00
esengine 5bba3bf0b4 fix(control): constrain the visible answer language per turn in auto mode 2026-10-04 05:04:16 -07:00
YHH 35da5f70be Merge pull request #11948 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-b63839cc01
build(deps): bump the npm group in /desktop/frontend-next with 4 updates
2026-10-04 05:04:15 -07:00
YHH 27f78af346 Merge pull request #11944 from esengine/feat/render-chart-tool
feat(tools): add the render_chart built-in, hidden from the provider schema
2026-10-04 05:03:04 -07:00
YHH ae6f621bb8 Merge pull request #11946 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-c15dc57556
build(deps-dev): bump electron from 44.4.5 to 44.5.1 in /desktop/electron in the npm group
2026-10-04 05:01:22 -07:00
YHH 28dd27d033 Merge pull request #11927 from esengine/fix/import-1x-session-layouts
fix(migration): import 1.x sessions from project, v4 and desktop v5 stores
2026-10-04 05:00:36 -07:00
dependabot[bot] 6ee1bbd3aa build(deps): bump pnpm/action-setup
Bumps the actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.9 to 6.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.9...ea17c68df8912ef543352723c149a84f56e3d413)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-04 11:55:05 +00:00
dependabot[bot] c8152a8f7e build(deps): bump the npm group in /desktop/frontend-next with 4 updates
Bumps the npm group in /desktop/frontend-next with 4 updates: [@lezer/highlight](https://github.com/lezer-parser/highlight), [katex](https://github.com/KaTeX/KaTeX), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@lezer/highlight` from 1.2.4 to 1.2.5
- [Changelog](https://github.com/lezer-parser/highlight/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lezer-parser/highlight/commits)

Updates `katex` from 0.18.9 to 0.18.10
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.18.9...v0.18.10)

Updates `vite` from 8.3.0 to 8.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.2/packages/vite)

Updates `vitest` from 5.0.1 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest)

---
updated-dependencies:
- dependency-name: "@lezer/highlight"
  dependency-version: 1.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: katex
  dependency-version: 0.18.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vite
  dependency-version: 8.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 5.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-04 11:54:18 +00:00
dependabot[bot] 0487b131a0 build(deps-dev): bump electron in /desktop/electron in the npm group
Bumps the npm group in /desktop/electron with 1 update: [electron](https://github.com/electron/electron).


Updates `electron` from 44.4.5 to 44.5.1
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.4.5...v44.5.1)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-04 11:53:26 +00:00
dependabot[bot] 87d1ebd1d4 build(deps): bump the go group with 2 updates
Bumps the go group with 2 updates: [github.com/klauspost/compress](https://github.com/klauspost/compress) and [modernc.org/sqlite](https://gitlab.com/cznic/sqlite).


Updates `github.com/klauspost/compress` from 1.20.0 to 1.20.1
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.20.0...v1.20.1)

Updates `modernc.org/sqlite` from 1.59.0 to 1.60.1
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.59.0...v1.60.1)

---
updated-dependencies:
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.20.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: modernc.org/sqlite
  dependency-version: 1.60.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-04 11:53:05 +00:00
yhh 5b4b4ccb97 fix(migration): import 1.x sessions from project, v4 and desktop v5 stores
Picking a 1.x data folder only looked at <root>/sessions, so per-project
sessions, sessions-v4 and desktop-sessions-v5/by-id imported nothing and the
UI reported no old sessions.
2026-10-04 04:49:41 -07:00
YHH ab8835393f Merge pull request #11932 from esengine/fix/cask-waits-for-cli-release
ci(release): write the Homebrew cask only after the CLI release exists
2026-10-04 04:49:29 -07:00
yhh dc809e1150 feat(tools): add the render_chart built-in, hidden from the provider schema
render_chart validates a chartspec v1 spec with chartspec.Parse and answers
with a chart_id and the deterministic text summary, never the rows. It is a
registered built-in left out of the provider allow list, so it is found and
called through use_capability and the cache-stable prefix is unchanged. The
spec persists in the call arguments the session already stores (#11564).
2026-10-04 04:46:12 -07:00
YHH 2bfe43a62d Merge pull request #11926 from esengine/cli/rename-by-index
fix(tui): /rename <n> <title> names the nth saved session and the picker shows the name
2026-10-04 04:41:55 -07:00
YHH 81ce4da64b Merge pull request #11919 from esengine/feat/chartspec-v1
feat(chartspec): add the closed v1 chart spec, validator and text summary
2026-10-04 04:38:49 -07:00
YHH 6a335ae9d7 Merge pull request #11939 from esengine/fix/editconn-save-failed-wording
fix(settings): distinguish failed save from saved-but-not-applied in provider edit
2026-10-04 04:33:07 -07:00
esengine f89c934e47 ci(release): the Homebrew cask is written only after the CLI release it points at exists, and never moves backwards
cli-channels and cli-pointer both waited only for cli-tag, so the cask could be
pushed while the v<version> release holding its archives was still being
created, and stayed pointing at nothing if cli-pointer then failed. The cask
step also overwrote the tap whatever its version, so a workflow_dispatch of an
older tag moved Homebrew backwards; it now skips a tap that is ahead and fails
closed when the tap's version cannot be read.
2026-10-04 04:32:09 -07:00
YHH bfe2ca68b8 Merge pull request #11931 from esengine/fix/cli-archive-build-identity
ci(release): CLI archives carry the same build identity as the npm packages
2026-10-04 04:31:21 -07:00
YHH c3837f5a93 Merge pull request #11937 from esengine/feat/slash-remote-preset-pasteimage
feat(control): answer /remote, /preset (/work-mode, /profile) and /paste-image
2026-10-04 04:28:17 -07:00
yhh 2abe2784e3 feat(chartspec): add the closed v1 chart spec, its validator and text summary
Leaf package internal/contract/chartspec: spec types for bar/line/pie, a
strict Parse (size cap first, unknown fields refused, scalar cells only,
unsafe integers refused), typed refusals with dotted codes and caps, and a
deterministic Summary that quotes cell data. No consumer yet (#11564).
2026-10-04 04:28:10 -07:00
YHH 0d7fd85452 Merge pull request #11920 from esengine/fix/run-untrusted-folder-exit-code
fix(run): exit 4 when an untrusted folder refused the work, and 1.x migration notes
2026-10-04 04:20:25 -07:00
YHH ac31510363 Merge pull request #11933 from esengine/fix/tui-finished-todos-stay
fix(tui): a finished to-do list stays docked until the next turn starts
2026-10-04 04:14:00 -07:00
YHH 753996a897 Merge pull request #11936 from esengine/feat/tui-rate-band
feat(tui): the footer and turn receipt name the peak or off-peak rate a spend was billed at
2026-10-04 04:13:55 -07:00
yhh 2f45ce03f3 fix(settings): say a refused provider save failed, and a saved-but-unapplied one is saved
EditConn titled every outcome "not saved" at warn level, so a write that
landed but was not applied to the live session read as a failure, and a real
failure read as a soft notice. Split by outcome: error alert for a refused
save, status note for saved-not-applied, own title for a catalog refresh.
2026-10-04 04:08:35 -07:00
esengine 4e4004f0f4 fix(tui): /rename <n> <title> names the nth saved session and the picker shows the name 2026-10-04 04:05:59 -07:00
YHH 939ff9f6f7 Merge pull request #11930 from esengine/fix/tui-blank-thinking-marker
fix(tui): a thinking block with no text draws no marker
2026-10-04 04:04:50 -07:00
YHH feb4a51051 Merge pull request #11925 from esengine/fix/windows-icon-fill
fix(desktop): fill the Windows icon tile so the Start menu icon matches its neighbours
2026-10-04 03:46:57 -07:00
esengine 69464792d2 feat(control): answer /remote, /preset (/work-mode, /profile) and /paste-image instead of 'unknown command' 2026-10-04 03:46:00 -07:00
esengine 75c640b9d2 ci(release): CLI archives carry the commit, build time and docs identity npm builds already carry
The archives that reasonix upgrade, the Homebrew cask and the GitHub release
install were linked with main.version only, so they reported no git_commit or
build_time_utc and the embedded product docs identified themselves as dev. The
npm packages of the same release were linked with all of them.
2026-10-04 03:43:25 -07:00
esengine a970662e35 feat(tui): the footer and turn receipt name the peak or off-peak rate a spend was billed at
CostQuote carries a rate band for a rate confirmed as the vendor's own
schedule, aggregates fold it to peak, off-peak or mixed (none when any call
has no schedule), and the TUI prints it beside the cost as 1.x does.
2026-10-04 03:39:23 -07:00
YHH 19c86553ae Merge pull request #11924 from esengine/cli/status-details
fix(tui): /status lists the work tree, the effort and the model ref as 1.x does
2026-10-04 03:36:09 -07:00
esengine 2719b42f8b fix(run): exit 4 when an untrusted folder refused the work, and write the 1.x migration notes
A headless run in a folder with no trust decision refused every edit and
command and exited 0, so scripts that relied on 1.x defaults looked
successful. It now exits 4 (3 under --fail-on-unverified); the gate and
what it allows are unchanged. docs/MIGRATING.md gains the 1.x differences:
trust prompt, untrusted-folder runs, compact-ratio range, env vars.

Refs #11493
2026-10-04 03:35:56 -07:00
esengine a15b269c7f fix(tui): a finished to-do list stays docked until the next turn starts
The panel dropped a list as soon as every item was completed, so the final
N/N never showed. It now stays through the ask panel and the idle prompt and
goes when the next turn begins; a rebuilt screen (resume, rewind) still does
not draw a list that finished before it was opened.
2026-10-04 03:35:51 -07:00
esengine 597153d377 fix(tui): a thinking block with no text draws no marker
Reasoning that is only whitespace has nothing to fold, so the transcript no
longer prints a collapsed "thought for 0s (Ctrl+O)" line for it.
2026-10-04 03:32:15 -07:00
yhh ebe758959e fix(desktop): fill the Windows icon tile so the Start menu icon matches its neighbours 2026-10-04 03:27:32 -07:00
esengine ae22f4b171 fix(tui): /status lists the work tree, the effort and the model ref as 1.x does 2026-10-04 03:25:25 -07:00
YHH 0b638eb96e Merge pull request #11534 from esengine/feat/commit-message-helper
feat(studio): draft a commit message from the staged diff, commit locally on confirm
2026-10-04 01:56:13 -07:00
esengine 7c2a81eb24 feat(studio): draft a commit message from the staged diff and commit locally on confirm
Adds a typed Commits port on the controller: ProposeCommit reads the staged
index through gitcmd, frozen as a tree object, and asks the session's model
for a conventional-commit message in a request of its own (prefix untouched,
billed as commit-message). CommitStaged writes the reviewed tree with
commit-tree and moves HEAD only if it has not moved, so an index that changes
after confirmation cannot enter the commit. Files named as secret holders or
whose added lines carry a private key are withheld from the model and need an
acknowledgement. Hooks and signing never run. Nothing pushes.

Refs #11518
2026-10-04 01:45:20 -07:00
YHH b3d091139f Merge pull request #11342 from par73e/fix/studio-glm-effort-9642
fix(studio): expose GLM reasoning effort levels
2026-10-04 01:38:46 -07:00
par73e 6913fc9765 fix(studio): address review on GLM reasoning effort
- Add the pre-5.3 GLM catalogs to shippedPresets so installs holding the
  old list are moved forward, plus upgrade tests (old shape upgrades, a
  curated catalog stays untouched).
- Declare the per-model ladder once in provider.ZhipuEffortContract and
  read it from the /effort menu, the normalizer and the request builder,
  instead of repeating the model lists across three switches.
- Cite Zhipu's Core Parameters reference for the documented levels
  (5.2's none/minimal/low/medium/xhigh folds, 5.3's forced thinking).
- Add a boot.Build effect test at the provider-request boundary covering
  the stored enabled/disabled -> EffortDisplay -> request path.
- Surface the disabled->low landing for GLM-5.3 in the /effort menu and
  document the 1M context window the added models reuse.
2026-10-04 16:28:33 +08:00
YHH 18ed0649c9 Merge pull request #11914 from esengine/fix/sidebar-open-version-twice
fix(studio): list a version a pane has open once, not under its parent and as its own row
2026-10-04 01:15:13 -07:00
YHH 90afa32c27 Merge pull request #11913 from esengine/fix/gitcmd-codeql-cut
fix(gitcmd): make the option screen a CodeQL barrier at the exec exit
2026-10-04 00:59:24 -07:00
esengine 7363b44c25 fix(studio): list a version a pane has open once, not under its parent and as its own row 2026-10-04 00:58:28 -07:00
esengine f74ae6d352 fix(gitcmd): classify every argument by the option test before it reaches the exec
CodeQL's go/command-injection models regexp matches as barriers only when the
match is the branch condition dominating each use. screen() folded the match
into an || condition and the -C directory skipped it, so alert 1014 kept its
flows through screen() and Args.
2026-10-04 00:49:46 -07:00
YHH c5b43c410c Merge pull request #11665 from Harbor404/fix/studio-11615-usage-ranges
feat(usage): add calendar ranges and precise daily details
2026-10-04 00:26:39 -07:00
Harbor404 080a8d7ac1 fix(usage): clip long rows without hiding totals 2026-10-04 15:11:03 +08:00
Harbor404 86ba9227ee fix(usage): preserve structured range validation
- keep typed field details for days and explicit ranges
- reject from/to combined with days
- treat explicit dates as zone-less calendar days
- relocate and extend usage range tests
2026-10-04 14:10:41 +08:00
YHH 9b1ec952e1 Merge pull request #11909 from esengine/fix/11905-probe-resolved-base
fix(providers): save the base URL the model-list probe answered at
2026-10-03 22:38:14 -07:00
YHH c28884609c Merge pull request #11911 from esengine/fix/rebuild-busy-saved-identity
fix(serve): settings saved while the runtime is busy no longer read as a failed rebuild
2026-10-03 22:33:29 -07:00
YHH 5f7827c9b2 Merge pull request #11906 from esengine/fix/gitcmd-arg-injection
fix(gitcmd): refuse program-running git options at the exec exit
2026-10-03 22:32:26 -07:00
YHH 711966ef31 Merge pull request #11908 from esengine/fix/no-reload-shortcut
fix(studio): leave F5 and Ctrl/Cmd+R to the page; app-window reload moves to Ctrl/Cmd+Shift+R
2026-10-03 22:26:32 -07:00
esengine fb297c3483 fix(serve): a settings save that meets a busy runtime says it was saved, by code
rebuildFailed collapsed the busy refusal into runtime.rebuild_failed with
the English sentence as detail, so the compaction, shell, sandbox,
permission, browser and MCP-load saves told a zh reader the write had
failed. It now answers runtime.saved_while_running, which the UI renders.
2026-10-03 22:16:18 -07:00
esengine 99ee5cdfe0 fix(gitcmd): refuse program-running git options at the exec exit (CodeQL go/command-injection) 2026-10-03 22:13:54 -07:00
esengine fc2f4b495c fix(providers): save the base URL the model-list probe actually answered at
A bare https://host passed the add-provider probe because {base}/v1/models
answered, while chat posts to the saved base + /chat/completions and failed.
The probe now reports the base that was proven (typed address completed with
/v1 only when the listing answered there alone, openai-style kinds only), and
add, onboarding and edit-refresh adopt it into the address field before save.
2026-10-03 22:13:18 -07:00
esengine 1a38708fda fix(studio): leave F5 and Ctrl/Cmd+R to the page; reload the app window on Ctrl/Cmd+Shift+R
F5 and Ctrl/Cmd+R reloaded the whole Studio UI from any focus, so a page
or browser-pane user pressing them lost the window state. The app window
now reloads only on Ctrl/Cmd+Shift+R (white-screen recovery, crash reload
unchanged); the browser pane reloads its own page on F5 and Ctrl/Cmd+R and
ignores the cache on the shifted chord.
2026-10-03 22:10:30 -07:00
Harbor404 6a508efac6 test(usage): isolate boot effect provider registration 2026-10-04 13:08:32 +08:00
YHH 11b0513747 Merge pull request #11903 from esengine/fix/feedback-keep-shots
fix(studio): keep feedback screenshots in the held draft
2026-10-03 22:03:25 -07:00
esengine 47d219e966 fix(studio): keep feedback screenshots in the held draft 2026-10-03 21:54:57 -07:00
YHH 72d4e9f28e Merge pull request #11711 from KHG420/codex/studio-export-json-encodings
fix(studio): export plugins using supported JSON encodings
2026-10-03 21:26:03 -07:00
Harbor404 46783d365f fix(usage): declare calendar range actions 2026-10-04 12:05:57 +08:00
YHH d486cfe71f Merge pull request #11808 from KHG420/codex/studio-remote-manifest-size
fix(install): require complete bounded remote manifests
2026-10-03 21:02:13 -07:00
xiexian 0f8dfa34f9 fix(studio): export plugins using supported JSON encodings 2026-10-04 11:58:15 +08:00
xiexian dabc3481d2 fix(install): require complete bounded remote manifests 2026-10-04 11:51:27 +08:00
YHH 0195f8241a Merge pull request #11897 from esengine/fix/model-switch-not-default
fix(serve): a session's model switch no longer rewrites default_model
2026-10-03 20:51:23 -07:00
esengine bc13eddb53 fix(serve): a session's model switch no longer rewrites default_model
POST /model persisted every switch as the user's default model, so changing
the model inside one conversation silently changed what every new session and
the next launch started on. The typed /model command, the terminal UI and ACP
never did this; only the HTTP picker path did.

The request now carries an explicit `default` flag. The Settings default-model
row and the first-run model choice send it; the composer picker does not, so
it changes only that session. A pane resolving through the hub's resolver
still never writes this machine's config.
2026-10-03 20:40:02 -07:00
YHH d98832f695 Merge pull request #11816 from esengine/feat/fix-11785
fix(mcp): redact endpoint credentials fail-closed in every display, preview and export surface
2026-10-03 19:32:40 -07:00
Harbor404 310ffda281 fix(usage): align source bars with chart tokens 2026-10-04 10:30:45 +08:00
YHH d7744a1616 Merge pull request #11895 from esengine/fix/powershell-parser-cold-start
fix(bash): give the PowerShell delete parser a cold-start budget
2026-10-03 19:22:45 -07:00
esengine 0da518ce84 fix(mcp): redact endpoint credentials fail-closed in every display, preview and export surface
One structural rule (secrets.RedactEndpoint / CredentialKey / RedactArgs)
replaces mcpsetup.RedactURL's query-only masking. Userinfo and fragments are
dropped or masked, query and fragment keys are decoded before classification,
unparseable endpoints render as <redacted>, and transport diagnostics keep
error identity while omitting peer-supplied prose.

Operational values (install, approval fingerprints, persistence) are untouched;
preview and plan responses add separate display fields that the desktop mirror
and confirmation UI render.

Rebased onto the launcher-aware NameFromArgv: names derive from the original
argv but never from a value the display projection masked, so build-flag values
such as `go run -exec` still resolve. Install classification reads the typed
HTTP status instead of sniffing the server name out of the message.

Fixes #11785
2026-10-03 19:19:58 -07:00
esengine 358fb4cff2 fix(bash): give the PowerShell delete parser a cold-start budget separate from per-call latency 2026-10-03 19:07:06 -07:00
YHH 7aeae1990d Merge pull request #11824 from esengine/feat/fix-images-fixture
test(boot): fix the shell used by the restart image effect test
2026-10-03 18:52:49 -07:00
YHH b2db65b417 Merge pull request #11831 from esengine/feat/fix-queue-withdraw
fix(studio): clear the composer on submit and restore a withdrawn queued message exactly once
2026-10-03 18:51:52 -07:00
YHH 43372e6ce7 Merge pull request #11893 from esengine/fix/focus-test-flaky
test(studio): wait for the plugin recovery focus move
2026-10-03 18:04:28 -07:00
esengine b650e362df test(studio): wait for the plugin recovery focus move instead of asserting it immediately
The primary button is focused from a passive effect that runs after the
element is committed, so asserting document.activeElement right after a
findBy* resolves races that effect. Use waitFor, as settings-layers does.
2026-10-03 17:37:55 -07:00
YHH 5bfdfb0bd6 Merge pull request #11891 from esengine/fix/release-credit-owner
fix(release): never credit the repository owner in release notes
2026-10-03 17:21:59 -07:00
YHH 3c060de3e2 Merge pull request #11890 from esengine/docs/studio-2.27.0-notes
docs(release): prepare Studio 2.27.0 notes
2026-10-03 17:14:40 -07:00
esengine de789afafb fix(release): never credit the repository owner in release notes 2026-10-03 17:12:08 -07:00
esengine c83f5931fc docs(release): prepare Studio 2.27.0 notes 2026-10-03 17:07:07 -07:00
YHH 028179b37f Merge pull request #11889 from esengine/perf/boot-test-close-catalogs-before-tempdir
test(boot): close process-global catalogs before the temp dir is removed
2026-10-03 16:31:54 -07:00
esengine 880dcc5b64 test(boot): close process-global catalogs before the temp dir is removed
testenv.TempDir retries RemoveAll for two seconds when a file stays held.
On Windows the usage and history SQLite catalogs, which the shared helpers
left open, held a file under nearly every test's directory, so each of those
tests paid the full retry budget at teardown. Closing the catalogs from the
helper's own cleanup, which runs before the removal, makes it converge on
the first attempt.
2026-10-03 16:22:09 -07:00
YHH 8576a0b0ff Merge pull request #11740 from KHG420/codex/studio-unicode-skill-inventory
fix(studio): align package skill inventory with runtime names
2026-10-03 15:53:09 -07:00
YHH 37056a8dba Merge pull request #11865 from KHG420/codex/studio-go-mcp-names
fix(studio): unify MCP launcher name inference
2026-10-03 15:52:29 -07:00
xiexian a078f8e30f fix(studio): unify MCP launcher name inference 2026-10-04 06:38:46 +08:00
YHH 875dd8e67a Merge pull request #11569 from KHG420/codex/studio-compatible-command-example
docs(ecosystem): add compatible command example and host proof
2026-10-03 15:19:23 -07:00
YHH 558b9cbbb2 Merge pull request #11814 from KHG420/codex/studio-theme-import-ignored-files
fix(studio): reject mixed theme archive selections
2026-10-03 15:18:08 -07:00
YHH 461e03770f Merge pull request #11811 from KHG420/codex/studio-theme-asset-freshness
fix(studio): revalidate theme assets with content ETags
2026-10-03 15:16:35 -07:00
YHH dbc79359c0 Merge pull request #11787 from KHG420/codex/studio-mcp-endpoint-transport-path
fix(studio): infer MCP transport from endpoint path segments
2026-10-03 15:14:38 -07:00
YHH 5dc827a1c0 Merge pull request #11821 from KHG420/codex/studio-publish-kind-keys
fix(studio): share keyboard navigation for market radios
2026-10-03 15:06:26 -07:00
YHH 9de8fe7374 Merge pull request #11886 from esengine/perf/boot-scan-limit
test(boot): make the workspace scan limit injectable (refs #11885)
2026-10-03 14:02:07 -07:00
YHH 075db5672f Merge pull request #11888 from esengine/fix/shellrun-quiet-timeout-test
test(shellrun): stop the quiet-timeout test racing interpreter startup
2026-10-03 13:52:23 -07:00
YHH 3dd8fd4dfa Merge pull request #11887 from esengine/perf/bash-probe-memo
perf(sandbox): remember a bash.exe that already passed the launch probe (refs #11885)
2026-10-03 13:51:38 -07:00
esengine 4a82273735 perf(sandbox): do not relaunch bash -c true for a bash.exe already proven under the same size and mtime
A successful Windows bash probe is remembered per executable identity (path, size, mtime) for the life of the process; failures are never remembered. Every boot.Build used to spawn Git bash for it.
2026-10-03 13:45:23 -07:00
esengine 65f5a0c6f3 test(boot): make the workspace scan limit injectable so the over-limit case needs 65 files, not 50,001
agent.Options.WorkspaceScanLimit (zero keeps the 50,000 production bound) is threaded from boot.Options. The incomplete-prose-scan effect test now sets it to 64 and writes 65 files instead of 50,001.
2026-10-03 13:44:58 -07:00
esengine 7e469c1c59 test(shellrun): inject the child in the quiet-timeout test so interpreter startup cannot race the deadline
The deadline starts at launch, so with a real interpreter the test measured startup time. Under load Git bash needed more than 500 ms and the pre-deadline write never landed.
2026-10-03 13:39:36 -07:00
YHH e190049a70 Merge branch 'studio' into codex/studio-theme-import-ignored-files 2026-10-03 13:11:29 -07:00
YHH a3fd97ab74 Merge pull request #11810 from KHG420/codex/studio-theme-import-lock
fix(studio): serialize theme import feedback
2026-10-03 13:00:46 -07:00
xiexian b8e25a4bb4 fix(studio): reject mixed theme archive selections 2026-10-04 03:56:56 +08:00
xiexian 98e1ebe28f fix(studio): serialize theme import feedback 2026-10-04 03:45:20 +08:00
YHH 963fd1eec1 Merge branch 'studio' into codex/studio-unicode-skill-inventory 2026-10-03 12:30:55 -07:00
YHH 09ba9cddc5 Merge pull request #11882 from esengine/fix/flaky-shellrun-quiet-timeout
test(shellrun): give the quiet-timeout case a deadline that outlasts PowerShell startup
2026-10-03 12:30:23 -07:00
YHH 599ba7cb77 Merge pull request #11883 from esengine/fix/flaky-remember-permission-waits
test(boot): bound permission-rule contention by writer count and process readiness by testenv.Budget
2026-10-03 12:27:25 -07:00
YHH 4e41da5a81 Merge pull request #11881 from esengine/fix/flaky-control-wait-budget
test(control): wait for turn events within testenv.Budget instead of fixed 2-10s
2026-10-03 12:26:21 -07:00
esengine 8e87a1557b test(boot): bound permission-rule contention by writer count and process readiness by testenv.Budget
TestRememberPermissionRuleSerializesConcurrentWriters queued 32 writers on a
lock that waits 5s, so a slow Windows filesystem ran the last waiters out of
time (lock timeout at 5.08s). The cross-process variant had the same shape:
each helper waited a fixed 5s for the others to spawn. Eight writers still
interleave, and the readiness waits now read the shared hang budget.
2026-10-03 12:07:30 -07:00
esengine 39fddef450 test(shellrun): give the quiet-timeout case a deadline that outlasts PowerShell startup
The deadline is armed at launch, so a 500ms budget races interpreter startup:
on a loaded Windows runner PowerShell had not printed anything when it
fired, and the test reported the pre-deadline write as lost. PowerShell now
gets a 10s deadline named for what it has to outlast; POSIX shells keep 500ms.
2026-10-03 12:05:48 -07:00
esengine 8f61e66cf1 test(control): wait for turn events within testenv.Budget instead of fixed 2-10s
Hang-catching waits in the control tests were hard-coded to 2, 3, 5 or 10
seconds. A whole turn through the real agent loop can exceed that on a
loaded Windows runner, so TestTurnOutcomeClassifiesPerseverationPause failed
with 'timed out waiting for TurnDone' on a run that passed on rerun. They now
read testenv.Budget(t), which is the single named bound for a wait that only
exists to report a hang.
2026-10-03 12:05:09 -07:00
xiexian 00c97a6036 fix(studio): align package skill inventory with runtime names 2026-10-04 02:43:46 +08:00
YHH 94d1a293f5 Merge branch 'studio' into codex/studio-compatible-command-example 2026-10-03 11:22:13 -07:00
YHH c66775519f Merge branch 'studio' into codex/studio-theme-asset-freshness 2026-10-03 11:22:04 -07:00
xiexian 65c7316669 fix(studio): share market radio keyboard navigation 2026-10-04 01:10:46 +08:00
xiexian a4b5a9a485 docs(ecosystem): add compatible command example and host proof 2026-10-04 01:02:54 +08:00
YHH 8e5a124070 Merge branch 'studio' into fix/studio-11615-usage-ranges 2026-10-03 09:17:32 -07:00
Harbor404 6d4b5dcb6a fix(usage): label sources without display names 2026-10-04 00:03:42 +08:00
xiexian c0856e8231 fix(studio): revalidate theme assets with content ETags 2026-10-03 23:26:41 +08:00
xiexian 2e9045a300 fix(sdk): include externalized reads in the intercept budget 2026-10-03 18:20:33 +08:00
xiexian 8ecfa8c084 fix(studio): infer MCP transport from endpoint path segments 2026-10-03 18:13:11 +08:00
xiexian cf490285b0 fix(sdk): cancel provider contexts when streams finish 2026-10-03 18:02:43 +08:00
Will ff8e467102 test(e2ebench): prove every declared-solvable verification-stress task can pass
The reference-solution guard fired only for the train corpus. For every other corpus a missing solution/ was a silent skip, so a grader that can never pass stayed indistinguishable from a task with no solution - the case the rule above it exists to prevent. #11380 supplied the ten vs-pos solutions; this extends the guard to the whole verification-stress corpus and supplies the ten tasks it was still skipping.

The e2e suite keeps the skip, and the skip message now says why: its handwritten tasks do not commit a reference solution.

Verified: go test ./cmd/e2ebench -run TestCorpusGradersPassTheReferenceSolution is green across e2e, verification-stress and train. Removing any single solution/ fails the guard at corpus_test.go:254 (checked by hand for vs-neu-cfgkey), so the added solutions are what the guard grades.
2026-10-03 17:51:55 +08:00
xiexian 54c8e7ab68 feat(sdk): add installable offline provider example 2026-10-03 15:16:42 +08:00
esengine bce7224270 fix(studio): clear the composer on submit and restore a withdrawn queued message exactly once 2026-10-02 23:19:21 -07:00
par73e a0cc22bfc0 fix(studio): expose GLM 5.2 and 5.3 reasoning effort levels 2026-10-03 13:02:02 +08:00
esengine 3552dc1412 test(boot): fix the shell used by the restart image effect test 2026-10-02 22:00:06 -07:00
xiexian 5a10bb1d7f fix(studio): keep blocked market previews out of confirmation 2026-10-03 09:30:11 +08:00
Harbor404 63bbb1334d docs(ui): remove session info helper comment 2026-10-03 02:59:04 +08:00
Harbor404 d2d5571a00 chore(merge): sync with latest main 2026-10-03 02:58:12 +08:00
Harbor404 e730dafd27 fix(usage): keep range errors inline 2026-10-03 02:57:13 +08:00
Harbor404 e1ca40f84a chore(merge): sync with latest main 2026-10-03 02:52:21 +08:00
xiexian f8843f4b2c fix(cli): detect non-executable plugin runtimes in doctor 2026-10-03 02:15:08 +08:00
Harbor404 158d9e2b18 feat(studio): copy session handoff information 2026-10-02 13:31:01 +08:00
Harbor404 49dba2381a feat(usage): add calendar ranges and precise daily details 2026-10-02 13:22:33 +08:00
586 changed files with 25478 additions and 1953 deletions

No files matched your search

+6 -2
View File
@@ -62,7 +62,7 @@ jobs:
# both paths must trigger the sdk job.
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
@@ -80,7 +80,11 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- run: node --test scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
+22 -6
View File
@@ -757,14 +757,18 @@ jobs:
- name: Cross-compile and archive
env:
VERSION: ${{ needs.resolve.outputs.version }}
SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
mkdir -p dist
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
pkg=reasonix/internal/tools/productdocs
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
os="${target%/*}"; arch="${target#*/}"; ext=""
[ "$os" = windows ] && ext=".exe"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
-o "reasonix$ext" ./cmd/reasonix
if [ "$os" = windows ]; then
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
else
@@ -1211,13 +1215,13 @@ jobs:
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
# line owns them until this one takes the CLI over; the repository variable
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
# exists, because the cask points at its archives. A candidate goes to npm's
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
# candidate cask would become the default install.
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
# created the CLI release, because the cask points at its archives. A candidate
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
# channel, so a candidate cask would become the default install.
cli-channels:
name: publish the CLI to npm and Homebrew
needs: [resolve, publish, cli-gate, cli-tag]
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
# id-token lets npm publish --provenance attach a Sigstore attestation; the
@@ -1256,6 +1260,18 @@ jobs:
run: |
set -euo pipefail
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
case "$decision" in
write) ;;
skip)
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
exit 0
;;
*)
echo "::error::unexpected cask guard decision: '$decision'"
exit 1
;;
esac
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
cd tap
git add Casks/reasonix.rb
+14 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"fmt"
"hash/fnv"
"io"
mathrand "math/rand"
"strings"
)
@@ -90,15 +91,25 @@ func seededRand(parts ...string) *mathrand.Rand {
// liveRand is the generator a paid run uses: every run gets values that existed
// nowhere before it started.
func liveRand() *mathrand.Rand {
var seed [8]byte
if _, err := rand.Read(seed[:]); err != nil {
rng, err := liveRandFrom(rand.Reader)
if err != nil {
panic("contextbench: no entropy for a live run: " + err.Error())
}
return rng
}
// liveRandFrom seeds a generator from an entropy source, so what a run's values
// are unpredictable from is the source and nothing else.
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
var seed [8]byte
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
return nil, err
}
var n int64
for _, b := range seed {
n = n<<8 | int64(b)
}
return mathrand.New(mathrand.NewSource(n))
return mathrand.New(mathrand.NewSource(n)), nil
}
// instantiate substitutes {{name}} and refuses anything left over. The grammar
+41 -12
View File
@@ -1,7 +1,10 @@
package main
import (
"bytes"
"fmt"
"maps"
mathrand "math/rand"
"os/exec"
"slices"
"strings"
@@ -82,20 +85,46 @@ func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
}
}
// Two runs of the same task must not share an answer, or one leaked run leaks
// every future one.
func TestInstancesDifferBetweenRuns(t *testing.T) {
// A run's values are unpredictable from a previous run only if the live
// generator is seeded from entropy. Comparing answers across two instances
// cannot assert that: a task whose answer is a bounded number collides by
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
func TestLiveRandDrawsFromEntropy(t *testing.T) {
a, b := liveRand(), liveRand()
for range 4 {
if a.Int63() != b.Int63() {
return
}
}
t.Fatal("two live generators produced the same stream: they are seeded alike")
}
// The source alone decides an instance: the same entropy gives the same
// answers, different entropy gives different values.
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
same := func() *mathrand.Rand {
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
if err != nil {
t.Fatal(err)
}
return rng
}
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
if err != nil {
t.Fatal(err)
}
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
t.Fatal("an empty entropy source was accepted")
}
for _, task := range allTasks() {
a, err := instantiateTask(task, liveRand())
if err != nil {
t.Fatal(err)
a, _ := instantiateTask(task, same())
b, _ := instantiateTask(task, same())
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
t.Errorf("%s: the same entropy gave different instances", task.ID)
}
b, err := instantiateTask(task, liveRand())
if err != nil {
t.Fatal(err)
}
if slices.Equal(a.AnswerMarkers, b.AnswerMarkers) {
t.Errorf("%s produced the same answers twice: %v", task.ID, a.AnswerMarkers)
c, _ := instantiateTask(task, other)
if maps.Equal(a.Vars, c.Vars) {
t.Errorf("%s: different entropy gave the same values", task.ID)
}
}
}
@@ -0,0 +1,8 @@
def wire_fee(amount):
"""Return the wire fee for `amount`: the greater of 15.0 and 0.1% of it.
A zero or negative amount has no wire fee, so it returns 0.0.
"""
if amount <= 0:
return 0.0
return max(15.0, amount * 0.001)
@@ -0,0 +1,13 @@
def convert(amount, rate):
"""Convert `amount` at `rate` and return the product.
Both units belong to the caller. A negative amount converts unchanged.
"""
return amount * rate
def spread(bid, ask):
"""Return `ask - bid`, the spread between the two quoted prices.
A negative result means the bid is above the ask.
"""
return ask - bid
@@ -0,0 +1,4 @@
EARTH_RADIUS_KM = 6371.0
def dist(left, right):
return abs(left - right) * EARTH_RADIUS_KM
@@ -0,0 +1,4 @@
import geo
def near_by(point, other):
return geo.dist(point, other) < 10
@@ -0,0 +1,16 @@
import json
import sys
def upgrade(row):
parts = row["fullname"].rsplit(" ", 1)
out = {k: v for k, v in row.items() if k != "fullname"}
out["first"] = parts[0]
out["last"] = parts[1] if len(parts) > 1 else ""
return out
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
print(json.dumps(upgrade(json.loads(line))))
@@ -0,0 +1,7 @@
import logging
log = logging.getLogger("orders")
def place(order, request_id):
log.info("placing order %s", order["id"], extra={"request_id": request_id})
return {"ok": True}
@@ -0,0 +1,7 @@
import logging
log = logging.getLogger("shipping")
def dispatch(order, request_id):
log.info("dispatching %s", order["id"], extra={"request_id": request_id})
return {"ok": True}
@@ -0,0 +1,11 @@
import math
def p95(sorted_values):
if not sorted_values:
return 0
return sorted_values[math.ceil(len(sorted_values) * 0.95) - 1]
def rate(events, seconds):
if seconds <= 0:
return 0.0
return len(events) / seconds
@@ -0,0 +1,9 @@
import core
_cache = None
def list_records_cached():
global _cache
if _cache is None:
_cache = core.list_records()
return _cache
@@ -0,0 +1,5 @@
import cache
def main():
for k, v in sorted(cache.list_records_cached().items()):
print(f"{k}={v}")
@@ -0,0 +1,4 @@
DATA = {"a": 1, "b": 2}
def list_records():
return dict(DATA)
@@ -0,0 +1 @@
DEFAULTS = {"host": "localhost", "port": 8080}
@@ -0,0 +1,6 @@
import json
import defaults
def load(text):
data = json.loads(text) if text.strip() else {}
return dict(defaults.DEFAULTS, **data)
@@ -0,0 +1,4 @@
import json
def dump(cfg):
return json.dumps(cfg, sort_keys=True)
@@ -0,0 +1,2 @@
STATES = ("new", "paid", "delivered", "cancelled")
TERMINAL = ("delivered", "cancelled")
@@ -0,0 +1,11 @@
import states
LEGAL = {
"new": ("paid", "cancelled"),
"paid": ("delivered", "cancelled"),
"delivered": (),
"cancelled": (),
}
def can_move(a, b):
return b in LEGAL[a]
@@ -0,0 +1,5 @@
import schema, serializer
def display_name(line):
rec = serializer.load(line)
return rec["handle"] or rec["email"]
@@ -0,0 +1,4 @@
FIELDS = ("handle", "email")
def blank():
return {f: "" for f in FIELDS}
@@ -0,0 +1,4 @@
import timings
def is_slow(entry):
return entry["elapsed_s"] > timings.SLOW_SECONDS
@@ -0,0 +1,4 @@
import timings
def within_budget(entries):
return sum(e["elapsed_s"] for e in entries) <= timings.BUDGET_SECONDS
@@ -0,0 +1,5 @@
SLOW_SECONDS = 2.0
BUDGET_SECONDS = 60.0
def record(seconds):
return {"elapsed_s": seconds}
+3 -3
View File
@@ -250,10 +250,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Parallel()
work := stageSeed(t, task.dir)
if !stageSolved(t, task.dir, work) {
if dir == trainCorpusDir {
t.Fatal("no solution/: a generated task must prove its grader can pass")
if dir == trainCorpusDir || dir == verificationStressDir {
t.Fatal("no solution/: a corpus task must prove its grader can pass")
}
t.Skip("no solution/ to check this grader against")
t.Skip("no solution/: the e2e suite does not commit reference solutions")
}
if err := gradeSeed(t, work); err != nil {
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
@@ -86,6 +86,13 @@ func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
t.Fatalf("%s does not wait for cli-tag", name)
}
}
found := false
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
found = found || n.Value == "cli-pointer"
}
if !found {
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
}
}
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+1 -1
View File
@@ -19,7 +19,7 @@
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
"electron": "44.4.5",
"electron": "44.5.1",
"electron-builder": "^26.15.3",
"playwright": "1.63.0"
},
+7 -1
View File
@@ -13,6 +13,11 @@ const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
// Linux keep the full-canvas artwork the platform templates expect.
const WIN_VIEWBOX = 'viewBox="91 91 842 842"';
const WIN_GLYPH_SCALE = "scale(2.4)";
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
const ICNS_TYPES = [
["ic04", 16], ["ic05", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic10", 1024],
@@ -74,8 +79,9 @@ async function main() {
for (const [scale, suffix] of TRAY_SCALES) {
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
}
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
const icoPngs = [];
for (const size of ICO_SIZES) icoPngs.push([size, await at(size)]);
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
fs.writeFileSync(path.join(ASSETS, "icon.ico"), ico(icoPngs));
const icnsPngs = [];
for (const [type, size] of ICNS_TYPES) icnsPngs.push([type, await at(size)]);
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
electron:
specifier: 44.4.5
version: 44.4.5([email protected])
specifier: 44.5.1
version: 44.5.1([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@44.4.5:
resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
electron@44.5.1:
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@44.4.5([email protected]):
electron@44.5.1([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
+2
View File
@@ -1,6 +1,7 @@
"use strict";
const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
// The size a page lays out at while nobody is looking at it: the kernel drives
@@ -96,6 +97,7 @@ class BrowserViews {
if (allowed(to)) onPopup(to);
return { action: "deny" };
});
installPaneReload(contents);
contents.debugger.attach("1.3");
contents.debugger.on("message", (_event, method, params, sessionId) => {
if (sessionId) return;
+26 -10
View File
@@ -1,22 +1,38 @@
"use strict";
// No menu this shell installs carries a reload role, and a page gone blank,
// whether its tree unmounted or its renderer died, has no way back but
// quitting. The kernel owns every running turn and a loaded page finds it again.
// A page gone blank, whether its tree unmounted or its renderer died, has no
// way back but quitting. The kernel owns every running turn and a loaded page
// finds it again. F5 and Ctrl/Cmd+R belong to the page, so the recovery chord
// is the shifted one.
const CRASH_RELOADS = 3;
const CRASH_WINDOW_MS = 60_000;
function reloadAction(input, platform = process.platform) {
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt) return null;
const mod = platform === "darwin" ? input.meta && !input.control : input.control && !input.meta;
const latin = typeof input.key === "string" && /^[a-z]$/i.test(input.key);
const isR = latin ? input.key.toLowerCase() === "r" : input.code === "KeyR";
if (mod && isR) return input.shift ? "hard" : "reload";
if (!input.control && !input.meta && !input.shift && input.key === "F5") return "reload";
return null;
}
function installPaneReload(contents, platform = process.platform) {
contents.on("before-input-event", (event, input) => {
const action = reloadAction(input, platform);
if (!action) return;
event.preventDefault();
if (action === "hard") contents.reloadIgnoringCache();
else contents.reload();
});
}
// A dead renderer's input never reaches before-input-event, so the crash itself
// has to trigger the reload, or for a hidden window the next revive before it is
// shown. A page that dies on every load stops being reloaded.
function installReload(contents, window, { platform = process.platform, now = Date.now } = {}) {
const mac = platform === "darwin";
contents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt || input.shift) return;
const mod = mac ? input.meta && !input.control : input.control && !input.meta;
const bare = !input.control && !input.meta;
const chord = mod && (input.key === "r" || input.key === "R" || input.code === "KeyR");
if (!chord && !(bare && input.key === "F5")) return;
if (reloadAction(input, platform) !== "hard") return;
event.preventDefault();
contents.reload();
});
@@ -40,4 +56,4 @@ function installReload(contents, window, { platform = process.platform, now = Da
};
}
module.exports = { installReload, CRASH_RELOADS, CRASH_WINDOW_MS };
module.exports = { installReload, installPaneReload, reloadAction, CRASH_RELOADS, CRASH_WINDOW_MS };
+60 -23
View File
@@ -1018,30 +1018,67 @@ function reloadRig(platform = "darwin") {
return { state, send, gone, revive };
}
test("the reload key brings back a window whose page stopped drawing", () => {
const press = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
const cases = [
["darwin", { meta: true }, { control: true }],
["win32", { control: true }, { meta: true }],
["linux", { control: true }, { meta: true }],
];
for (const [platform, mod, other] of cases) {
const reloadPress = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
const reloadMods = [
["darwin", { meta: true }, { control: true }],
["win32", { control: true }, { meta: true }],
["linux", { control: true }, { meta: true }],
];
test("reloadAction reads the chord, not the layout", () => {
const { reloadAction } = require("../src/reload.js");
for (const [platform, mod, other] of reloadMods) {
const at = (input) => reloadAction(input, platform);
assert.equal(at(reloadPress(mod)), "reload");
assert.equal(at(reloadPress({ ...mod, key: "к" })), "reload");
assert.equal(at(reloadPress({ key: "F5", code: "F5" })), "reload");
assert.equal(at(reloadPress({ ...mod, key: "p", code: "KeyR" })), null, `${platform}: a Dvorak P reloaded`);
assert.equal(at(reloadPress({ ...mod, key: "r", code: "KeyP" })), "reload", `${platform}: a Dvorak R did not reload`);
assert.equal(at(reloadPress({ ...mod, shift: true })), "hard");
assert.equal(at(reloadPress({ ...mod, shift: true, key: "R" })), "hard");
for (const input of [
reloadPress(), reloadPress(other), reloadPress({ ...mod, alt: true }),
reloadPress({ ...mod, type: "keyUp" }), reloadPress({ ...mod, isAutoRepeat: true }),
reloadPress({ ...mod, key: "t", code: "KeyT" }), reloadPress({ key: "F5", code: "F5", control: true }),
reloadPress({ key: "F5", code: "F5", shift: true }), reloadPress({ shift: true }),
]) assert.equal(at(input), null, `${platform}: ${JSON.stringify(input)}`);
}
});
test("only the hard reload chord reloads the app window; F5 and Ctrl/Cmd+R reach the page", () => {
for (const [platform, mod] of reloadMods) {
const { state, send } = reloadRig(platform);
send(press(mod));
send(press({ ...mod, key: "R" }));
send(press({ ...mod, key: "к" }));
send(press({ key: "F5", code: "F5" }));
assert.equal(state.reloads, 4, `${platform}: the reload keys did not reload`);
send(press());
send(press(other));
send(press({ ...mod, shift: true }));
send(press({ ...mod, alt: true }));
send(press({ ...mod, type: "keyUp" }));
send(press({ ...mod, isAutoRepeat: true }));
send(press({ ...mod, key: "t", code: "KeyT" }));
send(press({ key: "F5", code: "F5", control: true }));
assert.equal(state.reloads, 4, `${platform}: something other than a fresh reload press reloaded`);
assert.equal(state.prevented, 4);
send(reloadPress(mod));
send(reloadPress({ key: "F5", code: "F5" }));
assert.equal(state.reloads, 0, `${platform}: a plain reload key reloaded the UI`);
assert.equal(state.prevented, 0, `${platform}: a plain reload key was swallowed`);
send(reloadPress({ ...mod, shift: true, key: "R" }));
assert.equal(state.reloads, 1, `${platform}: the recovery chord did not reload`);
assert.equal(state.prevented, 1);
}
});
test("a browser pane reloads on F5 and Ctrl/Cmd+R, ignoring cache on the shifted chord, and swallows nothing else", () => {
const { installPaneReload } = require("../src/reload.js");
for (const [platform, mod] of reloadMods) {
let handler;
const calls = { reload: 0, hard: 0, prevented: 0 };
const contents = {
on: (name, fn) => { if (name === "before-input-event") handler = fn; },
reload: () => { calls.reload += 1; },
reloadIgnoringCache: () => { calls.hard += 1; },
};
installPaneReload(contents, platform);
const send = (input) => handler({ preventDefault: () => { calls.prevented += 1; } }, input);
send(reloadPress(mod));
send(reloadPress({ key: "F5", code: "F5" }));
assert.deepEqual(calls, { reload: 2, hard: 0, prevented: 2 }, platform);
send(reloadPress({ ...mod, shift: true, key: "R" }));
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, platform);
send(reloadPress({ ...mod, key: "f", code: "KeyF" }));
send(reloadPress({ key: "F12", code: "F12" }));
send(reloadPress({ ...mod, alt: true }));
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, `${platform}: another key was handled`);
}
});
+4 -4
View File
@@ -29,9 +29,9 @@
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.4",
"@lezer/highlight": "^1.2.5",
"codemirror": "^6.0.2",
"katex": "^0.18.9",
"katex": "^0.18.10",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
@@ -53,7 +53,7 @@
"jsdom": "^30.1.1",
"playwright": "^1.63.0",
"typescript": "^7.0.2",
"vite": "^8.3.0",
"vitest": "^5.0.1"
"vite": "^8.3.2",
"vitest": "^5.0.3"
}
}
+125 -138
View File
@@ -60,14 +60,14 @@ importers:
specifier: ^6.43.13
version: 6.43.13
'@lezer/highlight':
specifier: ^1.2.4
version: 1.2.4
specifier: ^1.2.5
version: 1.2.5
codemirror:
specifier: ^6.0.2
version: 6.0.2
katex:
specifier: ^0.18.9
version: 0.18.9
specifier: ^0.18.10
version: 0.18.10
react:
specifier: ^19.3.0
version: 19.3.0
@@ -116,7 +116,7 @@ importers:
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1([email protected].0([email protected]))
version: 6.1.1([email protected].2([email protected]))
jsdom:
specifier: ^30.1.1
version: 30.1.1
@@ -127,11 +127,11 @@ importers:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: ^8.3.0
version: 8.3.0([email protected])
specifier: ^8.3.2
version: 8.3.2([email protected])
vitest:
specifier: ^5.0.1
version: 5.0.1([email protected])([email protected].0([email protected]))
specifier: ^5.0.3
version: 5.0.3([email protected])([email protected].2([email protected]))
packages:
@@ -453,8 +453,8 @@ packages:
'@lezer/[email protected]':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/[email protected].4':
resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
'@lezer/[email protected].5':
resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
'@lezer/[email protected]':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
@@ -489,101 +489,101 @@ packages:
'@marijn/[email protected]':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@oxc-project/[email protected]47.0':
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
'@oxc-project/[email protected]52.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/[email protected].6':
resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -799,8 +799,8 @@ packages:
oxc-transform-react:
optional: true
'@vitest/[email protected].1':
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
'@vitest/[email protected].3':
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -810,8 +810,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].1':
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
'@vitest/[email protected].3':
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -1051,8 +1051,8 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
[email protected].9:
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
[email protected].10:
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
hasBin: true
[email protected]:
@@ -1396,8 +1396,8 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected].6:
resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==}
[email protected].12:
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -1408,9 +1408,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1418,9 +1415,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
@@ -1511,8 +1505,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
[email protected].0:
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
[email protected].2:
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1554,20 +1548,20 @@ packages:
yaml:
optional: true
[email protected].1:
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
[email protected].3:
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.1
'@vitest/browser-preview': 5.0.1
'@vitest/browser-playwright': 5.0.3
'@vitest/browser-preview': 5.0.3
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.1
'@vitest/coverage-v8': 5.0.1
'@vitest/ui': 5.0.1
'@vitest/coverage-istanbul': 5.0.3
'@vitest/coverage-v8': 5.0.3
'@vitest/ui': 5.0.3
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -1621,9 +1615,9 @@ packages:
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
engines: {node: ^22.14.0 || >=24.0.0}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
why-is-node-running@3.2.1:
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
engines: {node: '>=20.11'}
hasBin: true
[email protected]:
@@ -1771,7 +1765,7 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/[email protected]':
@@ -1789,7 +1783,7 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
@@ -1798,7 +1792,7 @@ snapshots:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
style-mod: 4.1.4
@@ -1943,41 +1937,41 @@ snapshots:
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected].4':
'@lezer/[email protected].5':
dependencies:
'@lezer/common': 1.5.2
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
@@ -1987,79 +1981,79 @@ snapshots:
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@marijn/[email protected]': {}
'@oxc-project/[email protected]47.0': {}
'@oxc-project/[email protected]52.0': {}
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected].6':
'@rolldown/[email protected].12':
optional: true
'@rolldown/[email protected]': {}
@@ -2194,21 +2188,21 @@ snapshots:
'@ungap/[email protected]': {}
'@vitejs/[email protected]([email protected].0([email protected]))':
'@vitejs/[email protected]([email protected].2([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.0([email protected])
vite: 8.3.2([email protected])
'@vitest/[email protected].1([email protected].0([email protected]))':
'@vitest/[email protected].3([email protected].2([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.1
'@vitest/spy': 5.0.3
estree-walker: 3.0.3
magic-string: 1.4.2
optionalDependencies:
vite: 8.3.0([email protected])
vite: 8.3.2([email protected])
'@vitest/[email protected].1': {}
'@vitest/[email protected].3': {}
[email protected]: {}
@@ -2519,7 +2513,7 @@ snapshots:
dependencies:
commander: 8.3.0
[email protected].9:
[email protected].10:
dependencies:
commander: 15.0.0
@@ -3117,26 +3111,26 @@ snapshots:
[email protected]: {}
[email protected].6:
[email protected].12:
dependencies:
'@oxc-project/types': 0.147.0
'@oxc-project/types': 0.152.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.6
'@rolldown/binding-android-arm64': 1.2.6
'@rolldown/binding-darwin-arm64': 1.2.6
'@rolldown/binding-darwin-x64': 1.2.6
'@rolldown/binding-freebsd-x64': 1.2.6
'@rolldown/binding-linux-arm-gnueabihf': 1.2.6
'@rolldown/binding-linux-arm64-gnu': 1.2.6
'@rolldown/binding-linux-arm64-musl': 1.2.6
'@rolldown/binding-linux-ppc64-gnu': 1.2.6
'@rolldown/binding-linux-s390x-gnu': 1.2.6
'@rolldown/binding-linux-x64-gnu': 1.2.6
'@rolldown/binding-linux-x64-musl': 1.2.6
'@rolldown/binding-openharmony-arm64': 1.2.6
'@rolldown/binding-win32-arm64-msvc': 1.2.6
'@rolldown/binding-win32-x64-msvc': 1.2.6
'@rolldown/binding-android-arm-eabi': 1.2.12
'@rolldown/binding-android-arm64': 1.2.12
'@rolldown/binding-darwin-arm64': 1.2.12
'@rolldown/binding-darwin-x64': 1.2.12
'@rolldown/binding-freebsd-x64': 1.2.12
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
'@rolldown/binding-linux-arm64-gnu': 1.2.12
'@rolldown/binding-linux-arm64-musl': 1.2.12
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
'@rolldown/binding-linux-s390x-gnu': 1.2.12
'@rolldown/binding-linux-x64-gnu': 1.2.12
'@rolldown/binding-linux-x64-musl': 1.2.12
'@rolldown/binding-openharmony-arm64': 1.2.12
'@rolldown/binding-win32-arm64-msvc': 1.2.12
'@rolldown/binding-win32-x64-msvc': 1.2.12
[email protected]:
dependencies:
@@ -3144,14 +3138,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -3279,21 +3269,21 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected].0([email protected]):
[email protected].2([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.6
rolldown: 1.2.12
tinyglobby: 0.2.17
optionalDependencies:
esbuild: 0.28.2
fsevents: 2.3.3
[email protected].1([email protected])([email protected].0([email protected])):
[email protected].3([email protected])([email protected].2([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1([email protected].0([email protected]))
'@vitest/mocker': 5.0.3([email protected].2([email protected]))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -3304,8 +3294,8 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.0([email protected])
why-is-node-running: 2.3.0
vite: 8.3.2([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
jsdom: 30.1.1
transitivePeerDependencies:
@@ -3339,10 +3329,7 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
why-is-node-running@3.2.1: {}
[email protected]: {}
+18
View File
@@ -81,6 +81,7 @@ export const ACTIONS: UIAction[] = [
// refused while a turn runs, so it never stops work.
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
{ id: "session.delete", kind: "destructive", target: "entity", proof: "authority-effect" },
@@ -113,6 +114,15 @@ export const ACTIONS: UIAction[] = [
{ id: "prompt.adopt", kind: "view", target: "none", proof: "interaction" },
{ id: "prompt.discard", kind: "view", target: "none", proof: "interaction" },
// ── Committing what is staged ────────────────────────────────────────────
// Drafting reads the index and asks the model; only confirm records a commit,
// and only locally.
{ id: "commit.draft", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "commit.edit", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.acknowledge", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "commit.close", kind: "view", target: "none", proof: "interaction" },
// ── What is waiting to be sent ───────────────────────────────────────────
{ id: "queue.edit", kind: "kernel-mutation", target: "entity", proof: "interaction" },
// Giving a session a name of your own. The sidebar's tree and the pane tabs
@@ -389,6 +399,11 @@ export const ACTIONS: UIAction[] = [
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
// Usage changes what report is read, not canonical session state.
{ id: "usage.range.start", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.end", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.month", kind: "view", target: "none", proof: "interaction" },
// ── How the window looks ─────────────────────────────────────────────────
// Named for what a person is doing, not for the control they reached for.
// The interface scale has a row of presets and a slider beside it, and both
@@ -467,6 +482,9 @@ export const ACTIONS: UIAction[] = [
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -16,4 +16,16 @@ export const FOLD_WHY: Record<string, string> = {
savings_below_minimum: "固定前缀已占满检查点上限,这次折叠省下的空间太少,没有采用",
fixed_prefix_above_trigger: "无法折叠的固定部分本身已超过压缩阈值",
fold_empty_after_hooks: "扩展钩子把待折叠的内容清空了",
cancelled: "压缩被取消了",
unclassified: "压缩失败,原因未归类;详情见日志",
busy: "有一轮对话或会话切换正在进行,请稍后再试",
input_unchanged: "上下文自上次整理后没有变化",
no_new_closed_prefix: "自上个检查点以来没有已结束的内容",
fold_below_economics: "新增内容太少,不值得再生成一次摘要",
active_turn_boundary: "正在进行的这一轮需要先结束",
no_foldable_region: "没有可折叠的内容",
};
// The reason a /compact notice carries as its detail; an empty detail is the
// kernel's decline with no class.
export const NO_CODE_WHY = "没有值得折叠的内容";
+71 -1
View File
@@ -546,7 +546,9 @@ export const EN: Record<string, string> = {
"编辑": "Edit",
"测试中…": "Testing…",
"测试连接": "Test it",
"没保存成功": "Not saved",
"保存失败": "Save failed",
"已保存,尚未生效": "Saved, not applied yet",
"刷新模型目录失败": "Could not refresh the model catalog",
"重新问一次有哪些模型": "Ask for the model list again",
"刷新模型目录": "Refresh model catalog",
"正在刷新…": "Refreshing…",
@@ -785,6 +787,7 @@ export const EN: Record<string, string> = {
"打开主题目录": "Open themes folder",
"阅读": "Read",
"设置需要一个打开的会话。请先在左栏添加一个文件夹。": "Settings need an open session. Add a folder in the sidebar first.",
"反馈需要一个打开的会话。请先在左栏添加一个文件夹。": "Feedback needs an open session. Add a folder in the sidebar first.",
"{host} 需要登录": "{host} asks you to sign in",
"代理 {host} 需要登录": "The proxy {host} asks you to sign in",
"用户名": "Username",
@@ -809,6 +812,7 @@ export const EN: Record<string, string> = {
"已导入「{name}」。": "Imported “{name}”.",
"立即使用": "Use now",
"未读取:{names}": "Not read: {names}",
"请单独选择一个 .zip,或选择不含压缩包的 theme.json 和图片。": "Choose a single .zip on its own, or select a theme folder’s theme.json and images without a ZIP.",
"尚未安装主题。选择一个 .zip,或同时选中主题文件夹里的 theme.json 和图片。": "No themes installed. Choose a .zip, or select a theme folder’s theme.json and images together.",
"选择一个 .zip,或同时选中主题文件夹里的 theme.json 和图片。": "Choose a .zip, or select a theme folder’s theme.json and images together.",
@@ -833,6 +837,7 @@ export const EN: Record<string, string> = {
"已关闭": "Disabled",
"未连接": "Not connected",
"待命 · 首次调用时启动": "Standby · starts on first call",
"待授权 · 来自项目声明": "Awaiting approval · declared by the project",
"本地构建": "Local build",
"只在这台机器上": "This machine only",
"这个项目": "This project",
@@ -1052,6 +1057,7 @@ export const EN: Record<string, string> = {
"兼顾响应速度与可靠性,适合大多数任务": "Balance speed and reliability for most tasks",
"投入更多时间分析复杂上下文与执行方案": "Spend more time analyzing complex context and execution plans",
"用于最复杂的问题,等待时间与消耗最高": "For the hardest problems, with the longest wait and highest usage",
"思考仍开启并计费,该模型无法关闭思考": "Thinking stays on and is billed; this model cannot turn it off",
"仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.",
"按模型生效": "Per model",
"查看上下文与压缩": "View context and compaction",
@@ -1118,6 +1124,11 @@ export const EN: Record<string, string> = {
"读取": "Read",
"本地工作区": "Local workspace",
"暂停执行": "Pause run",
"复制会话信息": "Copy session information",
"会话 ID": "Session ID",
"会话上下文路径": "Session context path",
"任务路径": "Task path",
"任务日志": "Task log",
"导出会话": "Export session",
"取消置顶": "Unpin",
"置顶会话": "Pin session",
@@ -1212,6 +1223,64 @@ export const EN: Record<string, string> = {
"The model returned nothing; try again",
"优化请求格式不正确":
"The refine request was malformed",
"提交…":
"Commit…",
"起草提交说明":
"Draft commit message",
"读取暂存区,起草提交说明…":
"Reading the staged changes and drafting a message…",
"重新起草":
"Redraft",
"提交说明":
"Commit message",
"暂存的文件":
"Staged files",
"{n} 个文件已暂存":
"{n} files staged",
"可能含有密钥:{files}":
"May contain secrets: {files}",
"新增的内容里有形似密钥的值":
"Added lines contain values shaped like credentials",
"我确认这些内容可以提交":
"I confirm these can be committed",
"提交到本地":
"Commit locally",
"只在本地提交,不会推送":
"Recorded locally only; nothing is pushed",
"已提交 {hash}:{subject}":
"Committed {hash}: {subject}",
"改过暂存区后请重新起草":
"Draft again after changing what is staged",
"暂存区内容过长,只按前面一部分起草":
"The staged diff is long; the draft reads only its first part",
"这个工作区不是 git 仓库,无法提交":
"This workspace is not a git repository, so there is nothing to commit",
"暂存区是空的,先用 git add 暂存要提交的文件":
"Nothing is staged; stage the files to commit with git add first",
"暂存区在你确认之后又变了,请重新起草":
"The staged changes changed after you reviewed them; draft again",
"暂存的内容里有疑似密钥的文件,需要你确认后才能提交":
"The staged changes include likely secrets; confirm them before committing",
"提交说明是空的":
"The commit message is empty",
"提交说明含有无法记录的字符":
"The commit message contains characters git cannot record",
"提交说明超过 {max_bytes} 字节":
"The commit message is over {max_bytes} bytes",
"git 没有配置提交者姓名和邮箱(user.name、user.email)":
"git has no author name and email configured (user.name, user.email)",
"git 没能记录这次提交":
"git could not record the commit",
"当前会话没有可用的模型,无法起草提交说明":
"This session has no model to draft a commit message with",
"起草提交说明超时,请重试":
"Drafting the commit message timed out; try again",
"模型没有给出提交说明,请重试":
"The model returned no commit message; try again",
"读取暂存区失败":
"Reading the staged changes failed",
"提交请求格式不正确":
"The commit request was malformed",
"新增工具":
"New tools",
"agent 可以调用,每次调用照常经过权限确认":
@@ -1230,6 +1299,7 @@ export const EN: Record<string, string> = {
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
"等待你的输入": "Waiting for you",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
};
@@ -21,6 +21,9 @@ export const EN_BACKUP: Record<string, string> = {
"备注(可选)": "Label (optional)",
"例如:公司笔记本": "e.g. work laptop",
"加密口令(至少 {n} 个字符)": "Passphrase (at least {n} characters)",
"至少选择一项备份内容": "Tick at least one thing to back up",
"口令还差 {n} 个字符": "Passphrase too short: {n} more needed",
"请再输一次口令": "Repeat the passphrase to confirm it",
"再输一次": "Repeat it",
"两次输入的口令不一致": "The two passphrases differ",
"正在加密上传…": "Encrypting and uploading…",
@@ -3,6 +3,8 @@
export const EN_COMPOSER: Record<string, string> = {
"↓ 回到最新": "↓ Back to latest",
"不思考": "No reasoning",
"最轻量": "Minimal",
"比快速档更轻;GLM-5.2 会跳过思考": "Lighter than Fast; GLM-5.2 skips thinking",
"超深入": "Extra deep",
"不做推理直接作答,响应最快,适合简单问答": "Answers without reasoning; fastest, for simple questions",
"比深入投入更多推理,适合困难的多步问题": "More reasoning than Deep, for hard multi-step problems",
@@ -188,12 +188,14 @@ export const EN_KERNEL: Record<string, string> = {
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
"名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。": "The name “{name}” cannot hold a key: the credential slot is derived from it, and a slot may not start with a digit. Use a name starting with a letter — the key itself is fine.",
"这个内核没有带界面,只提供接口": "This kernel serves no interface, only its API",
"当前模型还没有 API key,请先在设置里添加": "The current model has no API key yet. Add one in Settings first.",
"请填写 API key": "An API key is required",
"该 key 长度异常,可能粘贴了错误内容": "That key is too long — something else was probably pasted",
"已连接,无需重复配置": "Already connected — no need to set it up again",
"远端配置未完成,请稍后重试": "The remote setup did not complete; try again shortly",
"没有这个模型连接": "There is no such model connection",
"该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env": "This connection has no variable to store a key in — set its api_key_env in the configuration first",
"该 key 含有无法安全保存的字符组合,请重新复制": "That key contains a character combination that cannot be stored safely — copy it again",
"该 key 超长或含换行,请重新复制": "That key is too long or spans several lines — copy it again",
"已保存的密钥在此期间被改动,请重新打开配置再试": "The stored keys changed in the meantime — reopen the setup and try again",
"密钥已保存,但连接尚未生效,请重试": "The key was saved but the connection is not active yet — try again",
@@ -240,6 +242,7 @@ export const EN_KERNEL: Record<string, string> = {
"无法解析该会话路径": "That conversation path does not resolve",
"打不开这个会话": "Could not open that conversation",
"该路径位于会话目录之外": "That path is outside the conversations directory",
"没有这个会话": "No such 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",
@@ -330,6 +333,8 @@ export const EN_KERNEL: Record<string, string> = {
"写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹": "The target is outside the workspace write scope. This is the file tools' write scope, not an OS sandbox; to allow the write, add the target folder under Settings → Sandbox → Also writable",
"无法读取工作区文件列表": "The workspace file list could not be read",
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
"不支持这个币种,只能选自动、CNY 或 USD:{detail}": "That currency is not supported; choose Auto, CNY or USD: {detail}",
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
@@ -462,6 +462,18 @@ export const EN_SETTINGS: Record<string, string> = {
"固定前缀已占满检查点上限,这次折叠省下的空间太少,没有采用": "The fixed prefix fills the checkpoint limit and this fold saved too little, so it was not used",
"无法折叠的固定部分本身已超过压缩阈值": "The part that cannot be folded is already over the compaction threshold",
"扩展钩子把待折叠的内容清空了": "An extension hook emptied the content to fold",
"压缩被取消了": "The compaction was cancelled",
"压缩失败,原因未归类;详情见日志": "The compaction failed for a reason the host did not classify; see the log",
"有一轮对话或会话切换正在进行,请稍后再试": "A turn or a session change is in progress; try again shortly",
"上下文自上次整理后没有变化": "The context has not changed since it was last tidied",
"自上个检查点以来没有已结束的内容": "Nothing has closed since the last checkpoint",
"新增内容太少,不值得再生成一次摘要": "Too little has been added to pay for another summary",
"正在进行的这一轮需要先结束": "The turn in flight has to finish first",
"没有可折叠的内容": "No foldable region remains",
"没有值得折叠的内容": "Nothing left worth folding",
"已压缩": "Compacted",
"无需压缩:{why}": "Nothing to compact: {why}",
"压缩失败:{why}": "Compaction failed: {why}",
"本段的 {n} 处改动均已写入简报": "All {n} changes made here are in the digest",
"{kept}/{required} 处改动写进了简报": "{kept}/{required} changes are in the digest",
"另有 {n} 处仅保留索引地址,需通过 recall 取回原文": "{n} more are index entries only; recall fetches the original",
@@ -678,6 +690,8 @@ export const EN_SETTINGS: Record<string, string> = {
"检测中…": "Checking…",
"验证连接并读取": "Check connection and read models",
"连接可用 · 找到 {n} 个模型": "Connection works · {n} models found",
"接口地址已补全为 {url}": "Address completed to {url}",
"刷新模型目录时,模型列表只在 {url} 下响应。聊天若能正常使用就不用改;可选:把接口地址改成它。": "The model list only answers at {url}. If chat already works, no change is needed; optionally set the address to it.",
"启用的模型": "Enabled models",
"目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。": "The catalogue is for discovery, not a whitelist. You can enter any exact model ID supplied by the service.",
"阈值需至少为 1,000,并小于模型上下文窗口。": "The threshold must be at least 1,000 and below the model context window.",
+3 -1
View File
@@ -9,7 +9,9 @@ export const EN_STORAGE: Record<string, string> = {
"选择旧版数据目录…": "Choose older data folder…",
"正在扫描…": "Scanning…",
"已找回 {n} 个会话。": "Recovered {n} sessions.",
"没有发现尚未导入的旧会话。": "No sessions remain to be imported.",
"没有发现尚未导入的旧会话。": "Nothing new to import.",
"这个文件夹里没有找到旧版会话。请选择 Reasonix 的数据文件夹(里面有 sessions、projects 或 desktop-sessions-v5)。":
"No older sessions were found in this folder. Pick the Reasonix data folder (the one holding sessions, projects or desktop-sessions-v5).",
"有 {n} 项无法读取。": "{n} items could not be read.",
"未能扫描这个文件夹。请确认它是旧版 Reasonix 的数据目录。": "This folder could not be scanned. Make sure it is an older Reasonix data folder.",
存储: "Storage",
@@ -22,11 +22,30 @@ export const EN_USAGE: Record<string, string> = {
"以检查点版本覆盖": "Overwrite with the checkpoint's",
"保留当前版本": "Keep the current one",
"用量": "Usage",
"费用显示币种": "Cost display currency",
"只决定费用以哪种货币显示,不改动各来源的价目表。「自动」跟随账户余额的币种,没有余额信息时按价目表的币种显示。":
"Only decides which currency costs are shown in; each source's price list is untouched. Auto follows the account balance's currency, and falls back to the price list's currency when there is no balance.",
"显示币种": "Display currency",
"当前会话的费用、余额与用量页立即按所选币种显示,无需重启。": "The session's cost, the balance and the usage page switch to the chosen currency at once, no restart.",
"人民币 CNY": "Chinese yuan (CNY)",
"美元 USD": "US dollar (USD)",
"无法读取币种设置。": "Could not read the currency setting.",
"用量与成本": "Usage & cost",
"本机记录的 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…",
"时间范围": "Time range",
"自然月": "Calendar month",
"开始日期": "Start date",
"结束日期": "End date",
"应用": "Apply",
"选择月份": "Choose month",
"每日明细": "Daily details",
"精确到每天": "Exact totals for each day",
"左右滚动查看": "Scroll sideways for all columns",
"日期": "Date",
"支出": "Spend",
"请求": "Requests",
"7 天": "7 days",
"30 天": "30 days",
"全部": "All",
@@ -54,6 +73,7 @@ export const EN_USAGE: Record<string, string> = {
"另有 {n} 天有用量但没有成本记录 —— 不是那几天没花费,是成本字段后来才开始持久化。": "{n} more days have usage but no cost recorded — not free days; the cost field started being persisted later.",
"按模型": "By model",
"按来源": "By source",
"未知来源": "Unknown source",
"尚无 key?前往获取": "No key yet? Get one",
"打开 {name} 控制台": "Open the {name} console",
"无法打开浏览器,请手动访问 {at}": "Could not open a browser — visit {at} yourself",
+22 -1
View File
@@ -18,7 +18,7 @@ export const PROVIDER_EDIT_DISABLED = "provider.editing_disabled";
// A paired device answers this for every account route, votes included.
export const ACCOUNT_SIGNIN_DISABLED = "account.signin_disabled";
// The write landed and only applying it to the open conversation did not.
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed"];
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed", "runtime.saved_while_running"];
const SAID: Record<string, string> = {
"shell.destructive_target": "递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径",
@@ -78,6 +78,7 @@ const SAID: Record<string, string> = {
"session.bad_path": "无法解析该会话路径",
"session.open_failed": "打不开这个会话",
"session.outside_dir": "该路径位于会话目录之外",
"session.unknown": "没有这个会话",
"request.method_not_allowed": "该地址不接受此种请求方式",
"request.bad_content_type": "请求体必须是 application/json",
"permissions.editing_disabled": "这台服务器未开放权限编辑",
@@ -136,6 +137,7 @@ const SAID: Record<string, string> = {
"provider.no_current_model": "当前没有正在使用的模型,无法记录其窗口大小",
"context.window_after_this_turn": "窗口大小已记录,将在本轮结束后生效",
"provider.saved_while_running": "已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效",
"runtime.saved_while_running": "已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效",
"provider.saved_model_unlisted": "已保存。当前对话使用的模型已不在该来源的列表中,切换模型后才会生效",
"provider.extra_body_null": "额外设置中的「{path}」不能为空值(null)",
"provider.no_websearch_wire": "该协议不支持由端点自行搜索",
@@ -169,12 +171,14 @@ const SAID: Record<string, string> = {
"picker.failed": "打不开文件夹选择框:{error}",
"provider.bad_key_slot": "名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。",
"page.not_built": "这个内核没有带界面,只提供接口",
"provider.key_missing": "当前模型还没有 API key,请先在设置里添加",
"provider.key_required": "请填写 API key",
"provider.key_too_large": "该 key 长度异常,可能粘贴了错误内容",
"provider.setup_done": "已连接,无需重复配置",
"provider.setup_failed": "远端配置未完成,请稍后重试",
"provider.unknown": "没有这个模型连接",
"provider.no_key_slot": "该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env",
"provider.key_unstorable": "该 key 含有无法安全保存的字符组合,请重新复制",
"provider.key_invalid": "该 key 超长或含换行,请重新复制",
"provider.credentials_changed": "已保存的密钥在此期间被改动,请重新打开配置再试",
"provider.activation_failed": "密钥已保存,但连接尚未生效,请重试",
@@ -214,6 +218,21 @@ const SAID: Record<string, string> = {
"prompt_refine.failed": "优化失败,请重试",
"prompt_refine.bad_request": "优化请求格式不正确",
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
"commit.secrets_staged": "暂存的内容里有疑似密钥的文件,需要你确认后才能提交",
"commit.empty_message": "提交说明是空的",
"commit.message_invalid": "提交说明含有无法记录的字符",
"commit.message_too_long": "提交说明超过 {max_bytes} 字节",
"commit.identity_missing": "git 没有配置提交者姓名和邮箱(user.name、user.email)",
"commit.failed": "git 没能记录这次提交",
"commit.no_model": "当前会话没有可用的模型,无法起草提交说明",
"commit.timeout": "起草提交说明超时,请重试",
"commit.no_answer": "模型没有给出提交说明,请重试",
"commit.git_failed": "读取暂存区失败",
"commit.bad_request": "提交请求格式不正确",
// ── 会话 ─────────────────────────────────────────────────────────
"session.disabled": "这台服务器已关闭会话切换",
"session.pending_cleanup": "该会话正在清理,请稍后再打开",
@@ -289,6 +308,8 @@ const SAID: Record<string, string> = {
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
@@ -21,5 +21,9 @@ export const NOTICE_TEXT: Record<string, string> = {
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
display_currency: "费用显示币种已设为 {mode}",
compacted: "已压缩",
compact_declined: "无需压缩:{why}",
compact_failed: "压缩失败:{why}",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
@@ -84,6 +84,14 @@ export interface BrowserToolsSettings {
// When a run reads as no longer moving. Only the user file holds it: a
// project file cannot pause the user's runs.
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
// Which currency costs are shown in; the user file holds it.
export interface DisplayCurrencySettings {
mode: DisplayCurrencyMode;
path: string;
}
export interface ProgressWatchSettings {
pause: boolean;
rounds: number;
+2 -2
View File
@@ -88,7 +88,7 @@ export interface HubPort extends SharePort {
archiveSession(path: string, archived: boolean): Promise<void>;
renameSession(path: string, title: string): Promise<void>;
exportSession(path: string): Promise<{ name: string; content: string }>;
importLegacySessions(path: string, workspace: string): Promise<{ summary: string; imported: number; warnings: number }>;
importLegacySessions(path: string, workspace: string): Promise<{ summary: string; imported: number; warnings: number; recognised: boolean }>;
// The host book with each link's state, or null where this kernel refuses
// remote panes outright — a page served to a browser, rather than the window.
// Null is what lets the sidebar leave the whole section out instead of
@@ -247,7 +247,7 @@ export class SseHub implements HubPort {
}
importLegacySessions(path: string, workspace: string) {
return this.post<{ summary: string; imported: number; warnings: number }>("/tree/sessions/import-legacy", { path, workspace });
return this.post<{ summary: string; imported: number; warnings: number; recognised: boolean }>("/tree/sessions/import-legacy", { path, workspace });
}
async remoteHosts() {
+6
View File
@@ -25,6 +25,7 @@ export interface McpEntry {
// differ until the next one.
alwaysLoad?: boolean;
inSchema?: boolean;
launch?: string;
}
// always puts a server's tools in every request; deferred reaches them through
@@ -87,6 +88,11 @@ export interface McpDraftServer {
url?: string;
env?: Record<string, string>;
headers?: Record<string, string>;
displayUrl?: string;
displayEnv?: Record<string, string>;
displayHeaders?: Record<string, string>;
displayCommand?: string;
displayArgs?: string[];
}
// What the confirmation card must show: shell is the command that will run,
+4 -4
View File
@@ -1,6 +1,6 @@
import type { PlanAction } from "./session";
import { HttpError } from "./port";
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs } from "./port";
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockFeedback } from "./mock_feedback";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
@@ -122,8 +122,8 @@ export class MockPort extends MockFeedback implements AgentPort {
private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m }));
async usage(days: number): Promise<UsageReport> {
return mockUsage(days);
async usage(query: number | UsageQuery): Promise<UsageReport> {
return mockUsage(query);
}
async memories(): Promise<MemoryCatalog> {
@@ -775,7 +775,7 @@ export class MockPort extends MockFeedback implements AgentPort {
return mockStoragePlan(root, dir);
}
async setModel(ref: string) {
async setModel(ref: string, _asDefault?: boolean) {
this.state.modelRef = ref;
this.state.label = ref.split("/").pop() ?? ref;
}
@@ -1,5 +1,5 @@
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { ProgressWatchSettings } from "./boundary";
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
import { MockShell } from "./mock_shell";
// The boundary half of the fixture: what the agent is refused outright, and how
@@ -104,6 +104,17 @@ export class MockBoundary extends MockShell {
return { ...this.browser };
}
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml" };
async displayCurrency(): Promise<DisplayCurrencySettings> {
return { ...this.currency };
}
async saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings> {
this.currency = { ...this.currency, mode };
return { ...this.currency };
}
private watch: ProgressWatchSettings = {
pause: false, rounds: 20, tokenMultiple: 8, defaultRounds: 20, defaultTokenMultiple: 8,
path: "/Users/you/.reasonix/config.toml",
@@ -0,0 +1,22 @@
import { MockBackup } from "./mock_backup";
import type { CommitProposal, CommitRequest, CommitResult } from "./workspace";
export class MockCommit extends MockBackup {
async proposeCommit(): Promise<CommitProposal> {
await new Promise((r) => setTimeout(r, 600));
return {
message: "fix(provider): back off retries on 429 and drop credentials from logs\n\nHonour Retry-After before the next attempt and stop printing the key.",
fingerprint: "mock",
files: [
{ path: "internal/provider/retry.go", status: "M", insertions: 14, deletions: 3 },
{ path: "internal/config/credentials.go", status: "M", insertions: 6, deletions: 2 },
],
truncated: false,
contentSecrets: false,
};
}
async commitStaged(req: CommitRequest): Promise<CommitResult> {
return { hash: "0123456789abcdef0123456789abcdef01234567", subject: req.message.split("\n")[0] };
}
}
@@ -1,5 +1,5 @@
import { HttpError } from "./http_error";
import { MockBackup } from "./mock_backup";
import { MockCommit } from "./mock_commit";
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
@@ -59,7 +59,7 @@ function refusal(code: string): HttpError {
return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params: code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {} });
}
export class MockFeedback extends MockBackup {
export class MockFeedback extends MockCommit {
private filed: FeedbackItem[] = [];
private name = "";
private rows: FeedbackItem[] = SEEDED.map((i) => ({ ...i, replies: [...i.replies] }));
+1 -1
View File
@@ -221,7 +221,7 @@ export class MockHub implements HubPort {
}
importLegacySessions(_path: string, _workspace: string) {
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0 });
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0, recognised: true });
}
hostCapabilities() {
@@ -111,6 +111,15 @@ export class MockProvider extends MockBoundary {
if (request.model.includes("missing")) {
return { model: request.model, status: "unavailable", reason: "not_found" };
}
if (request.model.includes("refused")) {
return {
model: request.model,
status: "unknown",
reason: "rejected",
httpStatus: 400,
detail: `registry.ollama.ai/library/${request.model} does not support tools`,
};
}
return { model: request.model, status: "available" };
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { mockUsage } from "./mock_usage";
describe("mock usage windows", () => {
it("returns the requested explicit calendar range", () => {
const report = mockUsage({ from: "2026-08-03", to: "2026-08-05" });
expect(report.from).toBe("2026-08-03");
expect(report.to).toBe("2026-08-05");
expect(report.daily.map((day) => day.day)).toEqual(["2026-08-03", "2026-08-04", "2026-08-05"]);
});
it("rejects a range longer than the kernel accepts", () => {
expect(() => mockUsage({ from: "2025-01-01", to: "2026-01-01" })).toThrow("range must be between 1 and 365 days");
});
});
+31 -7
View File
@@ -1,20 +1,44 @@
import type { UsageReport } from "./port";
import type { UsageQuery, UsageReport } from "./port";
import { USAGE_PRICED, USAGE_TOKENS } from "./mock_memory";
import { DEFAULT_USAGE_DAYS } from "./usage";
const DAY_MS = 864e5;
function parseDay(value: string): number {
const parsed = Date.parse(`${value}T00:00:00Z`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== value) {
throw new Error("range dates must be YYYY-MM-DD");
}
return parsed;
}
function explicitRange(query: Extract<UsageQuery, { from: string }>) {
const from = parseDay(query.from);
const to = parseDay(query.to);
const days = Math.round((to - from) / DAY_MS) + 1;
if (days < 1 || days > 365) throw new Error("range must be between 1 and 365 days");
return { from, days };
}
// A fortnight with the shape a real one has: a couple of heavy days, a
// quiet stretch, and an early span the recorder priced before cost was
// persisted — those days carry tokens and no cost, which the panel must not
// render as free.
export function mockUsage(days: number): UsageReport {
export function mockUsage(query: number | UsageQuery): UsageReport {
const shape = USAGE_TOKENS;
const priced = USAGE_PRICED;
const explicit = typeof query === "number" || "days" in query ? null : explicitRange(query);
const days = explicit?.days ?? (typeof query === "number" ? query : "days" in query ? query.days : DEFAULT_USAGE_DAYS);
if (days < 1 || days > 365) throw new Error("days must be between 1 and 365");
const count = explicit ? days : Math.min(days, shape.length);
// Index against the full arrays, not the slice: taking 7 of 13 days shifted
// both the dates and the cost column by six.
const from = shape.length - Math.min(days, shape.length);
const daily = shape.slice(from).map((total, i) => {
const day = from + i;
const at = new Date(Date.now() - (shape.length - 1 - day) * 864e5).toISOString().slice(0, 10);
const amount = priced[day];
const first = explicit ? 0 : shape.length - count;
const daily = Array.from({ length: count }, (_, i) => {
const fixture = explicit ? i % shape.length : first + i;
const total = shape[fixture];
const at = (explicit ? new Date(explicit.from + i * DAY_MS) : new Date(Date.now() - (count - 1 - i) * DAY_MS)).toISOString().slice(0, 10);
const amount = priced[fixture];
return {
day: at, total,
byModel: (total ? { "deepseek/deepseek-flash": total } : {}) as Record<string, number>,
+2
View File
@@ -54,6 +54,8 @@ export interface ModelEntry {
answers?: "chat" | "decision";
efforts?: string[];
effort?: string;
// Even the lowest effort still reasons and is billed.
forcesThinking?: boolean;
contextWindow?: number;
price?: ModelPrice;
// The credential this route spends; pairs with vendor to name the account.
+16 -6
View File
@@ -11,16 +11,17 @@ 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 } from "./memory";
import type { UsageReport } from "./usage";
import type { UsageQuery, UsageReport } from "./usage";
export { DEFAULT_USAGE_DAYS } from "./usage";
export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageReport } from "./usage";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry } from "./skill";
import type { UpdateProgress, VersionEntry, VersionHub } from "./version";
import type { ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace";
// 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
@@ -32,13 +33,13 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
VersionHub, WalletLine, WalletReading, ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
VersionHub, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
export type { ModelEffort, ModelLimit, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -218,6 +219,7 @@ export interface AgentPort {
dryRunHook(h: HookEntry): Promise<HookDryRun>;
memories(): Promise<MemoryCatalog>;
usage(days: number, source?: string): Promise<UsageReport>;
usage(query: UsageQuery): Promise<UsageReport>;
// Archives rather than deletes: a fact dropped by mistake stays recoverable.
forgetMemory(name: string): Promise<void>;
saveMemory(edit: MemoryEdit): Promise<void>;
@@ -252,6 +254,9 @@ export interface AgentPort {
browserTools(): Promise<BrowserToolsSettings>;
// Writes the user file, then rebuilds: the tools are bound while assembling.
saveBrowserTools(enabled: boolean): Promise<BrowserToolsSettings>;
displayCurrency(): Promise<DisplayCurrencySettings>;
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
progressWatch(): Promise<ProgressWatchSettings>;
// Applies to the running turn at its next round; no rebuild.
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
@@ -429,6 +434,10 @@ export interface AgentPort {
// only this says how, and asking per path is what keeps a session that
// touched two hundred files from shipping two hundred diffs nobody opened.
changeDiff(path: string): Promise<ChangeDiff>;
// Drafts a message for what is staged. Nothing is committed: the person edits
// the text, and commitStaged records it locally once they confirm.
proposeCommit(signal?: AbortSignal): Promise<CommitProposal>;
commitStaged(req: CommitRequest): Promise<CommitResult>;
// Dot entries are left out unless hidden is set; VCS stores never appear.
workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise<WorkspaceFiles>;
workspaceFile(path: string): Promise<WorkspaceFile>;
@@ -543,7 +552,8 @@ export interface AgentPort {
// The person's answer to "trust this folder?" for the session's workspace.
decideWorkspaceTrust(trust: "trusted" | "declined"): Promise<void>;
setPreset(preset: Preset): Promise<void>;
setModel(ref: string): Promise<void>;
// Switches this session only; asDefault also records it as the model new sessions start on.
setModel(ref: string, asDefault?: boolean): Promise<void>;
setEffort(effort: string): Promise<void>;
// "" turns the session's model mode off.
setModelMode(mode: string): Promise<void>;
@@ -89,6 +89,9 @@ export interface Protocol {
// gateway speaks, only which ones it answers.
export interface ProviderProbe {
kind: string;
// The address chat must be rooted at: what was typed, completed with /v1 only
// when the model list answered there alone.
baseUrl?: string;
// Every kind that listing may be driven with, kernel order. `kind` is the
// pre-selection among them, not the only answer: DeepSeek serves both the
// OpenAI chat wire and the Responses API off one model list.
@@ -112,6 +115,8 @@ export interface ProviderProbe {
export interface ProviderCheck {
ok: boolean;
kind?: string;
// Present only when the saved address is not the one chat needs.
baseUrl?: string;
// Whether that answer is consistent with the kind the entry records. A
// Responses source answers the OpenAI listing, so equality is the wrong test.
matches?: boolean;
@@ -143,6 +148,8 @@ export interface ProviderModelCheck {
status: ProviderModelCheckStatus;
reason?: ProviderModelCheckReason;
httpStatus?: number;
// The endpoint's own error text, for display; never an input to status or reason.
detail?: string;
}
export interface ProviderModelCheckRequest {
+31 -7
View File
@@ -1,6 +1,6 @@
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type Attachment, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
import { SseFeedback } from "./sse_feedback";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
@@ -97,9 +97,21 @@ export class SsePort extends SseFeedback implements AgentPort {
return body;
}
usage(days: number, source?: string) {
const q = new URLSearchParams({ days: String(days) });
if (source && source !== "all") q.set("source", source);
usage(days: number, source?: string): Promise<UsageReport>;
usage(query: UsageQuery): Promise<UsageReport>;
usage(query: number | UsageQuery, source?: string) {
const q = new URLSearchParams();
if (typeof query === "number") {
q.set("days", String(query));
if (source && source !== "all") q.set("source", source);
} else if ("days" in query) {
q.set("days", String(query.days));
if (query.source && query.source !== "all") q.set("source", query.source);
} else {
q.set("from", query.from);
q.set("to", query.to);
if (query.source && query.source !== "all") q.set("source", query.source);
}
return this.get<UsageReport>("/usage?" + q);
}
memories() {
@@ -431,6 +443,18 @@ export class SsePort extends SseFeedback implements AgentPort {
return this.get<ChangeDiff>(`/changes/diff?path=${encodeURIComponent(path)}`);
}
async proposeCommit(signal?: AbortSignal) {
const res = await fetch(this.base + "/commit/propose", {
method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin", body: "{}", signal,
});
if (!res.ok) await SsePort.fail("/commit/propose", res);
return (await res.json()) as CommitProposal;
}
async commitStaged(req: CommitRequest) {
return this.post0<CommitResult>("/commit", req);
}
workspaceFiles(path = "", query = "", hidden = false) {
const params = new URLSearchParams();
if (path) params.set("path", path);
@@ -752,8 +776,8 @@ export class SsePort extends SseFeedback implements AgentPort {
setPreset(preset: Preset) {
return this.post("/preset", { preset });
}
setModel(ref: string) {
return this.post("/model", { ref });
setModel(ref: string, asDefault = false) {
return this.post("/model", asDefault ? { ref, default: true } : { ref });
}
setEffort(effort: string) {
return this.post("/effort", { effort });
@@ -1,6 +1,6 @@
import { SseShell } from "./sse_shell";
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { ProgressWatchSettings } from "./boundary";
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
@@ -32,6 +32,12 @@ export class SseBoundary extends SseShell {
saveBrowserTools(enabled: boolean) {
return this.post0<BrowserToolsSettings>("/browser-tools", { enabled });
}
displayCurrency() {
return this.get<DisplayCurrencySettings>("/display-currency");
}
saveDisplayCurrency(mode: DisplayCurrencyMode) {
return this.post0<DisplayCurrencySettings>("/display-currency", { mode });
}
progressWatch() {
return this.get<ProgressWatchSettings>("/progress-watch");
}
@@ -0,0 +1,55 @@
import { afterEach, expect, it, vi } from "vitest";
import { SseTheme } from "./sse_theme";
import type { ThemeImport } from "./look";
const pack = { id: "dusk", name: "Dusk", tokens: { light: { bg: "#FFFFFF" } } };
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function reply(body: ThemeImport) {
const fetch = vi.fn().mockResolvedValue(Response.json(body));
vi.stubGlobal("fetch", fetch);
return fetch;
}
it.each(["loose-first", "archive-first", "two-archives"])("rejects an ambiguous ZIP selection before reading or uploading any file (%s)", async (order) => {
const zip = new File(["archive fixture"], "Dusk.ZIP");
const other = new File(["other fixture"], order === "two-archives" ? "other.zip" : "theme.json");
const readZip = vi.spyOn(zip, "arrayBuffer");
const readOther = vi.spyOn(other, "arrayBuffer");
const fetch = reply({ pack, ignored: ["theme.json"] });
await expect(new SseTheme().importTheme(order === "loose-first" ? [other, zip] : [zip, other])).rejects.toThrow();
expect(readZip).not.toHaveBeenCalled();
expect(readOther).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it.each([{ ignored: undefined }, { ignored: ["notes.txt"] }])("preserves a single ZIP's complete canonical result (%j)", async ({ ignored }) => {
const response = ignored ? { pack, ignored } : { pack };
const fetch = reply(response);
expect(await new SseTheme().importTheme([new File(["archive fixture"], "Dusk.ZIP")])).toEqual(response);
expect(fetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ name: "Dusk.ZIP", zip: btoa("archive fixture") });
});
it("uploads every loose file and preserves the kernel's ignored-file list without reinterpretation", async () => {
const response = { pack, ignored: ["notes.txt", "notes.txt"] };
const fetch = reply(response);
const got = await new SseTheme().importTheme([
new File(["manifest fixture"], "theme.json"),
new File(["image fixture"], "preview.png"),
]);
expect(got).toEqual(response);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
files: { "theme.json": btoa("manifest fixture"), "preview.png": btoa("image fixture") },
});
});
it("propagates a single-ZIP upload failure without a successful receipt", async () => {
const failure = new Error("theme import unavailable");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(failure));
await expect(new SseTheme().importTheme([new File(["archive fixture"], "dusk.zip")])).rejects.toBe(failure);
});
@@ -1,5 +1,6 @@
import { SseNetwork } from "./sse_network";
import type { ThemeImport, ThemePack } from "./port";
import { t } from "../i18n";
// Theme packs, and where an extension's view is allowed to sit. Both are the
// user overruling a default the pack or the extension asked for.
@@ -13,6 +14,7 @@ export class SseTheme extends SseNetwork {
// JSON, not multipart: csrfGuard admits nothing else.
async importTheme(files: File[]) {
const zip = files.find((f) => f.name.toLowerCase().endsWith(".zip"));
if (zip && files.length > 1) throw new Error(t("请单独选择一个 .zip,或选择不含压缩包的 theme.json 和图片。"));
if (zip) return this.post0<ThemeImport>("/themes/import", { name: zip.name, zip: await base64Of(zip) });
const bodies: Record<string, string> = {};
for (const f of files) bodies[f.name] = await base64Of(f);
@@ -0,0 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SsePort } from "./sse";
afterEach(() => vi.unstubAllGlobals());
describe("usage range query", () => {
it("sends explicit calendar dates instead of folding them back into a day count", async () => {
let called = "";
vi.stubGlobal("fetch", async (url: string) => {
called = url;
return { ok: true, json: async () => ({}) } as Response;
});
await new SsePort().usage({ from: "2026-08-01", to: "2026-08-31" });
expect(called).toBe("/usage?from=2026-08-01&to=2026-08-31");
});
});
+9
View File
@@ -4,6 +4,15 @@
// up: a single total would have to invent an exchange rate, and the rate a turn
// was billed at is not the rate today. An empty list means the records carry no
// cost for that span, which is not the same as having cost nothing.
// A usage window is either a trailing day count or an explicit inclusive
// calendar range. Keeping them separate prevents "from" from silently riding
// on a days query and being ignored by older kernels.
export const DEFAULT_USAGE_DAYS = 30;
export type UsageQuery =
| { days: number; source?: string }
| { from: string; to: string; source?: string };
export interface Money {
amount: string;
currency: string;
@@ -51,3 +51,35 @@ export interface WorkspaceInfo {
recents: WorkspaceEntry[];
isolated?: boolean;
}
export interface CommitFile {
path: string;
// git raw status letter: "A", "M", "D", "T".
status: string;
insertions?: number;
deletions?: number;
// The name marks a file that holds secrets; its diff is never sent to a model.
sensitive?: boolean;
}
// The staged set as read, and the message the model proposed for it.
// fingerprint names that set: committing hands it back, and the kernel refuses
// an index that has since changed.
export interface CommitProposal {
message: string;
fingerprint: string;
files: CommitFile[];
truncated: boolean;
contentSecrets: boolean;
}
export interface CommitRequest {
message: string;
fingerprint: string;
acknowledgeSecrets: boolean;
}
export interface CommitResult {
hash: string;
subject: string;
}
@@ -6,6 +6,9 @@ export function nameQueued(s: SessionState, id: string, itemId: string, queued:
// Guidance read at a tool boundary can be reported before its receipt lands
// here. That report drew the delivered line, so this row is its duplicate and
// naming it would re-open a line that already arrived.
if (s.takenBack.includes(itemId)) {
return { ...s, items: s.items.filter((i) => !(i.t === "user" && i.id === id)) };
}
if (s.items.some((i) => i.t === "user" && i.itemId === itemId && i.id !== id)) {
return { ...s, items: s.items.filter((i) => !(i.t === "user" && i.id === id)) };
}
@@ -240,6 +240,11 @@ describe("a line that is still queued", () => {
expect(rows(st)[0].queued).toBe("followup");
});
it("does not name a row for an entry taken back before its receipt landed", () => {
const st = run([typed("row-1", "x"), { kind: "__unsent", id: "inbox-9" } as SessionEvent, queued("row-1", "inbox-9", "steer")]);
expect(rows(st)).toEqual([]);
});
it("takes the row away once the kernel drops it", () => {
const waiting = run([typed("row-1", "x"), queued("row-1", "inbox-9", "steer")]);
expect(rows(reduce(waiting, { kind: "__unsent", id: "row-1" } as SessionEvent))).toEqual([]);
+4 -3
View File
@@ -52,7 +52,7 @@ export const initialState: SessionState = {
waiting: {},
running: false,
doing: "空闲",
steerQueue: [], subagentPhase: {},
steerQueue: [], takenBack: [], subagentPhase: {},
awaitingTurnStart: [],
queueMoved: 0,
browserTabsMoved: 0,
@@ -198,7 +198,7 @@ export type SessionEvent =
| WireEvent
| { kind: "__restore"; items: Item[]; plan?: PlanStep[]; executions: Executions }
| { kind: "__todos"; plan: PlanStep[] }
| { kind: "__totals"; hit: number; miss: number; cost?: number; coverage?: CostCoverage; incompleteReason?: string }
| { kind: "__totals"; hit: number; miss: number; cost?: number; currency?: string; coverage?: CostCoverage; incompleteReason?: string }
| { kind: "__error"; text: string }
| { kind: "__user"; text: string; pending: boolean; id?: string }
| { kind: "__unsent"; id: string }
@@ -243,7 +243,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
return {
...s,
items: s.items.filter((i) => !(i.t === "user" && (i.id === ev.id || i.itemId === ev.id))),
steerQueue: s.steerQueue,
takenBack: [...s.takenBack, ev.id].slice(-32),
};
}
// The card stays after the fact is dropped, marked: the transcript is the
@@ -314,6 +314,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
hit: ev.hit,
miss: ev.miss,
cost: ev.cost ?? s.metrics.cost,
currency: ev.currency || s.metrics.currency,
coverage: ev.coverage ?? s.metrics.coverage,
incompleteReason: ev.coverage ? ev.incompleteReason ?? "" : s.metrics.incompleteReason,
},
@@ -219,6 +219,9 @@ export interface SessionState {
running: boolean;
doing: string;
steerQueue: string[];
// Queue ids this window gave up, so a receipt that lands after the withdrawal
// cannot name a row for an entry that no longer exists.
takenBack: string[];
// The rows whose turns have not started yet, oldest first: each send that the
// kernel has not yet named a message for. A steer never joins them, because
// it starts no turn of its own.
@@ -1397,3 +1397,18 @@ what exists.
- The reduced-motion block is deliberately the last motion rule in the file, so a
new component inherits the user's request instead of depending on a selector
written before it.
## MCP authorization state
- A server awaiting project launch approval uses a static `--warn-ink` pip.
It is an authorization decision, so it does not use the animated connecting
indicator or the failed-connection color.
## MCP rows in narrow settings groups
- Below 560px of available row width, server identity, status metadata and
actions use separate lines. Status and provenance wrap instead of disappearing
behind an ellipsis; long unbroken source strings may break within the line.
- The action group keeps its switches and connection buttons inside the row,
with room to wrap when needed. The container boundary scopes this to MCP rows,
including rows inside packages; wide server rows retain their inline layout.
+75 -7
View File
@@ -2756,6 +2756,7 @@
.srv[data-st="ready"] .pip { background: var(--ok) }
.srv[data-st="connecting"] .pip { background: var(--net); animation: tick 1.6s ease-in-out infinite }
.srv[data-st="failed"] .pip { background: var(--err) }
.srv[data-st="pending"] .pip { background: var(--warn-ink) }
.srv[data-st="disabled"] .pip { background: var(--ghost) }
.srv[data-st="disabled"] .nm, .srv[data-st="disabled"] .meta { color: var(--faint) }
@@ -2766,6 +2767,19 @@
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.srv .acts { flex: none }
.mcp-server { container: mcp-server / inline-size }
@container mcp-server (max-width: 560px) {
.srv > summary, .srv > .srv-hd { flex-wrap: wrap; row-gap: 8px }
.srv > summary > .nm, .srv > .srv-hd > .nm { flex: 1; min-width: 0 }
.srv > summary > .meta, .srv > .srv-hd > .meta {
order: 1; flex: 1 0 calc(100% - 15px); margin-left: 15px;
overflow: visible; overflow-wrap: anywhere; white-space: normal;
}
.srv > summary > .exc, .srv > .srv-hd > .exc { order: 2; margin-left: 15px }
.srv > summary > .acts, .srv > .srv-hd > .acts {
order: 2; margin-left: auto; max-width: 100%; flex-wrap: wrap; justify-content: flex-end;
}
}
.srv .why { margin: 4px 0 0 15px; font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--err) }
.srv .why[role="status"] { color: var(--muted) }
.srv .peek { margin: 7px 0 1px 15px }
@@ -3038,6 +3052,8 @@
.mevidence > i { width: 5px; height: 5px; margin-left: 2px; border-radius: 50%; background: var(--ghost) }
.mevidence > i[data-state="available"] { background: var(--ok) }
.mevidence > i[data-state="unavailable"] { background: var(--err) }
.mevidence > .mhttp { font-family: var(--mono) }
.mevidence > .mdetail { flex-basis: 100%; font-family: var(--mono); overflow-wrap: anywhere }
.mevidence > i[data-state="checking"] { background: var(--net) }
.mops { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px }
.mline .mverify {
@@ -5389,12 +5405,31 @@
.uranges { display: flex; gap: 3px; padding: 3px; background: var(--overlay); border-radius: var(--r-pill) }
.uranges button {
padding: 5px 13px; border: 0; background: none; cursor: pointer; border-radius: var(--r-pill);
flex: none; white-space: nowrap;
font: var(--w-med) var(--fs-ui) var(--ui); color: var(--muted);
transition: color var(--t-line) var(--ease), background var(--t-line) var(--ease);
}
.uranges button:hover { color: var(--text) }
.uranges button[aria-pressed="true"] { background: var(--raised); color: var(--text) }
.uspan { color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--mono) }
.udates {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; gap: 10px;
align-items: end; padding: 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--overlay);
}
.udates label, .umonth {
display: flex; flex-direction: column; gap: 5px;
color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui);
}
.udates input, .umonth input {
min-width: 0; padding: 7px 9px; border: 1px solid var(--border); border-radius: var(--r-sm);
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--mono); color-scheme: light dark;
}
.udates button, .umonth button {
min-height: 34px; padding: 7px 14px; border: 1px solid var(--border); border-radius: var(--r-sm);
background: var(--raised); color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); cursor: pointer;
}
.udates button:disabled { color: var(--faint); cursor: default }
.umonth { max-width: 260px; margin-top: -4px }
.uhero { padding: 22px 24px; background: var(--raised); border: 1px solid var(--border); border-radius: var(--r-md) }
.uhero-k { color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); letter-spacing: var(--track-lead); text-transform: uppercase }
@@ -5404,7 +5439,7 @@
padding: 4px 10px; border-radius: var(--r-pill); background: var(--overlay);
color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui);
}
.uhero-note { margin: 11px 0 0; max-width: 62ch; color: var(--fg-2, var(--muted)); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) }
.uhero-note { margin: 11px 0 0; max-width: 62ch; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) }
.utiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); gap: 12px }
.utile { padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md) }
@@ -5412,7 +5447,7 @@
.utile .v { margin-top: 6px; font: var(--w-med) var(--fs-display)/1.1 var(--mono); font-variant-numeric: tabular-nums; letter-spacing: var(--track-tight) }
.utile .m { margin-top: 4px; color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--ui) }
.ucard { padding: 16px 18px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md) }
.ucard { min-width: 0; padding: 16px 18px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md) }
.uhero, .utile, .ucard, .storage .item {
transition: border-color var(--t-card) var(--ease-out), box-shadow var(--t-card) var(--ease-out);
}
@@ -5422,13 +5457,13 @@
box-shadow: var(--lit), 0 16px 34px -30px color-mix(in srgb, var(--net) 30%, transparent);
}
}
.ucard-h { display: flex; align-items: baseline; justify-content: space-between; gap: 10px }
.ucard-h { display: flex; align-items: baseline; justify-content: space-between; gap: 4px 10px; flex-wrap: wrap }
.ucard-h h3 { margin: 0; font: var(--w-med) var(--fs-ui) var(--ui) }
.ucard-h .hint { color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui) }
.ucard-s { margin: 2px 0 12px; color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui) }
.utwo { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px }
.unote { margin-top: 12px; padding: 10px 12px; background: var(--overlay); border-radius: var(--r-sm);
color: var(--fg-2, var(--muted)); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui) }
color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui) }
.uplot { position: relative; margin-top: 8px }
.uplot svg { display: block; width: 100%; overflow: visible }
@@ -5447,15 +5482,48 @@
.utip .d { color: var(--muted); font: var(--fs-meta) var(--mono) }
.utip .v { margin-top: 1px; font: var(--w-med) var(--fs-ui) var(--mono); font-variant-numeric: tabular-nums }
.udaily { padding-top: 14px }
.utable-wrap { max-height: 320px; margin-top: 8px; overflow: auto; border: 1px solid var(--border); border-radius: var(--r-sm) }
.utable { width: 100%; min-width: 460px; border-collapse: collapse; font: var(--w-reg) var(--fs-meta) var(--mono); font-variant-numeric: tabular-nums }
.utable th, .utable td { padding: 8px 10px; border-bottom: 1px solid var(--hair); text-align: right; white-space: nowrap }
.utable th:first-child, .utable td:first-child {
position: sticky; left: 0; z-index: 2; background: var(--surface);
box-shadow: 1px 0 0 var(--hair); text-align: left;
}
.utable th:first-child { z-index: 3 }
.utable th { position: sticky; top: 0; z-index: 1; background: var(--surface); color: var(--muted); font-weight: var(--w-med) }
.utable tr:last-child td { border-bottom: 0 }
.utable td:not(:first-child) { color: var(--muted) }
.utable .ud { color: var(--text) }
.utable-scroll-hint { display: none }
.utable-wrap:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
.urows, .urow, .urow-t { min-width: 0 }
.urows { display: flex; flex-direction: column; gap: 10px; margin-top: 8px }
.urow-t { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px }
.urow-t .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--fs-ui) var(--mono) }
.urow-t .v { flex: none; color: var(--muted); font: var(--fs-ui) var(--mono); font-variant-numeric: tabular-nums }
.urow-t .n { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--fs-ui) var(--mono) }
.urow-t .v { flex: none; white-space: nowrap; color: var(--muted); font: var(--fs-ui) var(--mono); font-variant-numeric: tabular-nums }
.utrack { height: 6px; background: var(--hair); border-radius: var(--r-pill); overflow: hidden }
.ufill { display: block; height: 100%; background: var(--chart); border-radius: var(--r-pill);
.ufill { display: block; height: 100%; background: var(--row-color, var(--chart)); border-radius: var(--r-pill);
transition: width var(--t-live) var(--ease-spring) }
@media (max-width: 560px) {
.uhead { align-items: stretch }
.uranges { width: 100%; overflow-x: auto }
.uspan { width: 100%; text-align: center }
.uhero { padding: 18px 16px }
.uhero-v { font-size: 30px; overflow-wrap: anywhere }
.utiles { grid-template-columns: repeat(2, minmax(0, 1fr)) }
.ucard { padding: 14px 12px 10px }
.utwo { grid-template-columns: minmax(0, 1fr) }
.udates { grid-template-columns: 1fr }
.udates button { width: 100% }
.umonth { max-width: none }
.utable th, .utable td { padding: 7px 8px }
.utable-scroll-hint { display: inline }
.utable-scroll-hint::before { content: " · " }
}
.memedit { display: flex; flex-direction: column; gap: 10px }
.memedit label { display: flex; flex-direction: column; gap: 4px;
color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); letter-spacing: var(--track-lead) }
+44 -1
View File
@@ -1416,6 +1416,8 @@ body { background: var(--page) !important; }
.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 .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%; }
.workbench-changes .commit-card { margin: 6px 10px 8px; }
.workbench-change-row {
width: 100%; min-height: 26px; padding: 0 10px 0 8px; display: flex; align-items: center; gap: 7px;
border: 0; background: none; color: var(--muted); font: 450 11.5px/1.35 var(--ui);
@@ -4041,7 +4043,48 @@ body { font-size: 14px; line-height: 1.75; }
.refine-wait { margin: 0; color: var(--faint); font: 400 12px/1.5 var(--ui); }
.refine-error { margin: 0; color: var(--err); font: 400 12px/1.5 var(--ui); }
.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; }
:root[data-theme="light"] .refine-card { background: var(--page); }
.commit-card { display: grid; gap: 8px; margin: 8px 0 0; }
.commit-open { margin-left: auto; }
.workbench-explorer-head .commit-open { height: 20px; padding: 0 8px; font-size: 11px; }
.commit-files li i { font-style: normal; }
.commit-files li i[data-io="up"] { color: var(--ok); }
.commit-files li i[data-io="down"] { color: var(--err); }
.commit-card:not([data-phase="idle"]) {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--hair);
}
.commit-card header { display: flex; align-items: center; gap: 6px; color: var(--muted); font: 600 11px/1.4 var(--ui); }
.commit-card header b { color: var(--text); font-weight: 600; }
.commit-card header small { margin-left: auto; }
.commit-card small { color: var(--ghost); font: 400 10px/1.4 var(--ui); }
.commit-card footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.commit-done { margin: 0; color: var(--text); font: 400 12px/1.5 var(--ui); overflow-wrap: anywhere; }
.commit-files { display: grid; gap: 2px; max-height: 120px; margin: 0; padding: 0; overflow: auto; list-style: none; font: 400 11px/1.5 var(--mono); }
.commit-files li { display: flex; gap: 6px; min-width: 0; color: var(--muted); }
.commit-path { display: flex; flex: 1; min-width: 0; }
.commit-path .commit-dir { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; }
.commit-path .commit-dir::after { content: "\200E"; }
.commit-path .commit-base { flex: none; white-space: nowrap; }
.commit-files li[data-sensitive] { color: var(--err); }
.commit-warn { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--err); border-radius: 8px; color: var(--err); font: 400 11px/1.5 var(--ui); }
.commit-warn p { margin: 0; overflow-wrap: anywhere; }
.commit-warn label { display: flex; align-items: center; gap: 6px; color: var(--text); }
.commit-message {
width: 100%;
box-sizing: border-box;
resize: vertical;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--page);
color: var(--text);
font: 400 12px/1.5 var(--mono);
}
:root[data-theme="light"] .refine-card,
:root[data-theme="light"] .commit-card:not([data-phase="idle"]) { background: var(--page); }
:root[data-theme="light"] .commit-message { background: var(--bg); }
@media (hover: none) {
.flow .user-acts,
.wsdel,
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
const RAW = import.meta.glob(["./app.css", "./tokens.css", "./TOKENS.md"], {
query: "?raw", import: "default", eager: true,
}) as Record<string, string>;
const SHEET = RAW["./app.css"];
const TOKENS = RAW["./tokens.css"];
const TOKEN_DOC = RAW["./TOKENS.md"];
type Rgb = [number, number, number];
function linearize(value: number): number {
const c = value / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function encode(value: number): number {
const c = Math.max(0, Math.min(1, value));
return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
}
function hexToRgb(hex: string): Rgb {
return [1, 3, 5].map((at) => Number.parseInt(hex.slice(at, at + 2), 16)) as Rgb;
}
function relativeLuminance([r, g, b]: Rgb): number {
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}
function contrast(a: Rgb, b: Rgb): number {
const x = relativeLuminance(a), y = relativeLuminance(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
function oklchToRgb(lightness: number, chroma: number, hue: number): Rgb {
const angle = hue * Math.PI / 180;
const a = chroma * Math.cos(angle), b = chroma * Math.sin(angle);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3;
return [
encode(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
encode(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
encode(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
];
}
function hexToOklch(hex: string): { l: number; c: number } {
const [r, g, b] = hexToRgb(hex).map(linearize);
const l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3);
const m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3);
const s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3);
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
return { l: L, c: Math.hypot(a, b2) };
}
function minSourceContrast(chart: string, track: Rgb): number {
const { l, c } = hexToOklch(chart);
let minimum = Infinity;
for (let hue = 0; hue < 360; hue++) minimum = Math.min(minimum, contrast(oklchToRgb(l, c, hue), track));
return minimum;
}
describe("usage remains readable on a phone", () => {
it("derives source colours from chart tokens that clear 3:1 in both themes", () => {
const lightChart = SHEET.match(/\.usage\s*\{[^}]*--chart:\s*(#[0-9a-f]{6})/i)?.[1] ?? "";
const darkChart = SHEET.match(/\[data-theme="dark"\]\s*\.usage\s*\{[^}]*--chart:\s*(#[0-9a-f]{6})/i)?.[1] ?? "";
expect(lightChart).toBeTruthy();
expect(darkChart).toBeTruthy();
expect(TOKENS).toContain("--hair: oklch(91.7% .0126 85)");
expect(TOKENS).toContain("--hair: oklch(24.2% .016 255)");
expect(minSourceContrast(lightChart, oklchToRgb(0.917, 0.0126, 85))).toBeGreaterThanOrEqual(3.5);
expect(minSourceContrast(darkChart, oklchToRgb(0.242, 0.016, 255))).toBeGreaterThanOrEqual(4.5);
});
it("lets an explicit source colour override the shared chart hue", () => {
expect(SHEET).toMatch(/\.ufill\s*\{[^}]*background:\s*var\(--row-color,\s*var\(--chart\)\)/);
});
it("uses --muted instead of an undefined --fg-2 token", () => {
expect(SHEET).not.toContain("--fg-2");
expect(TOKENS).not.toContain("--fg-2");
expect(TOKEN_DOC).not.toContain("--fg-2");
expect(SHEET).toMatch(/\.utable td:not\(:first-child\)\s*\{\s*color:\s*var\(--muted\)/);
});
it("keeps the full daily table in a bounded, hinted scroll region", () => {
const rule = SHEET.match(/\.utable-wrap\s*\{([^}]*)\}/)?.[1] ?? "";
expect(rule).toMatch(/max-height:\s*\d+px/);
expect(rule).toMatch(/overflow:\s*auto/);
const pinned = SHEET.match(/\.utable th:first-child,\s*\.utable td:first-child\s*\{([^}]*)\}/)?.[1] ?? "";
expect(pinned).toMatch(/position:\s*sticky/);
expect(pinned).toMatch(/left:\s*0/);
expect(SHEET).toMatch(/\.utable-scroll-hint\s*\{\s*display:\s*none/);
expect(SHEET).toMatch(/@media\s*\(max-width:\s*560px\)[\s\S]*?\.utable-scroll-hint\s*\{\s*display:\s*inline/);
});
it("stacks cards and keeps range labels intact on a phone", () => {
expect(SHEET).toMatch(/\.uranges button\s*\{[^}]*white-space:\s*nowrap/);
expect(SHEET).toMatch(/@media\s*\(max-width:\s*560px\)[\s\S]*?\.utwo\s*\{\s*grid-template-columns:\s*minmax\(0,\s*1fr\)/);
expect(SHEET).toMatch(/\.ucard\s*\{[^}]*min-width:\s*0/);
expect(SHEET).toMatch(/\.urows,\s*\.urow,\s*\.urow-t\s*\{\s*min-width:\s*0/);
expect(SHEET).toMatch(/\.urow-t\s+\.n\s*\{[^}]*min-width:\s*0[^}]*text-overflow:\s*ellipsis/);
expect(SHEET).toMatch(/\.urow-t\s+\.v\s*\{[^}]*flex:\s*none[^}]*white-space:\s*nowrap/);
});
it("stacks the custom date fields before they squeeze the inputs", () => {
expect(SHEET).toMatch(/@media\s*\(max-width:\s*560px\)[\s\S]*?\.udates\s*\{[^}]*grid-template-columns:\s*1fr/);
});
});
@@ -191,3 +191,26 @@ it("normalizes spaces around an ASCII name and refuses a blank one", async () =>
name: "company-relay.1_x",
})));
});
it("saves the address the probe resolved, not the bare host that was typed", async () => {
const saveProvider = vi.fn(async () => {});
const probeProvider = vi.fn(async () => ({
kind: "openai", kinds: ["openai"], baseUrl: "https://relay.example/v1", authHeader: false,
models: ["model-x"], default: "model-x", efforts: [], effort: "", vision: [], ambiguous: false, noProxy: false,
}));
const port = {
protocols: vi.fn(async () => [{ kind: "openai", discovery: "openai", serverWebSearch: false, reasoningParams: true }]),
saveProvider,
probeProvider,
} as unknown as Port;
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example");
await userEvent.type(screen.getByLabelText("API Key"), "k");
await userEvent.click(screen.getByRole("button", { name: /验证连接并读取/ }));
expect(await screen.findByText("接口地址已补全为 https://relay.example/v1")).toBeTruthy();
expect((screen.getByLabelText("接口地址") as HTMLInputElement).value).toBe("https://relay.example/v1");
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
await waitFor(() => expect(saveProvider).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: "https://relay.example/v1" })));
});
+8 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { Protocol, ProviderEntry, ProviderProbe } from "../port/port";
import { clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
import { checkedFact, clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
import { KIND_LABEL, hostOf, nameFrom, sourceNameUsable, vendorLabel } from "./vendors";
import type { Port } from "./Providers";
import { reason } from "../i18n/kernel";
@@ -19,6 +19,7 @@ export function AddProvider({
port: Port; taken: string[]; known: ProviderEntry[]; onDone: () => void; onCancel: () => void;
}) {
const [baseUrl, setBaseUrl] = useState("");
const [completed, setCompleted] = useState("");
const [apiKey, setApiKey] = useState("");
const [probe, setProbe] = useState<ProviderProbe | null>(null);
const [catalog, setCatalog] = useState<Protocol[]>([]);
@@ -77,6 +78,10 @@ export function AddProvider({
try {
const got = await port.probeProvider(baseUrl.trim(), apiKey.trim());
setProbe(got);
const resolved = got.baseUrl?.trim() ?? "";
const changed = resolved !== "" && resolved !== baseUrl.trim();
if (changed) setBaseUrl(resolved);
setCompleted(changed ? resolved : "");
setKind((current) => current || got.kind);
setModels((current) => [...new Set([...got.models, ...current])]);
setPicked((current) => current.length ? current : got.models.slice(0, 8));
@@ -151,7 +156,7 @@ export function AddProvider({
});
setFacts((current) => ({
...current,
[model]: { ...(current[model] ?? { origin: "manual" }), status: got.status, reason: got.reason },
[model]: checkedFact(current[model], "manual", got),
}));
} catch {
setFacts((current) => ({
@@ -333,6 +338,7 @@ export function AddProvider({
)}
</div>
{probe && <p className="probe-ok">{t("连接可用 · 找到 {n} 个模型", { n: probe.models.length })}</p>}
{completed !== "" && completed === baseUrl.trim() && <p className="probe-ok">{t("接口地址已补全为 {url}", { url: completed })}</p>}
<div className="mlist">
<div className="mlhead">
<span className="ttl">{t("启用的模型")}</span>
+1 -1
View File
@@ -137,7 +137,7 @@ function ServerInput({ port, canProject, onClose, onInstalled }: Props) {
.map((k) => (
<div className="risk" key={k.field} data-kind={k.kind}>
<span className="lb">{t(KIND_LABEL[k.kind] ?? k.kind)}</span>
<span className="dt">{k.kind === "secret" ? k.field.split(".").pop() : k.detail}</span>
<span className="dt">{k.kind === "secret" ? k.field.split(".").pop() : k.kind === "unknown-host" ? (s.displayUrl ?? "<redacted>") : [s.displayCommand ?? "<redacted>", ...(s.displayArgs ?? [])].join(" ")}</span>
{k.kind === "secret" && <span className="why">{k.detail}</span>}
</div>
))}
+17 -18
View File
@@ -16,6 +16,7 @@ import { useFoldAway } from "./foldaway";
import { useDrawerCloses } from "./drawer";
import { RemoteAsk } from "./RemoteAsk";
import { Feedback, type FeedbackTab } from "./Feedback";
import { usePaneForSheets } from "./usePaneForSheets";
import { BrowserLogin } from "./BrowserLogin";
import type { RemoteAsk as RemoteAskT, RemoteHost } from "../port/remote";
import { Boundary } from "./Boundary";
@@ -489,27 +490,25 @@ export function App({ hub }: { hub: HubPort }) {
// the window's, not whichever project happens to sit first in the tree.
const newSessionRoot = activeWorkspace?.root;
// Every settings route is a runtime's, so with no pane open there is nothing
// to read them from. Asking for settings then opens a session in the current
// folder, as the empty state's own button would, and the sheet follows it.
const prefsPane = useRef(false);
useEffect(() => {
if (!settings || !panesRead || runtimes.length > 0) {
prefsPane.current = false;
return;
}
if (prefsPane.current) return;
prefsPane.current = true;
if (!newSessionRoot) {
usePaneForSheets({
settings: !!settings,
feedback: feedback !== null,
panesRead,
panes: runtimes.length,
root: newSessionRoot,
openPane,
refuse: (message) => {
setSettings(false);
setError(t("设置需要一个打开的会话。请先在左栏添加一个文件夹。"));
return;
}
void openPane({ root: newSessionRoot }).catch((e) => {
setFeedback(null);
setError(message);
},
fail: (e) => {
setSettings(false);
setFeedback(null);
fail(e);
});
}, [settings, panesRead, runtimes.length, newSessionRoot, openPane, fail]);
},
clear: (message) => setError((cur) => (cur === message ? "" : cur)),
});
// The tab strip has nowhere to await: closing is the end of the gesture there,
// so a refusal has to land in the error bar rather than in a caller.
@@ -35,6 +35,32 @@ describe("cloud backup", () => {
expect(req.passphrase).toBe("a long passphrase");
});
it("says why the button is held back while the passphrase is short or unconfirmed", async () => {
const port = new MockPort() as unknown as AgentPort;
render(<Backup port={port} />);
const btn = await screen.findByRole<HTMLButtonElement>("button", { name: "备份到账号" });
const [pass, again] = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
expect(btn.disabled).toBe(true);
expect(document.querySelector('[data-action="backup.wait"]')!.textContent).toBe("口令还差 10 个字符");
await userEvent.type(pass!, "abcd");
const why = document.querySelector('[data-action="backup.wait"]')!;
expect(why.textContent).toBe("口令还差 6 个字符");
expect(btn.getAttribute("aria-describedby")).toBe(why.id);
await userEvent.type(pass!, "efghij");
expect(document.querySelector('[data-action="backup.wait"]')!.textContent).toBe("请再输一次口令");
await userEvent.type(again!, "abcdefghij");
expect(document.querySelector('[data-action="backup.wait"]')).toBeNull();
expect(btn.disabled).toBe(false);
});
it("asks for a category when every box is unticked", async () => {
const port = new MockPort() as unknown as AgentPort;
render(<Backup port={port} />);
await screen.findByRole("button", { name: "备份到账号" });
for (const box of screen.getAllByRole<HTMLInputElement>("checkbox")) if (box.checked) await userEvent.click(box);
expect(document.querySelector('[data-action="backup.wait"]')!.textContent).toBe("至少选择一项备份内容");
});
it("holds a command back until its own consent box is ticked, and sends that consent", async () => {
const port = new MockPort() as unknown as AgentPort;
const apply = vi.spyOn(port, "applyBackup");
+14 -1
View File
@@ -52,6 +52,14 @@ export function Backup({ port }: { port: AgentPort }) {
const min = catalog?.minPassphrase ?? 10;
const mismatch = again !== "" && pass !== again;
const ready = chosen.size > 0 && pass.length >= min && pass === again && !busy;
const wait =
chosen.size === 0
? t("至少选择一项备份内容")
: pass.length < min
? t("口令还差 {n} 个字符", { n: min - pass.length })
: again === ""
? t("请再输一次口令")
: "";
const create = async () => {
setBusy(true);
@@ -124,9 +132,14 @@ export function Backup({ port }: { port: AgentPort }) {
{mismatch && <p className="acct-note" data-err="">{t("两次输入的口令不一致")}</p>}
<div className="bk-acts">
<button className="act" data-primary data-action="backup.create" disabled={!ready} onClick={() => void create()}>
<button className="act" data-primary data-action="backup.create" disabled={!ready} aria-describedby={!ready && wait ? "bk-wait" : undefined} onClick={() => void create()}>
{t(busy ? "正在加密上传…" : "备份到账号")}
</button>
{!ready && !busy && wait && (
<span id="bk-wait" className="acct-note" aria-live="polite" data-action="backup.wait">
{wait}
</span>
)}
<span className="acct-note">
{t("已用 {n}/{max} 份", { n: catalog.backups.length, max: catalog.limits.maxCount })}
</span>
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from "react";
import type { AgentPort, CommitProposal, CommitResult } from "../port/port";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
type Phase =
| { phase: "idle" }
| { phase: "drafting" }
| { phase: "ready"; proposal: CommitProposal; message: string; ack: boolean; committing: boolean; error?: string }
| { phase: "done"; result: CommitResult }
| { phase: "failed"; error: string };
// A proposal, not an action: the model drafts for what is staged, the person
// edits, and only the confirm button records a local commit. Nothing here stages
// a file or pushes.
export function useCommitCard(port: AgentPort, onCommitted: () => void) {
const [state, setState] = useState<Phase>({ phase: "idle" });
const pending = useRef<AbortController | null>(null);
useEffect(() => () => pending.current?.abort(), []);
const draft = () => {
pending.current?.abort();
const ac = new AbortController();
pending.current = ac;
setState({ phase: "drafting" });
port.proposeCommit(ac.signal).then(
(proposal) =>
!ac.signal.aborted && setState({ phase: "ready", proposal, message: proposal.message, ack: false, committing: false }),
(e) => !ac.signal.aborted && setState({ phase: "failed", error: reason(e) }),
);
};
const close = () => {
pending.current?.abort();
pending.current = null;
setState({ phase: "idle" });
};
const button = (
<button type="button" className="btn sm commit-open" data-action="commit.draft" disabled={state.phase !== "idle"} onClick={draft}>
{t("提交…")}
</button>
);
if (state.phase === "idle") return { button, card: null };
if (state.phase === "drafting") {
return {
button,
card: (
<section className="commit-card" data-phase="drafting" aria-label={t("提交说明")}>
<p className="refine-wait" role="status">{t("读取暂存区,起草提交说明…")}</p>
<footer>
<button type="button" className="btn sm" data-weak="" data-action="commit.close" onClick={close}>{t("取消")}</button>
</footer>
</section>
),
};
}
if (state.phase === "failed") {
return {
button,
card: (
<section className="commit-card" data-phase="failed" aria-label={t("提交说明")}>
<p className="refine-error" role="alert">{state.error}</p>
<footer>
<button type="button" className="btn sm" data-action="commit.draft" onClick={draft}>{t("起草提交说明")}</button>
<button type="button" className="btn sm" data-weak="" data-action="commit.close" onClick={close}>{t("取消")}</button>
</footer>
</section>
),
};
}
if (state.phase === "done") {
return {
button,
card: (
<section className="commit-card" data-phase="done" aria-label={t("提交说明")}>
<p className="commit-done" role="status">
{t("已提交 {hash}:{subject}", { hash: state.result.hash.slice(0, 7), subject: state.result.subject })}
</p>
<small>{t("只在本地提交,不会推送")}</small>
<footer>
<button type="button" className="btn sm" data-weak="" data-action="commit.close" onClick={close}>{t("收起")}</button>
</footer>
</section>
),
};
}
const { proposal, message, ack, committing, error } = state;
const sensitive = proposal.files.filter((f) => f.sensitive).map((f) => f.path);
const warned = sensitive.length > 0 || proposal.contentSecrets;
const set = (patch: Partial<typeof state>) => setState({ ...state, ...patch });
const confirm = () => {
set({ committing: true, error: undefined });
port
.commitStaged({ message, fingerprint: proposal.fingerprint, acknowledgeSecrets: ack })
.then(
(result) => {
setState({ phase: "done", result });
onCommitted();
},
(e) => setState({ ...state, committing: false, error: reason(e) }),
);
};
return {
button,
card: (
<section className="commit-card" data-phase="ready" aria-label={t("提交说明")}>
<header>
<b>{t("暂存的文件")}</b>
<small>{t("{n} 个文件已暂存", { n: proposal.files.length })}</small>
</header>
<ul className="commit-files">
{proposal.files.map((f) => (
<li key={f.path} data-sensitive={f.sensitive ? "" : undefined} title={f.path}>
<em className="st" data-s={f.status}>{f.status}</em>
<CommitPath path={f.path} />
{f.insertions !== undefined && <i data-io="up">+{f.insertions}</i>}
{f.deletions !== undefined && <i data-io="down">-{f.deletions}</i>}
</li>
))}
</ul>
{proposal.truncated && <small>{t("暂存区内容过长,只按前面一部分起草")}</small>}
{warned && (
<div className="commit-warn" role="alert">
{sensitive.length > 0 && <p>{t("可能含有密钥:{files}", { files: sensitive.join(", ") })}</p>}
{proposal.contentSecrets && <p>{t("新增的内容里有形似密钥的值")}</p>}
<label>
<input type="checkbox" data-action="commit.acknowledge" checked={ack} disabled={committing} onChange={(e) => set({ ack: e.target.checked })} />
{t("我确认这些内容可以提交")}
</label>
</div>
)}
<textarea
className="commit-message"
data-action="commit.edit"
aria-label={t("提交说明")}
value={message}
rows={6}
spellCheck={false}
disabled={committing}
onChange={(e) => set({ message: e.target.value })}
/>
{error && <p className="refine-error" role="alert">{error}</p>}
<small>{t("只在本地提交,不会推送")}</small>
<footer>
<button
type="button"
className="btn sm"
data-primary
data-action="commit.confirm"
disabled={committing || !message.trim() || (warned && !ack)}
onClick={confirm}
>
{committing ? t("正在提交…") : t("提交到本地")}
</button>
<button type="button" className="btn sm" data-action="commit.draft" disabled={committing} onClick={draft}>
{t("重新起草")}
</button>
<button type="button" className="btn sm" data-weak="" data-action="commit.close" disabled={committing} onClick={close}>
{t("取消")}
</button>
</footer>
</section>
),
};
}
// Only the directory gives way when the pane is narrow, from its left, so its tail
// and the whole file name stay visible. The directory is rtl for that ellipsis side.
function CommitPath({ path }: { path: string }) {
const cut = path.lastIndexOf("/") + 1;
return (
<span className="commit-path">
{cut > 0 && <span className="commit-dir">{path.slice(0, cut)}</span>}
<span className="commit-base">{path.slice(cut)}</span>
</span>
);
}
@@ -534,7 +534,8 @@ describe("a line taken back from the queue", () => {
it("restores the same text twice when it is taken back twice", () => {
const p = props();
const view = render(<Composer {...p} restore={{ n: 1, text: "甲" }} />);
const view = render(<Composer {...p} />);
view.rerender(<Composer {...p} restore={{ n: 1, text: "甲" }} />);
const box = view.container.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(box, { target: { value: "", selectionStart: 0 } });
view.rerender(<Composer {...p} restore={{ n: 2, text: "甲" }} />);
+8 -4
View File
@@ -5,7 +5,7 @@ import type { AgentPort, ChipCall, ModelEntry, SessionStatus, Attachment } from
import { Picker } from "./Menu";
import { Policy } from "./Policy";
import { modelMenu } from "./modelmenu";
import { effortMenu, effortReading, effortsFor, routeEffortPick } from "./effort";
import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
import { CompletionMenu, useCompletion } from "./Completion";
import { ChipMirror, useSkillChips } from "./ChipMirror";
import { useIme } from "./ime";
@@ -154,10 +154,13 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
queueMicrotask(() => box.current?.focus());
}, [quote?.n]);
const restoredAt = useRef(restore?.n ?? 0);
useEffect(() => {
if (!restore?.n) return;
if (!restore?.n || restore.n === restoredAt.current) return;
restoredAt.current = restore.n;
setText((prev) => {
const next = prev.trim() ? `${prev.replace(/\s+$/, "")}\n${restore.text}` : restore.text;
const next = !prev.trim() || prev.trim() === restore.text.trim()
? restore.text : `${prev.replace(/\s+$/, "")}\n${restore.text}`;
pending.current = next.length;
return next;
});
@@ -375,6 +378,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
// may still carry one from a model that did, and printing that would be the
// composer answering for an endpoint that never spoke.
const declared = efforts.length > 0;
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
const modelLb = status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—";
// 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
@@ -727,7 +731,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
title={t("推理强度")}
current={declared ? status.effort || "auto" : ""}
pending={busy["effort"] || busy["mode"]}
items={effortMenu(efforts, modelLb, "__effort-declare", status.modes)}
items={effortMenu(efforts, modelLb, "__effort-declare", status.modes, forcedThinking)}
onPick={(value) => routeEffortPick(value, status.modes, {
declare: () => onSettings("providers:effort-declare"),
effort: (level) => change("effort", () => port.setEffort(level)),
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { DisplayCurrency } from "./DisplayCurrency";
import type { AgentPort } from "../port/port";
import type { DisplayCurrencyMode, DisplayCurrencySettings } from "../port/boundary";
afterEach(cleanup);
const base: DisplayCurrencySettings = { mode: "auto", path: "/u/config.toml" };
function portWith(save: (m: DisplayCurrencyMode) => Promise<DisplayCurrencySettings>) {
return { displayCurrency: vi.fn(async () => base), saveDisplayCurrency: vi.fn(save) } as unknown as AgentPort & { saveDisplayCurrency: ReturnType<typeof vi.fn> };
}
describe("the display currency setting", () => {
it("offers three modes, marks the stored one, and writes a change once", async () => {
const port = portWith(async (mode) => ({ ...base, mode }));
const changed = vi.fn();
render(<DisplayCurrency port={port} onChanged={changed} />);
const auto = await screen.findByRole("button", { name: "自动" });
expect(auto.getAttribute("aria-pressed")).toBe("true");
const usd = screen.getByRole("button", { name: "美元 USD" });
await userEvent.click(usd);
expect(port.saveDisplayCurrency).toHaveBeenCalledTimes(1);
expect(port.saveDisplayCurrency).toHaveBeenCalledWith("USD");
await waitFor(() => expect(usd.getAttribute("aria-pressed")).toBe("true"));
expect(auto.getAttribute("aria-pressed")).toBe("false");
expect(changed).toHaveBeenCalledTimes(1);
});
it("does not write when the chosen mode is already stored", async () => {
const port = portWith(async (mode) => ({ ...base, mode }));
render(<DisplayCurrency port={port} onChanged={() => {}} />);
await userEvent.click(await screen.findByRole("button", { name: "自动" }));
expect(port.saveDisplayCurrency).not.toHaveBeenCalled();
});
it("keeps the old choice and says why when the kernel refuses", async () => {
const port = portWith(async () => {
throw new Error("boom");
});
const changed = vi.fn();
render(<DisplayCurrency port={port} onChanged={changed} />);
await userEvent.click(await screen.findByRole("button", { name: "人民币 CNY" }));
await waitFor(() => expect(document.querySelector(".why")).toBeTruthy());
const pressed = screen.getAllByRole("button").filter((b) => b.getAttribute("aria-pressed") === "true");
expect(pressed.map((b) => b.textContent)).toEqual(["自动"]);
expect(changed).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort } from "../port/port";
import type { DisplayCurrencyMode, DisplayCurrencySettings } from "../port/boundary";
import { Group } from "./Group";
const MODES: [DisplayCurrencyMode, string][] = [
["auto", "自动"],
["CNY", "人民币 CNY"],
["USD", "美元 USD"],
];
export function DisplayCurrency({ port, onChanged }: { port: AgentPort; onChanged: () => void }) {
const [state, setState] = useState<DisplayCurrencySettings | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
port.displayCurrency().then(setState).catch(() => setState(null));
}, [port]);
const pick = async (mode: DisplayCurrencyMode) => {
if (!state || mode === state.mode) return;
setBusy(true);
setError("");
try {
setState(await port.saveDisplayCurrency(mode));
onChanged();
} catch (e) {
setError(reason(e));
} finally {
setBusy(false);
}
};
return (
<Group id="currency" title={t("费用显示币种")}
hint={t("只决定费用以哪种货币显示,不改动各来源的价目表。「自动」跟随账户余额的币种,没有余额信息时按价目表的币种显示。")}>
{!state ? (
<div className="empty">{t("无法读取币种设置。")}</div>
) : (
<div className="box">
<div className="lrow">
<span className="tx">
<span className="lb">{t("显示币种")}</span>
<span className="ds">{t("当前会话的费用、余额与用量页立即按所选币种显示,无需重启。")}</span>
</span>
<div className="seg" data-text role="group" aria-label={t("显示币种")}>
{MODES.map(([m, label]) => (
<button key={m} data-action="currency.mode" data-value={m} aria-pressed={state.mode === m} disabled={busy} onClick={() => void pick(m)}>
{t(label)}
</button>
))}
</div>
</div>
{error && <div className="why">{error}</div>}
</div>
)}
</Group>
);
}
@@ -84,6 +84,35 @@ it("keeps an exact unlisted model id and verifies it independently of the catalo
}));
});
it("shows the HTTP status and the endpoint's own words when a model check is refused", async () => {
const checkProviderModel = vi.fn(async () => ({
model: "clef:27b",
status: "unknown" as const,
reason: "rejected" as const,
httpStatus: 400,
detail: "clef:27b does not support tools",
}));
const port = { checkProviderModel } as unknown as Port;
const entry: ProviderEntry = {
name: "ollama_local",
kind: "openai",
baseUrl: "http://localhost:11434/v1",
models: ["clef:27b"],
default: "clef:27b",
hasKey: false,
inUse: false,
preset: false,
};
render(<EditConn entry={entry} port={port} busy="" setBusy={() => {}} onDone={() => {}} />);
const row = screen.getAllByText("clef:27b").map((el) => el.closest(".mline")).find(Boolean) as HTMLElement;
await userEvent.click(within(row).getByRole("button", { name: "验证模型 clef:27b" }));
await waitFor(() => expect(within(row).getByText(/请求被拒绝,尚未确认/)).toBeTruthy());
expect(within(row).getByText("HTTP 400")).toBeTruthy();
expect(within(row).getByText("clef:27b does not support tools")).toBeTruthy();
});
it("preserves configured models that a refreshed catalog no longer returns", async () => {
const port = {
checkProvider: vi.fn(async () => ({ ok: true, models: ["deepseek-new"], vision: [] })),
@@ -166,3 +195,32 @@ it("refreshes the list when the save landed but the conversation keeps its setti
expect(onDone).not.toHaveBeenCalled();
expect(screen.getByText(/已保存。当前对话还有未结束的工作/)).toBeTruthy();
});
const rejecting = (status: number, code: string, error: string) => {
const editProvider = vi.fn(async () => {
throw new HttpError(status, error, { code, error });
});
const entry: ProviderEntry = {
name: "rich", kind: "openai", baseUrl: "https://gateway.invalid/v1",
models: ["alpha"], default: "alpha", hasKey: true, inUse: true, preset: false, canSetVision: false,
};
render(<EditConn entry={entry} port={{ editProvider } as unknown as Port} busy="" setBusy={() => {}} onDone={() => {}} onSaved={() => {}} />);
return userEvent.click(screen.getByRole("button", { name: "保存" }));
};
it("says a refused save failed, as an error alert", async () => {
await rejecting(400, "provider.endpoint_required", "no endpoint");
const alert = await screen.findByRole("alert");
expect(alert.getAttribute("data-lvl")).toBe("err");
expect(within(alert).getByText("保存失败")).toBeTruthy();
expect(screen.queryByText("没保存成功")).toBeNull();
});
it("does not call a saved-but-not-applied outcome a failed save", async () => {
await rejecting(409, "provider.saved_while_running", "saved");
const note = (await screen.findByText("已保存,尚未生效")).closest(".find")!;
expect(note.getAttribute("data-lvl")).toBe("warn");
expect(note.getAttribute("role")).toBe("status");
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText("保存失败")).toBeNull();
});
+19 -11
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { ProviderCheck, ProviderEntry } from "../port/port";
import { clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
import { checkedFact, clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
import type { Port } from "./Providers";
import { SAVED_NOT_APPLIED, reason } from "../i18n/kernel";
import { HttpError } from "../port/port";
@@ -23,6 +23,7 @@ export function EditConn({
const seededModels = [...new Set([...entry.models, ...(initialCheck?.models ?? [])])];
const seededVision = [...new Set([...(entry.visionModels ?? []), ...(initialCheck?.vision ?? [])])];
const [baseUrl, setBaseUrl] = useState(entry.baseUrl);
const [completed, setCompleted] = useState("");
const [apiKey, setApiKey] = useState("");
const [models, setModels] = useState<string[]>(seededModels);
const [picked, setPicked] = useState<string[]>(entry.models);
@@ -34,7 +35,7 @@ export function EditConn({
const [diff, setDiff] = useState(() => initialCheck?.ok ? catalogDiff(entry.models, initialCheck.models ?? []) : null);
const [checkingModel, setCheckingModel] = useState("");
const [def, setDef] = useState(entry.default || entry.models[0] || "");
const [err, setErr] = useState("");
const [err, setErr] = useState<{ text: string; kind: "refresh" | "save" | "unapplied" } | null>(null);
const [more, setMore] = useState(declare);
const [win, setWin] = useState(entry.contextWindow ? String(entry.contextWindow) : "");
const [maxOut, setMaxOut] = useState(entry.maxOutputTokens ? String(entry.maxOutputTokens) : "");
@@ -86,11 +87,14 @@ export function EditConn({
// provider with no credential at all, which fails before it reaches the host.
const refetch = async () => {
setBusy(`refresh:${entry.name}`);
setErr("");
setErr(null);
try {
const refreshed = apiKey.trim()
? await port.probeProvider(baseUrl.trim(), apiKey.trim())
: await port.checkProvider(entry.name);
const changed = !!refreshed.baseUrl && refreshed.baseUrl !== baseUrl.trim();
if (changed) setBaseUrl(refreshed.baseUrl!);
setCompleted(changed ? refreshed.baseUrl! : "");
const found = refreshed.models ?? [];
if (found.length === 0) throw new Error("这个端点没报出任何聊天模型");
const readers = refreshed.vision ?? [];
@@ -102,7 +106,7 @@ export function EditConn({
setVision((current) => [...new Set([...current, ...readers])]);
setVisionSettable((current) => current ? [...new Set([...current, ...readers])] : current);
} catch (e) {
setErr(reason(e));
setErr({ text: reason(e), kind: "refresh" });
} finally {
setBusy("");
}
@@ -125,7 +129,7 @@ export function EditConn({
});
setFacts((current) => ({
...current,
[model]: { ...(current[model] ?? { origin: "configured" }), status: got.status, reason: got.reason },
[model]: checkedFact(current[model], "configured", got),
}));
} catch {
setFacts((current) => ({
@@ -170,7 +174,7 @@ export function EditConn({
const save = async () => {
setBusy(`edit:${entry.name}`);
setErr("");
setErr(null);
try {
await port.editProvider({
name: entry.name,
@@ -191,10 +195,11 @@ export function EditConn({
});
onDone();
} catch (e) {
setErr(reason(e));
const unapplied = e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "");
setErr({ text: reason(e), kind: unapplied ? "unapplied" : "save" });
// Saved but not yet applied: the list has to show what is on file while
// the form stays open to say why.
if (e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "")) onSaved?.();
if (unapplied) onSaved?.();
} finally {
setBusy("");
}
@@ -253,6 +258,9 @@ export function EditConn({
<p className="mguide">
{t("目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;验证会发送一次最小请求,可能产生少量 Token 费用。")}
</p>
{completed !== "" && completed === baseUrl.trim() && (
<p className="mdiff" role="status">{t("接口地址已补全为 {url}", { url: completed })}</p>
)}
{diff && (diff.added > 0 || diff.missing > 0) && (
<p className="mdiff" role="status">
{diff.added > 0 && t("发现 {n} 个新模型。", { n: diff.added })}
@@ -370,9 +378,9 @@ export function EditConn({
</details>
{err && (
<div className="find" data-lvl="warn">
<span className="t">{t("没保存成功")}</span>
<span className="why">{err}</span>
<div className="find" data-lvl={err.kind === "unapplied" ? "warn" : "err"} role={err.kind === "unapplied" ? "status" : "alert"}>
<span className="t">{t(err.kind === "unapplied" ? "已保存,尚未生效" : err.kind === "save" ? "保存失败" : "刷新模型目录失败")}</span>
<span className="why">{err.text}</span>
</div>
)}
@@ -393,4 +393,62 @@ describe("dialog", () => {
await ready();
expect(body().value).toBe("还没写完的话");
});
it("keeps attached screenshots across close and reopen, and drops them once sent", async () => {
setup();
await fill();
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, [png("one.png"), png("two.png")]);
await screen.findByAltText("two.png");
await userEvent.click(screen.getByRole("button", { name: "移除截图 one.png" }));
cleanup();
const again = setup();
await ready();
expect(screen.queryByAltText("one.png")).toBeNull();
const img = screen.getByAltText("two.png") as HTMLImageElement;
expect(img.src).toMatch(/^data:image\/png/);
const spy = vi.spyOn(again.port, "sendFeedback");
await userEvent.type(name(), "ada");
await userEvent.click(send());
expect(await screen.findByText("已收到你的反馈")).toBeTruthy();
expect(spy.mock.calls[0]![0].images).toHaveLength(1);
expect(spy.mock.calls[0]![0].images[0]).toMatchObject({ name: "two.png" });
cleanup();
setup();
await ready();
expect(document.querySelector(".fbk-shots")).toBeNull();
expect(body().value).toBe("");
});
it("keeps screenshots when a send fails, and honours the count limit after restore", async () => {
const { port } = setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 2, uploadBytes: 100 } as FeedbackEnv["limits"] });
port.sendFeedback = vi.fn(async () => { throw new HttpError(500, "boom"); }) as AgentPort["sendFeedback"];
await fill();
const input = () => document.querySelector<HTMLInputElement>('input[type="file"]')!;
await userEvent.upload(input(), [png("a.png", 10), png("b.png", 10)]);
await waitFor(() => expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2));
await userEvent.click(send());
await screen.findByRole("alert");
expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2);
cleanup();
setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 2, uploadBytes: 100 } as FeedbackEnv["limits"] });
await ready();
expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2);
fireEvent.change(input(), { target: { files: [png("c.png", 10)] } });
expect(await screen.findByText(/c\.png:最多 2 张/)).toBeTruthy();
expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2);
});
it("trims restored screenshots that no longer fit the limits", async () => {
setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 3, uploadBytes: 100 } as FeedbackEnv["limits"] });
await ready();
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, [png("a.png", 10), png("b.png", 10), png("c.png", 10)]);
await waitFor(() => expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(3));
cleanup();
setup({ limits: { bodyBytes: 4000, nameChars: 40, contactChars: 80, images: 2, uploadBytes: 100 } as FeedbackEnv["limits"] });
await ready();
await waitFor(() => expect(document.querySelectorAll(".fbk-shots li")).toHaveLength(2));
});
});
+11 -3
View File
@@ -46,7 +46,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
const [body, setBody] = useState(() => heldDraft().body);
const [contact, setContact] = useState(() => heldDraft().contact);
const [name, setName] = useState("");
const [shots, setShots] = useState<Shot[]>([]);
const [shots, setShots] = useState<Shot[]>(() => heldDraft().shots);
const [refused, setRefused] = useState<Refused[]>([]);
const [over, setOver] = useState(false);
const [phase, setPhase] = useState<"editing" | "sending" | "sent">("editing");
@@ -78,8 +78,8 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
useEffect(load, [load]);
useEffect(() => {
if (phase !== "sent") holdDraft({ category, body, contact });
}, [category, body, contact, phase]);
if (phase !== "sent") holdDraft({ category, body, contact, shots });
}, [category, body, contact, shots, phase]);
useEffect(() => {
if (phase === "sent") done.current?.focus();
@@ -91,6 +91,14 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
const limits = env?.limits;
useEffect(() => {
if (!limits) return;
setShots((prev) => {
const fit = prev.filter((s) => s.bytes <= limits.uploadBytes).slice(0, limits.images);
return fit.length === prev.length ? prev : fit;
});
}, [limits]);
const add = useCallback(
async (files: File[]) => {
if (!limits || files.length === 0) return;
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Market } from "./Market";
import { MockPort } from "../port/mock";
import { boot, STORAGE, t } from "../i18n";
import type { AgentPort } from "../port/port";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
localStorage.setItem(STORAGE, "zh");
boot();
});
const group = (name: string) => within(screen.getByRole("radiogroup", { name: t(name) }));
const radio = (groupName: string, name: string) => group(groupName).getByRole<HTMLButtonElement>("radio", { name: t(name) });
async function draw(lang = "zh") {
localStorage.setItem(STORAGE, lang);
boot();
const port = new MockPort() as unknown as AgentPort;
const read = vi.spyOn(port, "marketList");
render(<Market port={port} onInstalled={() => {}} />);
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
return { port, read };
}
it.each(["zh", "en"])("sends the kind selected with the keyboard to the registry (%s)", async (lang) => {
const { read } = await draw(lang);
await userEvent.tab();
await userEvent.tab();
expect(document.activeElement).toBe(radio("类型", "全部"));
for (const [key, name, kind] of [["ArrowRight", "技能", "skill"], ["ArrowRight", "插件", "plugin"]]) {
await userEvent.keyboard("{" + key + "}");
expect(document.activeElement).toBe(radio("类型", name));
expect(radio("类型", name).getAttribute("aria-checked")).toBe("true");
expect(group("类型").getAllByRole("radio").filter((el) => el.getAttribute("aria-checked") === "true")).toHaveLength(1);
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind, sort: "recommended", q: "", offset: 0, pinned: true }));
}
});
it.each(["zh", "en"])("sends the sort selected with the keyboard to the registry (%s)", async (lang) => {
const { read } = await draw(lang);
radio("排序", "推荐").focus();
for (const [key, name, sort] of [["ArrowRight", "近期热门", "trending"], ["ArrowRight", "安装最多", "installs"]]) {
await userEvent.keyboard("{" + key + "}");
expect(document.activeElement).toBe(radio("排序", name));
expect(radio("排序", name).getAttribute("aria-checked")).toBe("true");
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind: "", sort, q: "", offset: 0, pinned: true }));
}
});
it.each(["zh", "en"])("gives each filter group one Tab stop after pointer selection (%s)", async (lang) => {
await draw(lang);
await userEvent.click(radio("类型", "插件"));
await userEvent.tab();
expect(document.activeElement).toBe(radio("排序", "推荐"));
await userEvent.tab({ shift: true });
expect(document.activeElement).toBe(radio("类型", "插件"));
await userEvent.click(radio("排序", "最新"));
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("checkbox", { name: t("只看已固定") }));
await userEvent.tab({ shift: true });
expect(document.activeElement).toBe(radio("排序", "最新"));
});
it("filters and opens a package using only the keyboard without installing it", async () => {
const { port, read } = await draw();
const detail = vi.spyOn(port, "marketDetail");
const install = vi.spyOn(port, "installMarket");
await userEvent.tab();
await userEvent.keyboard("review");
await waitFor(() => expect(read).toHaveBeenLastCalledWith(expect.objectContaining({ q: "review" })));
await userEvent.tab();
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
await userEvent.tab();
expect(document.activeElement).toBe(radio("排序", "推荐"));
await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("checkbox", { name: t("只看已固定") }));
await userEvent.keyboard(" ");
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind: "plugin", sort: "new", q: "review", offset: 0, pinned: false }));
await screen.findByRole("button", { name: /review-kit/ });
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("button", { name: /review-kit/ }));
await userEvent.keyboard("{Enter}");
await waitFor(() => expect(detail).toHaveBeenCalledExactlyOnceWith("acme/review-kit"));
await screen.findByRole("button", { name: t("查看将安装的内容") });
expect(install).not.toHaveBeenCalled();
});
it("preserves pointer and Space selection", async () => {
const { read } = await draw();
await userEvent.click(radio("类型", "主题"));
radio("排序", "最新").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(read).toHaveBeenLastCalledWith({ kind: "theme", sort: "new", q: "", offset: 0, pinned: true }));
});
+11 -9
View File
@@ -8,7 +8,7 @@ import { Group } from "./Group";
import { PlanConfirm } from "./MarketConfirm";
import { MyPackages, PublishForm } from "./MarketPublish";
import { MarketVote, approvalLabel } from "./MarketVote";
import { arrowTabs } from "./tablist";
import { arrowRadios, arrowTabs } from "./tablist";
const KINDS: [MarketKind | "", string][] = [["", "全部"], ["skill", "技能"], ["plugin", "插件"], ["mcp", "MCP 服务"], ["theme", "主题"]];
type Sort = "recommended" | "trending" | "installs" | "new";
@@ -121,16 +121,16 @@ export function Market({ port, onInstalled, onViewInstalled, onSignIn }: Props)
aria-label={t("搜索社区市场")}
onChange={(e) => setQ(e.target.value)}
/>
<div className="seg" data-text role="radiogroup" aria-label={t("类型")}>
<div className="seg" data-text role="radiogroup" aria-label={t("类型")} data-action-keydown="market.kind" onKeyDown={arrowRadios}>
{KINDS.map(([id, name]) => (
<button key={id || "all"} role="radio" aria-checked={kind === id} data-action="market.kind" data-value={id || "all"} onClick={() => setKind(id)}>
<button key={id || "all"} role="radio" aria-checked={kind === id} tabIndex={kind === id ? 0 : -1} data-action="market.kind" data-value={id || "all"} onClick={() => setKind(id)}>
{t(name)}
</button>
))}
</div>
<div className="seg" data-text role="radiogroup" aria-label={t("排序")}>
<div className="seg" data-text role="radiogroup" aria-label={t("排序")} data-action-keydown="market.sort" onKeyDown={arrowRadios}>
{SORTS.map(([id, name]) => (
<button key={id} role="radio" aria-checked={sort === id} data-action="market.sort" data-value={id} onClick={() => setSort(id)}>
<button key={id} role="radio" aria-checked={sort === id} tabIndex={sort === id ? 0 : -1} data-action="market.sort" data-value={id} onClick={() => setSort(id)}>
{t(name)}
</button>
))}
@@ -215,7 +215,9 @@ function Entry({ port, slug, onBack, onInstalled, onViewInstalled, onSignIn }: {
setBusy(true);
setError("");
try {
setPlan(await port.planMarket({ slug, replace: update, ...(trust ? { trust } : {}) }));
const p = await port.planMarket({ slug, replace: update, ...(trust ? { trust } : {}) });
if (!p.ok) setError(p.error || p.next || t("该来源中没有可安装的内容"));
else setPlan(p);
} catch (e) {
setError(reason(e));
} finally {
@@ -352,7 +354,7 @@ function Entry({ port, slug, onBack, onInstalled, onViewInstalled, onSignIn }: {
</button>
)}
</div>
{error && <div className="why">{error}</div>}
{error && <div className="why" role="alert">{error}</div>}
</div>
);
}
@@ -378,9 +380,9 @@ export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSig
<Group id="market" title={t("社区市场")}
hint={t("社区发布、经过审核的技能、插件、MCP 服务与主题。固定了审核内容的版本按审核时的内容安装,未固定的需要你信任发布者;安装前都会列出将写入的全部内容,与粘贴地址安装走同一套确认。")}>
{handle ? (
<div className="seg mkt-views" data-text role="radiogroup" aria-label={t("社区市场")}>
<div className="seg mkt-views" data-text role="radiogroup" aria-label={t("社区市场")} data-action-keydown="market.view" onKeyDown={arrowRadios}>
{VIEWS.map(([id, name]) => (
<button key={id} role="radio" aria-checked={at === id} disabled={applying} data-action="market.view" data-value={id} onClick={() => { if (id === "publish" && at !== "publish") setDraft(null); setView(id); }}>
<button key={id} role="radio" aria-checked={at === id} tabIndex={at === id ? 0 : -1} disabled={applying} data-action="market.view" data-value={id} onClick={() => { if (id === "publish" && at !== "publish") setDraft(null); setView(id); }}>
{t(name)}
</button>
))}
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { MarketGroup } from "./Market";
import { MockPort } from "../port/mock";
import type { AgentPort, MarketPlan } from "../port/port";
import { boot, t } from "../i18n";
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.setItem("rx-lang", "zh"); boot(); });
const account = { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } };
const port = () => new MockPort() as unknown as AgentPort;
const views = () => within(screen.getByRole("radiogroup", { name: t("社区市场") }));
const radio = (name: string) => views().getByRole<HTMLButtonElement>("radio", { name: t(name) });
const nameInput = () => screen.getByRole("textbox", { name: new RegExp("^" + t("名称")) });
describe("market author navigation keys", () => {
it.each(["zh", "en"])("keeps one navigation Tab stop and enters the selected view in %s", async (language) => {
localStorage.setItem("rx-lang", language); boot();
render(<MarketGroup port={port()} account={account} onInstalled={() => {}} onSignIn={() => {}} />);
expect(views().getAllByRole<HTMLButtonElement>("radio").filter((button) => button.tabIndex === 0)).toEqual([radio("浏览")]);
radio("浏览").focus();
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("searchbox"));
await userEvent.tab({ shift: true });
expect(document.activeElement).toBe(radio("浏览"));
await userEvent.keyboard("{ArrowLeft}");
expect(views().getAllByRole<HTMLButtonElement>("radio").filter((button) => button.tabIndex === 0)).toEqual([radio("发布")]);
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("radio", { name: t("技能") }));
await userEvent.tab({ shift: true });
expect(document.activeElement).toBe(radio("发布"));
});
it("preserves pointer and Space activation", async () => {
render(<MarketGroup port={port()} account={account} onInstalled={() => {}} onSignIn={() => {}} />);
await userEvent.click(radio("我的发布"));
expect(radio("我的发布").getAttribute("aria-checked")).toBe("true");
radio("发布").focus();
await userEvent.keyboard(" ");
expect(radio("发布").getAttribute("aria-checked")).toBe("true");
expect(nameInput()).toBeTruthy();
});
it("keeps applying navigation disabled and restores arrows after the own-install reply", async () => {
const backend = port();
const plan = await backend.planOwnMarket({ slug: "demo/ship-notes" });
let finish!: (result: MarketPlan) => void;
const install = vi.spyOn(backend, "installOwnMarket").mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
const changed = vi.fn();
render(<MarketGroup port={backend} account={account} onInstalled={() => {}} onSignIn={() => {}} onApplying={changed} />);
await userEvent.click(radio("我的发布"));
const row = (await screen.findByText("ship-notes")).closest("li")!;
await userEvent.click(within(row).getByRole("button", { name: "安装" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
expect(changed).toHaveBeenLastCalledWith(true);
expect(views().getAllByRole<HTMLButtonElement>("radio").every((button) => button.disabled)).toBe(true);
expect(fireEvent.keyDown(radio("我的发布"), { key: "ArrowRight" })).toBe(true);
expect(radio("我的发布").getAttribute("aria-checked")).toBe("true");
expect(install).toHaveBeenCalledTimes(1);
await act(async () => finish({ ...plan, applied: true, status: "done" }));
expect(changed).toHaveBeenLastCalledWith(false);
radio("我的发布").focus();
await userEvent.keyboard("{ArrowRight}");
expect(document.activeElement).toBe(radio("发布"));
expect(nameInput()).toBeTruthy();
});
it("reaches the author form from browse and submits its exact values using only keys", async () => {
const backend = port();
const mine = vi.spyOn(backend, "myMarket");
const publish = vi.spyOn(backend, "publishMarket").mockRejectedValue(new Error("controlled publish refusal"));
const install = vi.spyOn(backend, "installMarket");
render(<MarketGroup port={backend} account={account} onInstalled={() => {}} onSignIn={() => {}} />);
radio("浏览").focus();
await userEvent.keyboard("{ArrowRight}");
await screen.findByText("ship-notes");
expect(mine).toHaveBeenCalledTimes(1);
await userEvent.keyboard("{ArrowRight}");
for (let i = 0; i < 5 && document.activeElement !== nameInput(); i++) await userEvent.tab();
expect(document.activeElement).toBe(nameInput());
await userEvent.keyboard("key-kit"); await userEvent.tab(); await userEvent.keyboard("1.2.3");
await userEvent.tab(); await userEvent.keyboard("https://example.com/SKILL.md");
for (let i = 0; i < 6; i++) await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "提交审核" }));
await userEvent.keyboard("{Enter}");
await screen.findByText("controlled publish refusal");
expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith({ kind: "skill", name: "key-kit", source: "https://example.com/SKILL.md", summary: "", description: "", repoUrl: "", version: "1.2.3", tags: [], visibility: "public" });
expect(install).not.toHaveBeenCalled();
});
it("shows the existing sign-in action without author radio controls when signed out", () => {
render(<MarketGroup port={port()} account={{ signedIn: false }} onInstalled={() => {}} onSignIn={() => {}} />);
expect(screen.queryByRole("radiogroup", { name: "社区市场" })).toBeNull();
expect(screen.getByRole("button", { name: "去登录" })).toBeTruthy();
});
});
+5 -1
View File
@@ -38,7 +38,11 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
onApplying?.(false);
port
.planOwnMarket({ slug: pkg.slug, replace })
.then((p) => live() && setPlan(p))
.then((p) => {
if (!live()) return;
if (!p.ok) setError(p.error || p.next || t("该来源中没有可安装的内容"));
else setPlan(p);
})
.catch((e) => live() && setError(reason(e)))
.finally(() => live() && setBusy(false));
return () => {
@@ -0,0 +1,70 @@
// @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 { PublishForm } from "./MarketPublish";
import { MockPort } from "../port/mock";
import { boot, STORAGE, t } from "../i18n";
import type { AgentPort } from "../port/port";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
localStorage.setItem(STORAGE, "zh");
boot();
});
const nameField = () => screen.getByLabelText<HTMLInputElement>(new RegExp("^" + t("名称")));
const sourceField = () => screen.getByLabelText<HTMLInputElement>(new RegExp("^" + t("来源地址")));
const types = () => within(screen.getByRole("radiogroup", { name: t("类型") }));
const radio = (name: string) => types().getByRole<HTMLButtonElement>("radio", { name: t(name) });
function draw(lang = "zh") {
localStorage.setItem(STORAGE, lang);
boot();
const port = new MockPort() as unknown as AgentPort;
render(<PublishForm port={port} handle="demo" onMine={() => {}} />);
return port;
}
it.each(["zh", "en"])("leaves the selected kind in one Tab stop and returns to it (%s)", async (lang) => {
draw(lang);
await userEvent.click(radio("插件"));
await userEvent.tab();
expect(document.activeElement).toBe(nameField());
await userEvent.tab({ shift: true });
expect(document.activeElement).toBe(radio("插件"));
});
it("publishes the kind chosen entirely with the keyboard", async () => {
const port = draw();
const publish = vi.spyOn(port, "publishMarket").mockRejectedValue(new Error("registry unavailable"));
await userEvent.tab();
await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
expect(document.activeElement).toBe(radio("主题"));
await userEvent.tab();
expect(document.activeElement).toBe(nameField());
await userEvent.keyboard("notes-kit");
await userEvent.tab();
await userEvent.tab();
expect(document.activeElement).toBe(sourceField());
const source = "https://github.com/demo/notes-kit/tree/" + "a".repeat(40) + "/theme";
await userEvent.keyboard(source);
for (let i = 0; i < 6; i++) await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("button", { name: t("提交审核") }));
await userEvent.keyboard("{Enter}");
expect(publish).toHaveBeenCalledExactlyOnceWith({
kind: "theme", name: "notes-kit", source, summary: "", description: "", repoUrl: "", version: "", tags: [], visibility: "public",
});
expect(await screen.findByText("registry unavailable")).toBeTruthy();
});
it("keeps mouse and Space selection available", async () => {
draw();
await userEvent.click(radio("主题"));
expect(radio("主题").getAttribute("aria-checked")).toBe("true");
radio("MCP 服务").focus();
await userEvent.keyboard(" ");
expect(radio("MCP 服务").getAttribute("aria-checked")).toBe("true");
});
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, MarketKind, MarketPackage, MarketPublished } from "../port/port";
import { OwnInstall } from "./MarketOwn";
import { arrowRadios } from "./tablist";
const KINDS: [MarketKind, string][] = [["skill", "技能"], ["plugin", "插件"], ["mcp", "MCP 服务"], ["theme", "主题"]];
@@ -119,9 +120,9 @@ function PublishDraft({ port, handle, onMine, initial }: PublishProps) {
<p className="mkt-sum">{t(d.private
? "以 @{handle} 的名义保存,仅自己可见,不提交审核。只收来源地址,不上传文件。"
: "以 @{handle} 的名义提交,审核通过后公开。只收来源地址,不上传文件。", { handle })}</p>
<div className="seg" data-text role="radiogroup" aria-label={t("类型")}>
<div className="seg" data-text role="radiogroup" aria-label={t("类型")} data-action-keydown="market.draft" data-value="kind" onKeyDown={arrowRadios}>
{KINDS.map(([id, name]) => (
<button key={id} type="button" role="radio" aria-checked={d.kind === id} disabled={busy} data-action="market.draft" data-value="kind" onClick={() => setD({ ...d, kind: id })}>
<button key={id} type="button" role="radio" aria-checked={d.kind === id} tabIndex={d.kind === id ? 0 : -1} disabled={busy} data-action="market.draft" data-value="kind" onClick={() => setD({ ...d, kind: id })}>
{t(name)}
</button>
))}
+9 -1
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { t } from "../i18n";
import type { ProviderModelCheckReason, ProviderModelCheckStatus } from "../port/port";
import type { ProviderModelCheck, ProviderModelCheckReason, ProviderModelCheckStatus } from "../port/port";
// Which models a connection offers. A gateway answers with hundreds of names
// that differ by a date suffix, so the list is a search field first: one field
@@ -18,9 +18,15 @@ export interface ModelFact {
origin: ModelOrigin;
status?: ProviderModelCheckStatus;
reason?: ProviderModelCheckReason;
httpStatus?: number;
detail?: string;
checking?: boolean;
}
export function checkedFact(fact: ModelFact | undefined, origin: ModelOrigin, got: ProviderModelCheck): ModelFact {
return { ...(fact ?? { origin }), status: got.status, reason: got.reason, httpStatus: got.httpStatus, detail: got.detail };
}
export function clearModelCheckFacts(facts: Record<string, ModelFact>): Record<string, ModelFact> {
return Object.fromEntries(Object.entries(facts).map(([model, fact]) => [
model,
@@ -176,6 +182,8 @@ function ModelEvidence({ fact }: { fact: ModelFact }) {
<span className="mevidence" role={fact.status === "unavailable" ? "alert" : "status"} aria-live="polite">
<span>{t(origin)}</span>
{state && <><i aria-hidden="true" data-state={fact.checking ? "checking" : fact.status ?? "unverified"} />{t(state)}</>}
{!fact.checking && fact.httpStatus ? <code className="mhttp">{`HTTP ${fact.httpStatus}`}</code> : null}
{!fact.checking && fact.detail ? <span className="mdetail">{fact.detail}</span> : null}
</span>
);
}
+8 -1
View File
@@ -71,6 +71,7 @@ function failed(e: unknown): Failed {
export function Onboarding({ port, setup, onDone }: Props) {
const [pick, setPick] = useState(SHORTCUTS[0].label);
const [url, setUrl] = useState(SHORTCUTS[0].url);
const [completed, setCompleted] = useState("");
const [key, setKey] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<Failed | null>(setup.error ? { text: setup.error } : null);
@@ -111,6 +112,9 @@ export function Onboarding({ port, setup, onDone }: Props) {
try {
const probe = await port.probeProvider(at, k);
setFound(probe);
const changed = !!probe.baseUrl && probe.baseUrl !== at;
if (changed) setUrl(probe.baseUrl!);
setCompleted(changed ? probe.baseUrl! : "");
setKind(probe.kind);
setModel(probe.default || probe.models[0] || "");
} catch (e) {
@@ -139,7 +143,7 @@ export function Onboarding({ port, setup, onDone }: Props) {
vision: found.vision,
replace: true,
});
await port.setModel(`${name}/${model}`);
await port.setModel(`${name}/${model}`, true);
onDone();
} catch (e) {
setErr(failed(e));
@@ -260,6 +264,9 @@ export function Onboarding({ port, setup, onDone }: Props) {
<span>{t("连上了")}</span>
<span className="k">{t("{n} 个模型 · key 保存在本机", { n: found.models.length })}</span>
</div>
{completed !== "" && completed === url.trim() && (
<p className="probe-ok" role="status">{t("接口地址已补全为 {url}", { url: completed })}</p>
)}
<div className="onb-field">
<span className="lb">{t("接入方式")}</span>
<div className="onb-protocols" role="radiogroup" aria-label={t("接入方式")}>
Loaded 100 of 586 files, more files were not shown because too many files have changed in this diff. Show more