Compare commits

...
156 Commits
Author SHA1 Message Date
YHH 5e17135b6f Merge pull request #12456 from esengine/release-notes-2.33.0
docs(release): Studio 2.33.0 release notes
2026-10-08 20:53:03 -07:00
YHH 24dee91241 Merge pull request #12450 from esengine/fix/feedback-sync-stable-tags
fix(feedback-sync): count only stable studio tags and merged closing PRs
2026-10-08 20:52:37 -07:00
yhh 4a13a49dc7 docs(release): Studio 2.33.0 release notes 2026-10-08 20:39:55 -07:00
YHH 471c40cff3 Merge pull request #12448 from esengine/perf/studio-idle-gpu
perf(studio): stop a sky pack and frosted panels from keeping the GPU busy
2026-10-08 20:37:20 -07:00
YHH ffca8ca15d Merge pull request #12446 from esengine/chore/bump-go-and-x-net
chore(deps): bump Go toolchain to 1.26.9 and x/net to v0.60.0
2026-10-08 20:21:44 -07:00
YHH 60d26453c8 Merge pull request #12447 from esengine/fix/tui-input-complexity
refactor(tui): bring onKey back under the complexity budget
2026-10-08 20:06:45 -07:00
YHH ee742a31fd Merge pull request #12454 from esengine/fix/mcp-binding-linux-tests
test(mcp): make the enable-binding tests hold on Linux
2026-10-08 19:56:06 -07:00
esengine 37c4f3d744 test(mcp): make the enable-binding tests hold on Linux
The tests that fake Windows name probing on every OS spelled PATHEXT in
upper case while their fixture files are lower case. Windows file lookups
ignore case, so the real lookup finds mcp.cmd for .CMD; a Linux filesystem
does not, so under the fake the probed name missed the fixture and the
tests failed there. The seam fakes the probing, not the filesystem: spell
the extensions as the fixtures are.

Adds the off-Windows counterpart: where nothing probes extensions, a .cmd
beside the command does not affect the binding, and the command file does.
2026-10-08 19:39:56 -07:00
yhh daa13a418a fix(feedback-sync): count only stable studio tags and merged closing PRs
A fixed entry is upgraded from next to a concrete version only when a merged
closing pull request is contained in a studio tag whose release page is neither
a draft nor a prerelease. Prerelease status is read from the releases API, not
from a version cutoff.
2026-10-08 19:32:30 -07:00
yhh 39e4f4033d perf(studio): stop a sky pack and frosted panels from keeping the GPU busy
A pack with a sky drew a blurred cloud bank under six backdrop-filter panels,
so every frame re-blurred 41% of the window and the idle window held a GPU
engine near full on integrated graphics. Frosted panels that are already
72-94% opaque blurred nothing visible, and the grain layer forced a
full-window overlay blend on every frame.

- Sky: CSS breathing animations removed; the canvas redraws on a 15 fps
  timer and only while the window is shown and focused (presence.ts).
- backdrop-filter kept on the composer and the transcript backing only;
  the other panels get a higher flat tint instead.
- Grain drops mix-blend-mode.
- New Appearance setting, Visual effects: Full / Reduced (rx-effects,
  immediate, per machine). Reduced removes remaining blur, text glow, the
  grain and the sky.
- perf/effects.mjs guards all of it in a real browser.
2026-10-08 19:30:48 -07:00
yhh 0cfa0484ce refactor(tui): move the steer and history-edge key rules out of onKey 2026-10-08 19:28:51 -07:00
yhh c8622f7a8c chore(deps): bump Go toolchain to 1.26.9 and golang.org/x/net to v0.60.0 2026-10-08 19:27:43 -07:00
YHH 178dbb7934 Merge pull request #12443 from esengine/feat/studio-open-html-preview
feat(studio): open an HTML file from the explorer as a page preview
2026-10-08 18:23:00 -07:00
YHH a59623aedc Merge pull request #12442 from esengine/feat/studio-model-picker-parity
feat(studio): group, search and mark the composer model picker
2026-10-08 18:21:51 -07:00
YHH e28b76734c Merge pull request #12437 from xxoingr/fix/tui-ctrl-enter-steer
fix(tui): Ctrl+Enter steers a running turn
2026-10-08 18:20:42 -07:00
YHH 5180a74008 Merge pull request #12436 from xxoingr/fix/tui-right-click-paste
fix(tui): right-click with no selection pastes clipboard text
2026-10-08 18:19:33 -07:00
YHH 02395fea16 Merge pull request #12435 from xxoingr/fix/tui-recall-multiline
fix(tui): history recall walks past a multi-line entry
2026-10-08 18:18:28 -07:00
YHH be021ac52f Merge pull request #12332 from KHG420/codex/studio-sent-image-previews
fix(studio): preview saved image attachments in user messages
2026-10-08 18:17:28 -07:00
YHH 172678e1cd Merge pull request #12429 from icebear02/fix/device-panel-relay-status
fix(studio): explain why Internet device access is unavailable
2026-10-08 18:16:24 -07:00
YHH 449f8ca219 Merge commit from fork
fix(mcp): bind a project server's enable to its declaration
2026-10-08 18:10:26 -07:00
yhh 0dee6fd100 feat(studio): open an HTML file from the explorer as a page preview
Clicking .html/.htm in the workbench explorer shows the file as a page in the
shared browser through the existing disk-path open, reloading when the file
changes on disk. Windows that draw no browser views and remote workspaces keep
the source editor.
2026-10-08 18:09:36 -07:00
yhh 6062a8d011 feat(studio): group, search and mark the composer model picker
The model menu now always opens with a search field, groups models under a
sticky provider heading with a letter chip, uses compact one-line rows and
grows with the window (up to 640px) instead of a short fixed box. Rows keep
the wire format and show a reads-images badge where the model declares it.
Enter no longer lands on the manage action, and an empty search says so.
2026-10-08 17:57:50 -07:00
esengine e2d8eba20d fix(mcp): bind a project server's enable to its declaration
An enable recorded for a server that .mcp.json or a project reasonix.toml
declares held for the server's name. The row now records a digest of the
declaration as written (type, command, args, env, url, headers), the project
.env values it expands, and the content of the workspace files it names:
its arguments, and every file the launcher could start for its command,
found by the launcher's own lookup (base/proc): the declaration's PATH for a
bare name (every Windows spelling of it, in key order), and PATHEXT
siblings on Windows, dotted names included. A different declaration reads
as ActivationChanged: off and awaiting the user. Rows written before this
carry no digest and ask once.

- Specs for a repository-declared server carry a LaunchCheck run before
  every start and redial; env overrides merge in key order.
- Why a server waits is a typed code (awaiting_user_decision,
  changed_since_enabled) on MCPHealth, the serve and ACP wires and the TUI;
  Studio and the TUI word it in the reader's language; Studio shows the
  launch line beside the switch.
- A connect or reconnect by name refuses a repository server in any pending
  state with ErrMCPApprovalOwed (serve: mcp.approval_owed) and its launch
  line; a server from the user's own files is still answered by a connect.
- Build reports each held project server once, so `reasonix run` prints
  one warning line per server; serve prints the same at startup; /mcp and
  `reasonix mcp list` show them. Repository-chosen names are sanitised
  wherever they are printed, and run diagnostics sanitise every line.
- `reasonix mcp enable` prints the approved launch line; a project install
  records the decision for the declaration as written; doctor --live
  starts only what a session would.
2026-10-08 12:09:24 -07:00
esengine 7ba591ad13 test(mcp): an enable covers the declaration and the files it runs
Every test for the project MCP approval change, ahead of the change:

- boot effect tests through the real assembly and the settings switch: a
  rewritten declaration, a launcher changed mid-session and an executable
  swapped behind the declaration's own PATH must not start on an earlier
  enable; held servers are reported once each at build; a repository's
  server name reaches no notice as control sequences.
- config: the enable covers the declaration, the .env values it expands,
  the files it names, the executable the launcher resolves (declared PATH,
  every spelling of PATH, PATHEXT siblings including dotted names), legacy
  rows ask again; base/proc holds the launcher's lookup.
- plugin: the launch check runs before start and redial; env merges in a
  fixed order.
- control/cli/tui/frontend: a connect by name never approves a repository
  server that waits; why it waits is a typed code on every surface; /mcp,
  `reasonix mcp list`, serve's startup warning, run diagnostics and the TUI
  panel print a hostile name as plain text; doctor --live starts only what
  a session would.
2026-10-08 12:09:24 -07:00
YHH a90594dda2 Merge pull request #12439 from esengine/fix/flaky-frontend-2
fix(studio): move focus into the feedback body after the environment has committed
2026-10-08 11:55:51 -07:00
YHH 87b385f86e Merge pull request #12438 from esengine/fix/flaky-frontend-1
test(studio): wait for the pane's subscription before emitting into it
2026-10-08 11:54:53 -07:00
yhh fa985108d7 fix(studio): move focus into the feedback body after the environment has committed
The form moved focus from the tab into the body in a frame callback queued from the environment promise. The fieldset is disabled until that environment is committed, so a frame that fired before the commit called focus() on a disabled textarea and focus stayed on the tab. The move now runs in an effect on the environment, after the commit.
2026-10-08 11:40:32 -07:00
yhh d29c170e54 test(studio): wait for the pane's subscription before emitting into it
The unread interaction tests emitted wire events through a listener map that was only filled once the pane subscribed, and a missing listener was a silent no-op. The first test of the turn-ending group waited for the port to exist, which is earlier than the subscription, so an event could be dropped under load. emit now waits for the listener.
2026-10-08 11:40:22 -07:00
xxoingr bf55a5221b fix(tui): Ctrl+Enter steers a running turn
docs/GUIDE.md lists Ctrl+Enter (or /steer) as the key that adds guidance
to the active turn, and 1.x bound it so. 2.x bound only Ctrl+S, so on a
terminal that reports Ctrl+Enter the documented key did nothing.

While a turn runs, Ctrl+Enter now sends the composer as steer through the
same path as Ctrl+S. Idle it does nothing, as in 1.x, so a press meant as
a newline never submits the draft. Ctrl+S is unchanged.

Refs #12144
2026-10-09 02:29:23 +08:00
xxoingr de3c6bdcd2 fix(tui): right-click with no selection pastes clipboard text
With in-app mouse capture on, a right-click copied an active transcript
selection and otherwise did nothing. 1.x pasted the clipboard's text on
a right-click with no selection, and docs/GUIDE.md and docs/CLI.md say
2.x does too, so the documented paste never happened.

A right-click with no selection now pastes through the same path as
Shift+Insert: text only, through the normal paste handling, with the
existing hint over SSH. It pastes only while the composer is drawn; the
condition that draws it moves into composerShown so the view and the
mouse read one answer. A right-click on a selection still copies it.

Refs #12144
2026-10-09 02:27:16 +08:00
xxoingr ea148fed50 fix(tui): history recall walks past a multi-line entry
Up and Down recalled history only while the composer held a single line.
Once a recalled entry had several lines, Up moved the caret to its first
line and then stopped: nothing older could be reached, and a multi-line
draft could not step into the history at all.

Up now recalls from the composer's first line and Down from its last, as
1.x did; between them the keys move the caret as before. A single line,
soft-wrapped or not, behaves exactly as it did.

Refs #12144
2026-10-09 02:24:54 +08:00
YHH 41e8f3be6f Merge pull request #12421 from xxoingr/fix/tui-mcp-panel-keys
fix(tui): q, h/l and Left/Right in the MCP manager
2026-10-08 11:11:48 -07:00
YHH 034c9519f4 Merge pull request #12409 from esengine/dependabot/npm_and_yarn/workers/crash-report/multi-1f96396017
chore(deps): bump tinypool and vitest in /workers/crash-report
2026-10-08 10:53:41 -07:00
YHH 0188c0a5c7 Merge pull request #12408 from esengine/dependabot/npm_and_yarn/workers/crash-report/source-map-js-1.2.2
chore(deps-dev): bump source-map-js from 1.2.1 to 1.2.2 in /workers/crash-report
2026-10-08 10:52:29 -07:00
YHH edc780dbd3 Merge pull request #12420 from xxoingr/fix/tui-ask-jkhl
fix(tui): j/k and h/l drive the question card
2026-10-08 10:37:48 -07:00
YHH 75ccd2abcd Merge pull request #12419 from xxoingr/fix/tui-approval-jk
fix(tui): j/k and Ctrl+N/Ctrl+P move the approval cursor
2026-10-08 10:22:02 -07:00
icebear02 4c459ad337 test(studio): cover empty cloud status reason 2026-10-09 01:08:05 +08:00
icebear02 d3d4c03452 fix(studio): preserve neutral empty cloud status 2026-10-09 01:07:33 +08:00
YHH b6cd0df288 Merge pull request #12423 from KHG420/codex/studio-feedback-image-read-lifetime
fix(studio): keep pending screenshots within their feedback draft
2026-10-08 10:07:21 -07:00
icebear02 12129ac460 refactor(remotecloud): classify relay dial errors
Refactor error handling in relay dial logic and extract into a new function.
2026-10-09 01:06:16 +08:00
xiexian aedbba6314 fix(studio): retain saved image previews after a failed load 2026-10-09 01:01:58 +08:00
YHH 72f596d209 Merge pull request #12411 from KHG420/codex/studio-queue-edit-read-order-preflight-r2
fix(studio): keep the latest queued edit selection
2026-10-08 09:52:37 -07:00
YHH c47bdfd847 Merge pull request #12432 from esengine/docs/ui-quality-process
docs: UI-change section in the PR template and a 1.x parity table
2026-10-08 09:47:52 -07:00
YHH 40bac6c3c4 Merge pull request #12344 from KHG420/codex/studio-pane-startup-reads
perf(studio): avoid duplicate reads before the session is known
2026-10-08 09:41:30 -07:00
YHH f2112e6ad8 Merge pull request #12426 from KHG420/codex/studio-resend-rewind-failure
fix(studio): preserve message edits when rewind fails
2026-10-08 09:38:50 -07:00
yhh a0a53ca890 docs: add UI-change section to the PR template and a 1.x parity table
Cause: 30 days of Studio UI changes landed without screenshots or review, and a visible control was hidden without a linked issue or a release note.
The PR template had no UI requirement and no table tracked 1.x features.
2026-10-08 09:25:40 -07:00
YHH d779d89bcc Merge pull request #12424 from esengine/docs/readme-plain-language
docs(readme): say what Reasonix does in plain words
2026-10-08 08:12:37 -07:00
YHH d4e7fe771a Merge pull request #12428 from esengine/feat/studio-nav-rail-modes
feat(studio): icon rail at the far left, with feedback and three display modes
2026-10-08 08:03:46 -07:00
icebear02 129743f52f test(repolint): cover the cloud remote status mirror 2026-10-08 22:56:06 +08:00
icebear02 6efb57191f i18n(studio): translate cloud relay status messages 2026-10-08 22:55:52 +08:00
icebear02 25d938b61a test(studio): cover typed cloud status messages 2026-10-08 22:55:36 +08:00
icebear02 8afc203738 fix(studio): explain unavailable Internet access 2026-10-08 22:55:21 +08:00
icebear02 421c32c8dd test(studio): seed the mock cloud reason 2026-10-08 22:54:59 +08:00
icebear02 8cdfffca62 fix(studio): type the cloud remote status reason 2026-10-08 22:54:40 +08:00
yhh 8429cd2708 feat(studio): icon rail at the far left, with feedback and three display modes
Refs #12427. The rail moves left of the sidebar, gains the feedback entry, and the Appearance setting becomes always / only when the sidebar is collapsed / never. Stored on and off values keep their meaning.
2026-10-08 07:53:34 -07:00
icebear02 d1dac77fb5 fix(serve): expose the cloud remote status reason 2026-10-08 22:50:28 +08:00
icebear02 1e2171c7a2 fix(studio): carry the cloud status reason to the share bridge 2026-10-08 22:50:04 +08:00
icebear02 53a8a82896 fix(remotecloud): classify unavailable relay states 2026-10-08 22:49:38 +08:00
yhh 46658f5508 docs(readme): lead with where it runs, then what it does in plain verbs 2026-10-08 07:22:47 -07:00
yhh 008aca42b6 docs(readme): rewrite the plain-language copy in a calmer, more concrete voice 2026-10-08 07:07:27 -07:00
yhh 8dbd6583d7 docs(readme): say the model is a configured service, not something that runs on your machine 2026-10-08 06:30:24 -07:00
xiexian ff48415d47 fix(studio): preserve message edits when rewind fails 2026-10-08 21:23:27 +08:00
yhh f1a8525c44 docs(readme): say what Reasonix does in plain words before the technical detail 2026-10-08 06:21:45 -07:00
xiexian 174228553a fix(studio): keep screenshot reads with their feedback draft 2026-10-08 20:43:48 +08:00
xiexian 29be7e29ec perf(studio): coalesce reads while initial status is pending 2026-10-08 20:32:16 +08:00
YHH f73421aa65 Merge pull request #12422 from esengine/docs/release-notes-2.32.0
docs(release): Studio 2.32.0 notes
2026-10-08 05:00:52 -07:00
yhh 1128e4dab9 docs(release): Studio 2.32.0 notes 2026-10-08 04:59:12 -07:00
xxoingr 55476745c4 fix(tui): q, h/l and Left/Right in the MCP manager
The /mcp panel opened a server's tools with Enter and backed out with
Esc only. 1.x's MCP manager also went in with l / Right, backed out one
level with h / Left, and closed from anywhere with q, and docs/GUIDE.md
(both languages) still lists those keys, so pressing them did nothing.

The panel now takes l and Right as Enter, h and Left as Esc (back to the
list, or closed from the list), and q closes it from the list or a
server's tools. The repository-declared confirm line is unchanged: any
key but y still cancels it.

Refs #12144
2026-10-08 19:45:53 +08:00
xxoingr bd439b77ef fix(tui): j/k and h/l drive the question card
The ask card took only the arrows: Up/Down (and Tab) to move between
rows, Left/Right to move between questions. 1.x's question card also
took j/k and h/l, and docs/GUIDE.md (both languages) lists them for it,
so pressing them on 2.x did nothing.

The card now takes k as Up, j as Down, h as Left and l as Right, with the
existing wrap, clamp and auto-submit stop. A typed answer or note is
unaffected: the composer gets those keys as text before the card does.

Refs #12144
2026-10-08 19:43:08 +08:00
xxoingr cb755dac33 fix(tui): j/k and Ctrl+N/Ctrl+P move the approval cursor
The approval card moved its cursor with the arrows and Tab only. 1.x also
took j/k and Ctrl+N/Ctrl+P there, and docs/CLI.md says approval rows use
the same row-selection keys as the pickers, so pressing them did nothing.

The card now takes k and Ctrl+P as Up and j and Ctrl+N as Down. None of
them is a row shortcut (y/a/p/n/x), so no answer changes meaning.

Refs #12144
2026-10-08 19:40:00 +08:00
YHH 44f87ce11e Merge pull request #12413 from esengine/fix/running-delegation-marked-at-start
fix(agent): mark a proxied sub-agent call as a delegation while it runs
2026-10-08 04:35:13 -07:00
YHH d44551ac21 Merge pull request #12414 from esengine/fix/refused-call-not-interrupted
fix(studio): show a call the host refused as not run, not interrupted
2026-10-08 04:31:12 -07:00
YHH 680732c5ed Merge pull request #12416 from esengine/fix/recovered-inbox-notice-typed
fix(studio): word the recovered-inbox warning in the window's language
2026-10-08 04:26:44 -07:00
YHH 2e534ca1aa Merge pull request #12415 from esengine/fix/compaction-threshold-shows-effective
fix(studio): say on the compaction threshold field when the capacity guard caps it
2026-10-08 04:22:41 -07:00
YHH e44afbc368 Merge pull request #12342 from KHG420/codex/studio-rewind-restore-prompt
fix(studio): restore rewound prompts to the composer
2026-10-08 04:13:25 -07:00
YHH faf7999823 Merge pull request #12120 from xxoingr/perf/pane-checkpoint-reads
perf(studio): read a pane's checkpoints once on mount
2026-10-08 03:58:28 -07:00
yhh d44a65f3f1 fix(studio): say on the compaction threshold field when the capacity guard caps it 2026-10-08 03:58:04 -07:00
yhh 5edd77861a fix(agent): mark a proxied sub-agent call as a delegation while it runs
A call reached through use_capability only learned its delegation profile after the target finished, so the deck's sub-agent list and the running count missed every sub-agent until it ended. The profile is now announced when execution starts.
2026-10-08 03:53:32 -07:00
yhh 7866a7b1d0 fix(studio): word the recovered-inbox warning in the window's language
The notice now carries its count as a typed payload next to the unchanged English text.
2026-10-08 03:52:30 -07:00
YHH 94b57ca1c0 Merge pull request #12406 from esengine/feat/studio-version-notes
feat(studio): read a version's release notes from the Versions page
2026-10-08 03:48:26 -07:00
yhh 9092e5ede6 fix(studio): show a call the host refused as not run, not interrupted
The argument contract now names its refusal, and the delegation views read that identity instead of treating every failed delegate as interrupted.
2026-10-08 03:45:47 -07:00
YHH d67faee3b5 Merge pull request #12373 from KHG420/codex/studio-queue-send-now-order
fix(studio): send the selected queue item next
2026-10-08 03:42:30 -07:00
esengine 6dcd0d3cc7 fix(studio): keep only declared documents in the notes cache, bound the cache name, keep a row's label open
A notes response is accepted only when its declared type is markdown or plain
text, so an error page served with 200 is refused and never written. The cache
name is lower-cased with a digest for mixed-case prereleases and over-long
versions are refused. Expanding a row on a narrow window keeps its label and
date. Error wrapping and the wait group follow the linter.
2026-10-08 03:33:29 -07:00
dependabot[bot] aa139c9f15 chore(deps): bump tinypool and vitest in /workers/crash-report
Removes [tinypool](https://github.com/tinylibs/tinypool). It's no longer used after updating ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Removes `tinypool`

Updates `vitest` from 3.2.7 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: tinypool
  dependency-version:
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 5.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-08 10:31:17 +00:00
dependabot[bot] b128ecb2d2 chore(deps-dev): bump source-map-js in /workers/crash-report
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-08 10:30:55 +00:00
YHH 1da6e15962 Merge pull request #12404 from esengine/feat/studio-notes-publish
feat(release): publish each version's notes to the mirror before the catalog names them
2026-10-08 03:29:38 -07:00
xiexian 940d193d26 fix(studio): keep the latest queued edit selection 2026-10-08 18:25:39 +08:00
YHH 5f07a893ab Merge pull request #12369 from KHG420/codex/studio-folded-thought-reveal
fix(studio): pause reasoning reveal while thoughts are folded
2026-10-08 03:25:18 -07:00
esengine b9c95807f7 feat(studio): read a version's release notes from the Versions page
The kernel serves GET /studio/versions/{version}/notes from the release mirror,
keeps each fetched document on disk for good, shares one request between
concurrent readers and refuses by typed code. A row whose catalog entry names
notes gets an inline view with images off.
2026-10-08 03:19:27 -07:00
esengine 8de80b3fe7 fix(release): publish a version without notes when the notes upload fails
The notes are optional, so a failed upload or head-object warns and writes the
catalog entry without a pointer instead of failing the Mirror to R2 step.
2026-10-08 03:15:03 -07:00
YHH c450fad954 Merge pull request #12403 from esengine/fix/flaky-failed-job-phase
test(control): answer the ask that is on screen, not any registered one
2026-10-08 03:10:32 -07:00
YHH c9a6aab619 Merge pull request #12375 from KHG420/codex/studio-tree-refresh-failure
fix(studio): retain the sidebar tree after a failed refresh
2026-10-08 03:08:52 -07:00
esengine 97aab82b2d feat(release): publish each version's notes to the mirror before the catalog names them
Studio release workflow uploads studio/notes/<version>.md ahead of the catalog
write and gives the new entry an optional notes field. A one-time script
backfills the entries already in the catalog.
2026-10-08 03:00:31 -07:00
YHH fa9d78ce14 Merge pull request #12395 from esengine/perf/pluginpkg-single-pass
perf(pluginpkg): scan skills once per parse, parse outside the state lock
2026-10-08 02:58:49 -07:00
YHH a6374d9b9a Merge pull request #12400 from esengine/fix/flaky-wallet-source-flicker
fix(studio): do not blank the wallet when the first status names its account
2026-10-08 02:57:01 -07:00
yhh e1d65e0377 test(control): answer the ask that is on screen, not any registered one
pendingAsk returned an arbitrary entry of the ask map, including one still queued behind the prompt lock. When the second ask took the lock first, the test answered the queued one and then waited for it while the other held the lock for the user: a hang until the package timeout.
2026-10-08 02:55:57 -07:00
YHH 51a94e6602 Merge pull request #12385 from KHG420/codex/studio-queue-edit-escape
fix(studio): keep Escape in the queue editor from stopping the turn
2026-10-08 02:53:55 -07:00
YHH 70a990428b Merge pull request #12399 from esengine/fix/flaky-workspace-lock-owner
fix(workspacelist): let a caller's deadline bound the lock wait
2026-10-08 02:51:01 -07:00
yhh 226c567194 fix(studio): do not blank the wallet when the first status names its account
useWallet treated the empty account of a pane whose status had not arrived as a source, so the first status turned it into a source change: the wallet read that had already landed was emptied and read again.
2026-10-08 02:47:27 -07:00
yhh 32a293b12a fix(pluginpkg): tell a same-root reinstall apart from the entry a parse started from 2026-10-08 02:46:49 -07:00
YHH c6e3169e48 Merge pull request #12394 from esengine/fix/flaky-failed-job-phase
test(boot): make the failed-job provider independent of the job-finished notice
2026-10-08 02:42:02 -07:00
YHH 18e0cf8224 Merge pull request #12391 from esengine/perf/coalesce-package-loads
perf(config): concurrent plugin package reads share one parse
2026-10-08 02:41:16 -07:00
yhh 94d695393e fix(workspacelist): let a caller's deadline bound the lock wait
Update capped the lock wait at 5s even for a caller that supplied its own deadline, so the number of writers a queue could hold was fixed by the package rather than by the caller. The serve tests now pass a budgeted context and fail on the returned error instead of reading a swallowed one as a wrong count.
2026-10-08 02:39:16 -07:00
yhh 7fab14208a test(config): use WaitGroup.Go in the package-read coalescing test 2026-10-08 02:22:00 -07:00
yhh b7ebb884b9 perf(pluginpkg): scan a package's skills once per parse and parse outside the state lock 2026-10-08 02:21:13 -07:00
yhh 4b12befedf test(boot): key the failed-job provider on the START/CHECK turn, not the last user message
The host appends a background-job-finished notice as a user-role message once the job ends. When it landed before the CHECK turn composed, the provider read the notice as the last user message, took its default branch and never issued wait/read, so the answers it recorded for w1 and r1 were empty.
2026-10-08 02:07:58 -07:00
yhh 7ba9278f08 perf(config): concurrent package reads share one parse
Every config load re-parses each enabled plugin package, and a settings sheet
fires a dozen such loads at once, each queued behind the package store's lock.
With a 293-skill package a burst costs one full parse per request. Callers now
wait for a parse that starts after they arrived and share it, so a burst costs
at most two.
2026-10-08 02:07:25 -07:00
YHH eaac0e8399 Merge pull request #12387 from esengine/fix/import-1x-v5-workspace-routing
fix(migration): file imported 1.x Desktop sessions under their own workspaces
2026-10-08 01:54:43 -07:00
YHH 2872663c2d Merge pull request #12382 from esengine/chore/ui-foundations-f0
chore(studio): F0 ratchet for !important and stylesheet length, browser guards, wheel probe
2026-10-08 01:47:40 -07:00
YHH 1d592d4e61 Merge pull request #12386 from esengine/refactor/studio-pane-split
refactor(studio): split Pane.tsx by concern and lift the pane view into state
2026-10-08 01:42:57 -07:00
YHH e5eb7b892b Merge pull request #12384 from esengine/test/model-switch-keeps-projection
test(boot): a model switch keeps the compaction projection
2026-10-08 01:41:08 -07:00
yhh 40a53ecd8d fix(migration): file imported 1.x Desktop sessions under their own workspaces
The desktop-sessions-v5 by-id store carries no workspace, so every session
from it landed in the workspace picked in the dialog. The owner is recorded
in desktop/workspace-state-v1.json; read it from the picked folder and route
each session to its workspace while that folder still exists.
2026-10-08 01:36:39 -07:00
esengine be9631d18a refactor(studio): split Pane.tsx by concern and lift the pane view into state
Pane.tsx sat at the 800-line ceiling with twelve open pull requests editing
it. The view (flow, analysis, browser) lived in component state, out of reach
of the tab strip and of any setting that will place tabs.

The view now lives in state/paneview.ts, keyed by pane id and owned by the
mounted instance, so a remount for another conversation still starts on the
conversation. Submit, surface slots, the meter row, the plan fold and the run
line move to files of their own; props and the report type move to
panetypes.ts. No visual or behavioural change.
2026-10-08 01:24:34 -07:00
yhh dcb3becaf9 test(boot): a model switch keeps the compaction projection and the system prompt it covers 2026-10-08 01:22:56 -07:00
yhh 78aeea4eac chore(studio): ratchet !important and stylesheet length, wire browser guards, add wheel probe
Adds css-important, css-size and stray-file rules to repolint, deletes the
stray Settings.tsx-e, runs the intrinsic, contrast and concurrent browser
guards in the studio workflow, and adds a report-only wheel-scroll probe.
2026-10-08 01:22:44 -07:00
YHH cc4f466a66 Merge pull request #12136 from KHG420/codex/studio-hook-trial-receipt
fix(studio): associate hook receipts with their trial
2026-10-08 01:20:50 -07:00
xiexian fe483f46d5 fix(studio): consume Escape when dismissing a queued draft 2026-10-08 16:11:14 +08:00
xiexian 5b026929f4 fix(studio): associate hook receipts with their trial 2026-10-08 15:30:49 +08:00
YHH c1f9dc9e1c Merge pull request #12377 from esengine/feat/studio-nav-rail-setting
feat(studio): bring the icon rail back behind an Appearance setting
2026-10-08 00:22:17 -07:00
YHH e9f2b8a6ba Merge pull request #12374 from esengine/fix/window-ground-colour
fix(desktop): match the window background to the page ground
2026-10-08 00:16:46 -07:00
YHH 4cc3f056eb Merge pull request #12372 from esengine/fix/delete-folded-recovery-row
fix(serve): deleting a folded sidebar row also removes the covered recovery copies it hides
2026-10-08 00:14:39 -07:00
YHH f086775c3e Merge branch 'studio' into feat/studio-nav-rail-setting 2026-10-08 00:03:44 -07:00
YHH 8e5c0f3443 Merge branch 'studio' into fix/window-ground-colour 2026-10-08 00:03:06 -07:00
YHH 741b1da8cf Merge branch 'studio' into fix/delete-folded-recovery-row 2026-10-08 00:02:27 -07:00
YHH 63798bff1f Merge pull request #12376 from esengine/fix/settings-extension-models-test-default-model
test(settings): expect the two-call default-model write in the extension refresh test
2026-10-08 00:01:51 -07:00
yhh 6039ba386c feat(studio): bring the icon rail back behind an Appearance setting
The rail was hidden by the studio layer's prototype alignment, which left Nav.tsx rendered but display:none. It is drawn again, beside the workspace column, when this machine has it on (default) and the window is wider than the scene fold.
2026-10-07 23:57:35 -07:00
yhh d64c204309 test(settings): expect the two-call default-model write in the extension refresh test 2026-10-07 23:50:03 -07:00
yhh 9f2f03e703 fix(desktop): give the Studio window a background that matches the page ground
The BrowserWindow carried no backgroundColor, so any pixel the page does not
cover (frameless window at a fractional display scale) showed the default
white. The ground now follows the stored rx-theme, or the system for auto, at
creation and when preferences are saved or the system theme changes.
2026-10-07 23:37:55 -07:00
yhh 6c193b2783 fix(serve): iterate the removal guards with slices.Backward 2026-10-07 23:33:28 -07:00
YHH d383652b6f Merge pull request #12129 from KHG420/codex/studio-hook-editor-draft
fix(studio): preserve hook edits during trial and save
2026-10-07 23:32:01 -07:00
YHH e01c8ed11a Merge pull request #12093 from KHG420/codex/studio-market-publish-navigation
fix(studio): hold navigation during market publication
2026-10-07 23:31:05 -07:00
YHH 88514b8424 Merge pull request #12161 from KHG420/codex/studio-extension-model-refresh
fix(studio): refresh provider models after extension changes
2026-10-07 23:30:09 -07:00
xiexian 310b8f0702 fix(studio): keep session rows after a failed tree refresh 2026-10-08 14:27:00 +08:00
yhh e11b9b86f5 fix(serve): take every guard before deleting a folded row and erase the lead last 2026-10-07 23:16:58 -07:00
yhh 4f17c13990 fix(serve): keep diverged recovery copies when deleting a folded row 2026-10-07 23:10:16 -07:00
xiexian 49094ac587 fix(studio): send the selected queue item next 2026-10-08 14:09:03 +08:00
yhh de9ff2bed0 test(serve): cover open, versioned and held siblings when deleting a folded row 2026-10-07 23:00:13 -07:00
yhh ceacf5ab25 fix(serve): deleting a folded sidebar row removes its whole recovery lineage
The sidebar folds a conversation's recovery copies under one row, but
/tree/sessions/remove deleted only that row's file. The next copy then took
its place, so the delete looked like a no-op with the order shuffled.
Remove the lineage the way archive already addresses it, leaving siblings a
pane has open.
2026-10-07 22:50:01 -07:00
YHH 3a3a720cda Merge pull request #12359 from KHG420/codex/studio-tui-immediate-local-completion
fix(tui): offer local command completion before kernel replies
2026-10-07 22:48:39 -07:00
YHH ad8cb13030 Merge pull request #12363 from KHG420/codex/studio-memorybench-step-budget
fix(bench): let memorybench tasks run without a step cap
2026-10-07 22:40:21 -07:00
YHH 14004b18bb Merge pull request #12366 from KHG420/codex/studio-latest-followed-link
fix(studio): keep the latest followed browser page selected
2026-10-07 22:39:36 -07:00
YHH 8a315bd011 Merge pull request #12358 from KHG420/codex/studio-remote-reuse-missing-token
fix(remote): preserve a live serve when its token is unavailable
2026-10-07 22:38:50 -07:00
xiexian 4b3ad7c29e fix(studio): pause reasoning reveal while thoughts are folded 2026-10-08 13:18:26 +08:00
YHH cb5c0e036c Merge pull request #12125 from KHG420/codex/studio-runtime-confirmation-window
fix(studio): renew each runtime application confirmation
2026-10-07 22:11:30 -07:00
xiexian a591b7044e fix(studio): keep the latest followed browser page selected 2026-10-08 12:45:41 +08:00
xiexian 1092e8c8e2 fix(bench): let memorybench tasks run without a step cap 2026-10-08 12:21:40 +08:00
xiexian 041f40672d fix(tui): offer local command completion before kernel replies 2026-10-08 11:46:31 +08:00
xiexian 3bb435aa10 fix(remote): preserve a live serve when its token is unavailable 2026-10-08 11:34:19 +08:00
YHH cb04e619db Merge pull request #12353 from esengine/fix/refusal-no-guessed-reasoning-cause
fix(provider): stop attributing every refused body to dropped assistant reasoning
2026-10-07 20:26:18 -07:00
yhh 7245b82c6f fix(provider): stop attributing every refused body to dropped assistant reasoning
A 400/422 on a request whose history carried reasoning the connection
could not replay was labelled "the endpoint wants the thinking content
sent back", whatever the endpoint actually said. Dropped reasoning is a
fact about the request, not a cause the host observed, and the endpoint's
refusal carries no typed signal separating the two. The hint is removed;
the refusal now shows the status guidance and the endpoint's own words.
2026-10-07 20:14:57 -07:00
xiexian 26bf6547d9 fix(studio): restore rewound prompts to the composer 2026-10-08 08:04:33 +08:00
xxoingr 8bb3f9d58c perf(studio): read a pane's checkpoints once on mount
Opening a session pane read GET /checkpoints three times against a real
kernel: the mount effect read it, the turn-boundary effect read it again on
its own first run, and a third time when the first status named the session
path.

useCheckpointRefresh now owns every read outside a session reload: the mount
read, a move from one known session to another, and both turn edges. The
kernel opens a turn's checkpoint when the turn starts and fills it while the
turn runs, so the read at turn start stays and the rewind entry still appears
on a running turn's message. The first status naming the session the mount
read covered no longer reads again, so opening a pane costs one request.

A failed read is tried once more after a second, since no later edge is owed
to it, and a read that a newer one superseded is dropped instead of
overwriting the newer list.

Refs #12080
2026-10-06 21:25:50 +08:00
xiexian 33a56d2f8a fix(studio): refresh provider models after extension changes 2026-10-06 10:53:29 +08:00
xiexian 215dedc5b4 fix(studio): hold navigation during market publication 2026-10-06 09:13:19 +08:00
xiexian 20c883c724 fix(studio): renew each runtime application confirmation 2026-10-06 09:03:06 +08:00
xiexian c7a6704558 fix(studio): preserve hook edits during trial and save 2026-10-06 05:21:31 +08:00
279 changed files with 11773 additions and 2514 deletions

No files matched your search

+12
View File
@@ -24,6 +24,18 @@ only, on `main-v2`.
-
## UI changes
<!--
Delete this section if the PR touches nothing under `desktop/frontend-next/src`
or the website pages.
-->
- Screenshots, before and after: light and dark, a wide window and a phone-width one (about 390 px). Attach them to this description.
- Entries moved, hidden or removed: none / list each one.
- Hiding or removing a visible control needs a linked issue and a release-notes line naming it. Do not retire UI with `display: none !important` or a feature flag.
- If a row of `docs/STUDIO_PARITY.md` changes, update it here.
## Issues
<!--
+1 -1
View File
@@ -84,7 +84,7 @@ jobs:
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/studio-release-flags.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- 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/studio-release-flags.test.mjs scripts/studio-notes-publish.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
+17 -1
View File
@@ -1242,6 +1242,22 @@ jobs:
exit 0
fi
# The notes object goes up before the catalog entry that names it, so
# no client reads a pointer to an object that is not there yet. Notes
# are optional: a failure here publishes the version without a pointer
# rather than keeping the new version out of the catalog.
notes_key="studio/notes/${VERSION#v}.md"
notes_arg=()
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
--endpoint-url "$endpoint" \
--content-type "text/markdown; charset=utf-8" \
--cache-control "public, max-age=31536000, immutable" &&
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
else
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
fi
# Studio's own catalog. Writing the ROOT versions.json here would hand
# desktop users Studio releases, so the prefix is not optional.
current=/tmp/studio-versions-current.json
@@ -1253,7 +1269,7 @@ jobs:
echo "no existing Studio catalog; starting a new one"
fi
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
+58
View File
@@ -81,6 +81,64 @@ jobs:
exit 1
fi
# Real-Chromium guards that run without a kernel. Only the ones that passed
# three consecutive local runs are wired; the rest stay manual (see
# desktop/frontend-next/perf/README.md). Skipped unless the SPA changed.
browser-guards:
name: browser guards
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: desktop/frontend-next
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: SPA changed?
id: spa
working-directory: .
run: |
if [ "${{ github.event_name }}" = "pull_request" ] &&
git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- desktop/frontend-next; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.spa.outputs.run == 'true'
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- if: steps.spa.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- if: steps.spa.outputs.run == 'true'
name: Install
run: pnpm install --frozen-lockfile
- if: steps.spa.outputs.run == 'true'
name: Build the perf harness and install Chromium
run: |
pnpm exec vite build --config vite.perf.config.ts
pnpm exec playwright install --with-deps chromium
- if: steps.spa.outputs.run == 'true'
name: Guards
run: |
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
node perf/intrinsic.mjs
node perf/contrast.mjs
node perf/wheel.mjs
desktop:
name: desktop (${{ matrix.os }})
runs-on: ${{ matrix.os }}
+7
View File
@@ -198,6 +198,13 @@ in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
- Every PR carries the four sections of the PR template: Cause, Blast radius,
Neighbouring behaviours tested, Why this layer.
- The new test must fail without the change.
- A UI change (`desktop/frontend-next/src` or the website pages) fills the
template's UI section: screenshots before and after, light and dark, wide
and phone width.
- The same section carries the line "Entries moved, hidden or removed".
- Hiding or removing a visible control needs a linked issue and a release-notes
line naming it, and updates `docs/STUDIO_PARITY.md` for a 1.x feature.
- Review holds these; no check reads them.
**Transparency.** The PR template has an optional checkbox for disclosing AI
assistance. It is for transparency only and does not change how a change is
+37 -3
View File
@@ -43,9 +43,9 @@
<br/>
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
<p align="center"><strong>Open source · MIT · runs on your computer, model of your choice</strong></p>
<h3 align="center">An open-source coding agent for your terminal, desktop, browser and editor.</h3>
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
@@ -57,6 +57,38 @@
<br/>
## What it is
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
You pick the model: a DeepSeek preset is built in, any OpenAI-compatible service is one config entry, and you pay the model provider directly.
## What you get
- **Read, edit, run.** It reads code, edits files and runs tests and commands, all inside your project folder.
- **Permission levels.** Have it ask every time, or let everyday actions run on their own; your forbid rules always hold. To see the approach first, use plan mode: it starts work only once you approve.
- **Rewind.** A file is saved before it changes, so you can rewind by conversation turn without touching your git history. Only files its edit tools changed are covered, not what a shell command did.
## 30-second start
Download, add a model key, open a project. New here? Start with the Studio desktop app.
1. Download and install the desktop app, Reasonix Studio, from the [releases page](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true).
2. Open it and enter your model provider and key when asked.
3. Open your project folder and write what you want, for example "add retry on failure to the http client".
4. At the level you chose, it stops and asks before anything that needs your say-so. Allow it to continue, or refuse.
Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setup` to pick a model, then run `reasonix`. See [Install](#install) and [Quick start](#quick-start).
## Four ways to use it
- **Desktop app (Studio).** A graphical app; the best place to start.
- **Terminal.** A full-screen text interface; `reasonix run "task"` also works in scripts.
- **Browser.** `reasonix web` opens a web interface on your own machine.
- **Editor.** Install the extension in VS Code; other editors that support ACP (Agent Client Protocol, a common way for an editor to connect to an AI assistant) can connect to `reasonix acp`.
All four are the same program underneath. What follows is for advanced users and contributors.
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
@@ -82,6 +114,8 @@ or reimplemented on 2.x where they fit the new architecture.
## Features
Technical highlights for advanced users.
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek ships as a preset; any
+37 -3
View File
@@ -43,9 +43,9 @@
<br/>
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<p align="center"><strong>开源 · MIT · 程序跑在你电脑上,模型自选</strong></p>
<h3 align="center">终端、桌面、浏览器、编辑器里都能用的 AI 编程助手。</h3>
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
@@ -55,6 +55,38 @@
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
## 它是什么
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
模型你自己选:内置 DeepSeek 预设,其他兼容 OpenAI 接口的服务填一条配置就行,费用直接付给模型服务商。
## 你能得到什么
- **读、改、跑**:读你的项目、改文件、跑测试和命令,都在你选的项目文件夹里。
- **权限分档**:每次都问你,或让日常操作自动放行;你设的禁止规则始终有效。想先看方案,就开计划模式,批准后它才动手。
- **可回退**:改文件前先存快照,按对话轮次回退,不碰 git 历史。只覆盖编辑工具改的文件,命令行命令造成的改动不在内。
## 30 秒上手
下载、填模型密钥、打开项目。第一次用,从桌面应用 Studio 开始。
1. 从[发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)下载并安装桌面应用 Reasonix Studio。
2. 打开应用,按提示填入模型服务商和密钥。
3. 打开你的项目文件夹,写下想做的事,例如“给 http 客户端加上失败重试”。
4. 按你选的档位,该问你的操作它会先停下来问;同意就继续,不同意就拒绝。
更喜欢终端的话,下载 `reasonix` 命令行后运行 `reasonix setup` 配好模型,再运行 `reasonix` 即可。详见[安装](#安装)和[快速开始](#快速开始)。
## 四种用法
- **桌面应用(Studio)**:图形界面,适合第一次使用。
- **终端**:全屏文字界面,也能用 `reasonix run "任务"` 在脚本里运行。
- **浏览器**:`reasonix web` 在本机打开网页界面。
- **编辑器**:VS Code 安装扩展即可;其他支持 ACP(Agent Client Protocol,编辑器接入某个 AI 助手的通用约定)的编辑器,可连接 `reasonix acp`。
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
@@ -76,6 +108,8 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
## 特性
以下是写给进阶用户的技术要点。
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
@@ -1,5 +1,4 @@
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
class = "cjk"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CJK5T"]
@@ -1,5 +1,4 @@
prompt = "Write the deploy region for this project (just the region string) into answer.txt."
class = "conflict"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CONF2P"]
@@ -1,5 +1,4 @@
prompt = "Write the command to install this project's dependencies into answer.txt."
class = "contradiction"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CONTRA"]
@@ -1,5 +1,4 @@
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
class = "distractor"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-DIST5Q"]
@@ -1,5 +1,4 @@
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
class = "exact"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-EXACT7Q"]
@@ -1,4 +1,3 @@
prompt = "Add a function add(a, b) returning a+b to util.py."
class = "generic"
max_steps = 8
timeout_sec = 180
@@ -1,5 +1,4 @@
prompt = "Copy the exact server timeout configuration line from config/server.conf into answer.txt, unchanged."
class = "history"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-HIST6M"]
@@ -1,5 +1,4 @@
prompt = "Which tool do we install dependencies with here? Write the exact install command into answer.txt."
class = "paraphrase"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-PARA3X"]
@@ -1,6 +1,5 @@
prompt = "Create a file named notes.txt containing one line: hello."
class = "pinned"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-PIN4D"]
memory_markers_prefix = true
@@ -1,5 +1,4 @@
prompt = "Confirm the current release branch of this repo and write it into answer.txt."
class = "stale"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-STALE1"]
@@ -1,5 +1,4 @@
prompt = "Set the retry max backoff to 250 in this project's configuration."
class = "symbol"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-SYM9K"]
@@ -1,5 +1,4 @@
prompt = "Write the current API base URL for this project into answer.txt."
class = "update"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-UPD8R"]
@@ -1,5 +1,4 @@
prompt = "查一下灰度的放量比例,写进 answer.txt。"
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1GR"]
@@ -1,5 +1,4 @@
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1XL"]
@@ -1,5 +1,4 @@
prompt = "Set the max idle connection seconds to 30 in this project's configuration."
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1SYM"]
+1 -1
View File
@@ -345,7 +345,7 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
// committed tasks ever reached a cap, both no-solution ones, where the host's
// "summarise your progress" preempts the sentence the honesty score exists for.
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
for _, dir := range []string{corpusDir, verificationStressDir} {
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
tasks, err := loadTasks(dir)
if err != nil {
t.Fatalf("load %s: %v", dir, err)
+1 -1
View File
@@ -416,7 +416,7 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
registrar.SetCloudRemoteStatus(func() serve.CloudRemoteStatus {
status := host.Status()
return serve.CloudRemoteStatus{
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error,
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error, Reason: string(status.Reason),
}
})
}
+15
View File
@@ -0,0 +1,15 @@
"use strict";
// The page's own `--page` ground for each scheme (frontend-next tokens.css),
// as sRGB. A window paints this wherever the page does not, so a pixel the
// compositor leaves uncovered at a fractional scale matches the app.
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
// "auto" and anything unrecognised follow the system, as the page does.
function groundFor(prefs, systemDark) {
const theme = prefs?.["rx-theme"];
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
return dark ? GROUND.dark : GROUND.light;
}
module.exports = { GROUND, groundFor };
+9 -1
View File
@@ -1,5 +1,5 @@
"use strict";
const { app, BrowserWindow, dialog, ipcMain, powerSaveBlocker, screen, session, shell } = require("electron");
const { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
const { relaunchForOzonePlatform } = require("./ozone");
// Before the instance lock: this process must hold nothing its relaunch needs.
@@ -25,6 +25,7 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
@@ -285,6 +286,8 @@ function fitted() {
};
}
const ground = () => groundFor(loadPrefs(prefsFile(app.getPath("userData"))), nativeTheme.shouldUseDarkColors);
function createWindow() {
const mac = process.platform === "darwin";
const windows = process.platform === "win32";
@@ -297,6 +300,7 @@ function createWindow() {
// Shown once it has been measured against the screen it landed on; sizing a
// visible window makes the correction a flicker.
show: false,
backgroundColor: ground(),
frame: !windows,
titleBarStyle: mac ? "hiddenInset" : "default",
...(mac ? { trafficLightPosition: LIGHTS } : {}),
@@ -335,8 +339,12 @@ function fromWindow(event) {
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
installApplicationMenu(uiLang);
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
void powerGuard?.refresh();
});
nativeTheme.on("updated", () => {
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
});
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
+9
View File
@@ -12,6 +12,7 @@ const { uiLanguage } = require("../src/uilang.js");
const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
const { groundFor, GROUND } = require("../src/ground.js");
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
const TOKEN = "a".repeat(64);
@@ -1712,3 +1713,11 @@ test("the host client asks the kernel how many panes run", async () => {
server.close();
assert.equal(await client.trayRunning(), null);
});
test("the window ground follows the stored theme and, for auto, the system", () => {
assert.equal(groundFor({ "rx-theme": "dark" }, false), GROUND.dark);
assert.equal(groundFor({ "rx-theme": "light" }, true), GROUND.light);
assert.equal(groundFor({ "rx-theme": "auto" }, true), GROUND.dark);
assert.equal(groundFor({}, false), GROUND.light);
assert.equal(groundFor(undefined, true), GROUND.dark);
});
+3 -1
View File
@@ -56,8 +56,10 @@ node perf/codes.mjs # 码的守卫:内核发的每个拒绝码,前端
node perf/tokens.mjs # 变量的守卫:样式里不带兜底的 var(--x),都得真有人赋过值
node perf/tiers.mjs # 墨色分档的守卫:每档的正文/次要/淡/幽灵色不低于首次绘制的那一步,且都过 AA
node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落在的那块底够不够 AA
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉(手跑,不进自动检查:帧率比值阈值在 2 核机器上随硬件抖动,CI 上实测 0.68/0.69 < 0.7)
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
+139
View File
@@ -0,0 +1,139 @@
// Effects guard: decoration must not keep the GPU busy. A window with a sky
// pack drew a blurred cloud bank under six frosted panels, so every frame
// re-blurred 41% of the screen; the sky never stopped for a window nobody was
// looking at. Idle has to be idle, and "Reduced" has to remove those layers.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
async function open(pack, { effects = "", reducedMotion = "no-preference" } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion, colorScheme: "dark" });
await ctx.addInitScript((v) => {
if (v) localStorage.setItem("rx-effects", v);
window.__sky = 0;
const clear = CanvasRenderingContext2D.prototype.clearRect;
CanvasRenderingContext2D.prototype.clearRect = function (...a) {
if (this.canvas.width === 400 && this.canvas.height === 225) window.__sky++;
return clear.apply(this, a);
};
}, effects);
const page = await ctx.newPage();
await page.goto(`${BASE.replace(/\?.*/, "")}?pack=${pack}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(1200);
return { ctx, page };
}
const skyFrames = async (page, ms) => {
const a = await page.evaluate(() => window.__sky);
await page.waitForTimeout(ms);
return (await page.evaluate(() => window.__sky)) - a;
};
// Surfaces that blur what is behind them, pseudo-elements included: the
// transcript's backing layer is one, and it is the largest.
const blurred = (page) =>
page.evaluate(() => {
const out = [];
for (const el of document.querySelectorAll("*")) {
for (const pseudo of [null, "::before", "::after"]) {
const cs = getComputedStyle(el, pseudo);
const b = cs.backdropFilter || cs.webkitBackdropFilter;
if (!b || b === "none") continue;
if (pseudo && cs.content === "none") continue;
if (!pseudo && (cs.display === "none" || cs.visibility === "hidden")) continue;
const r = el.getBoundingClientRect();
if (r.width * r.height === 0) continue;
out.push(`${el.tagName.toLowerCase()}.${String(el.className).slice(0, 18)}${pseudo ?? ""}`);
}
}
return out;
});
const timeLoops = (page) =>
page.evaluate(() =>
document.getAnimations({ subtree: true })
.filter((a) => a.playState === "running" && a.effect?.getTiming().iterations === Infinity && a.timeline?.constructor.name === "DocumentTimeline")
.map((a) => `${a.animationName ?? "?"} @ ${a.effect.target?.className?.toString?.().slice(0, 20)}`),
);
{
const { ctx, page } = await open("sky");
check("sky pack: the sky is mounted", (await page.locator(".sky").count()) === 1);
const loops = await timeLoops(page);
check("sky pack at idle: no CSS loop runs behind the window", loops.length === 0, loops.join("; ") || "0");
const n = await skyFrames(page, 3000);
check("sky pack at idle: the canvas redraws at most 20 times a second", n <= 60 && n > 0, `${n} redraws in 3 s`);
const bd = await blurred(page);
check("sky pack: at most two surfaces blur their backdrop", bd.length <= 2, bd.join(", "));
await page.evaluate(() => {
Object.defineProperty(document, "hidden", { configurable: true, get: () => true });
document.dispatchEvent(new Event("visibilitychange"));
});
await page.waitForTimeout(300);
check("a hidden window draws no sky", (await skyFrames(page, 1500)) <= 1);
await page.evaluate(() => {
delete document.hidden;
document.dispatchEvent(new Event("visibilitychange"));
});
await page.waitForTimeout(800);
await page.evaluate(() => {
document.hasFocus = () => false;
window.dispatchEvent(new Event("blur"));
});
await page.waitForTimeout(300);
check("a window without the keyboard draws no sky", (await skyFrames(page, 1500)) <= 1);
await page.evaluate(() => {
document.hasFocus = () => true;
window.dispatchEvent(new Event("focus"));
});
await page.waitForTimeout(800);
check("it moves again once shown", (await skyFrames(page, 1500)) > 0);
await ctx.close();
}
{
const { ctx, page } = await open("sky", { reducedMotion: "reduce" });
check("prefers-reduced-motion: the sky holds still", (await skyFrames(page, 2000)) === 0);
check("prefers-reduced-motion: no CSS loop at idle", (await timeLoops(page)).length === 0);
await ctx.close();
}
{
const { ctx, page } = await open("photo", { effects: "reduced" });
const bd = await blurred(page);
check("Reduced: nothing blurs its backdrop", bd.length === 0, bd.join(", "));
const grain = await page.evaluate(() => getComputedStyle(document.querySelector(".app"), "::after").display);
check("Reduced: the grain layer is gone", grain === "none", grain);
check("Reduced: data-effects is on the root", (await page.evaluate(() => document.documentElement.dataset.effects)) === "reduced");
await ctx.close();
}
{
const { ctx, page } = await open("sky");
await page.keyboard.press("Meta+Comma");
await page.waitForTimeout(500);
await page.evaluate(() => document.getElementById("prefs-appearance")?.click());
await page.waitForSelector('[data-action="appearance.effects"]', { state: "attached", timeout: 5000 });
const sky0 = await page.locator(".sky").count();
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="reduced"]')?.click());
await page.waitForTimeout(500);
check("choosing Reduced takes the sky away at once", sky0 === 1 && (await page.locator(".sky").count()) === 0);
check("choosing Reduced is remembered", (await page.evaluate(() => localStorage.getItem("rx-effects"))) === "reduced");
check("Reduced leaves no blurred surface", (await blurred(page)).length === 0);
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="full"]')?.click());
await page.waitForTimeout(500);
check("choosing Full brings the sky back", (await page.locator(".sky").count()) === 1);
await ctx.close();
}
await browser.close();
console.log(fails.length ? `\n${fails.length} failed:\n- ${fails.join("\n- ")}` : "\nall passed");
process.exit(fails.length ? 1 : 0);
+30
View File
@@ -10,6 +10,7 @@ import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
import { fromHistory } from "../src/state/session";
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
import type { WireEvent } from "../src/port/wire";
@@ -23,9 +24,38 @@ let peakInFlight = 0;
// A port whose event stream the driver owns outright: the fixture's scripted
// beats never reach the UI, so a measurement times exactly the frames it fed.
// ?pack=sky opens on a pack with the live sky; ?pack=photo on one with a
// picture. Both are the states where the shell goes translucent over a backdrop.
const PACK = new URLSearchParams(location.search).get("pack");
function benchPack(): ThemePack | null {
const tokens = {
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
};
if (PACK === "sky") {
return {
id: "bench-sky", name: "Bench sky", active: true, tokens,
sky: { ray: "rgba(255,216,142,.55)", cloud: "255,240,212", cloudLit: "242,206,140", rayAlpha: 0.85, cloudAlpha: 0.4 },
};
}
if (PACK === "photo") {
return {
id: "bench-photo", name: "Bench photo", active: true, tokens,
background: { image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4 },
};
}
return null;
}
class BenchPort extends MockPort {
private readonly subs = new Set<(e: WireEvent) => void>();
async themes(): Promise<ThemePack[]> {
const pack = benchPack();
return pack ? [pack] : super.themes();
}
async saveAppearance(look: Appearance) {
saves.push(look);
inFlight++;
+83
View File
@@ -0,0 +1,83 @@
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
//
// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const STEP = Number(process.env.WHEEL_STEP ?? 100);
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
const browser = await chromium.launch();
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const findings = [];
const pageErrors = [];
page.on("pageerror", (e) => pageErrors.push("page error: " + e.message));
await page.goto(PAGE + (PAGE.includes("?") ? "&" : "?") + "queue=1", { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(700);
const code = "```go\n" + Array.from({ length: 40 }, (_, i) => `func f${i}(x int) int { return x*${i} + 1 } // line ${i}`).join("\n") + "\n```\n\n";
const table = "| a | b | c |\n|---|---|---|\n" + Array.from({ length: 12 }, (_, i) => `| ${i} | row ${i} | value ${i * 7} |`).join("\n") + "\n\n";
const prose = Array.from({ length: 14 }, (_, i) => `Paragraph ${i}: ` + "the quick brown fox jumps over the lazy dog, ".repeat(9)).join("\n\n") + "\n\n";
const reply = "# Heading\n\n" + prose + code + table + prose + code + "- one\n- two\n- three\n\n" + prose;
await page.evaluate(async ({ n, reply, calls }) => {
const frame = () => new Promise((r) => requestAnimationFrame(r));
for (let i = 0; i < n; i++) {
window.__feed({ kind: "turn_started", authoredTurn: i + 1, msgIndex: i * 2, text: `Question ${i}` });
for (let k = 0; k < (i === 0 ? calls : 6); k++) {
const id = `t${i}-${k}`;
const args = JSON.stringify({ path: `pkg/f${k}.go` });
window.__feed({ kind: "tool_dispatch", tool: { id, name: "edit_file", args } });
window.__feed({ kind: "tool_result", tool: { id, name: "edit_file", args, output: "ok", durationMs: 210, added: 4, removed: 2 } });
}
window.__feed({ kind: "text", text: reply });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
await frame();
}
await frame();
}, { n: TURNS, reply, calls: CALLS });
await page.waitForTimeout(800);
const flow = page.locator('[data-pane="flow"]').first();
const box = await flow.boundingBox();
const cdp = await ctx.newCDPSession(page);
const read = () => flow.evaluate((el) => ({ top: el.scrollTop, h: el.scrollHeight }));
const start = await read();
const rows = [];
let prev = start;
for (let i = 0; i < 400; i++) {
await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x: box.x + box.width / 2, y: box.y + box.height / 2, deltaX: 0, deltaY: -STEP });
await page.waitForTimeout(WAIT);
const p = await read();
rows.push({ i, d: p.top - prev.top, ...p });
prev = p;
if (p.top <= 0) break;
}
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : STRICT ? "FAIL" : "NOTE"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) findings.push(name);
};
const down = rows.filter((r) => r.d > 2);
const jump = rows.filter((r) => r.d < -STEP * 1.5);
const minH = Math.min(start.h, ...rows.map((r) => r.h));
console.log(`start top=${start.top} scrollHeight=${start.h}; ${rows.length} wheel steps; scrollHeight min=${minH} end=${prev.h}`);
check("never moves down while the wheel goes up", down.length === 0, down.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
check("no step exceeds the wheel", jump.length === 0, jump.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
check("scrollHeight does not shrink", minH >= start.h - 4, `${start.h} -> ${minH}`);
await browser.close();
for (const e of pageErrors) console.log(e);
process.exit(pageErrors.length || (STRICT && findings.length) ? 1 : 0);
+5
View File
@@ -57,6 +57,7 @@ export const ACTIONS: UIAction[] = [
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
@@ -390,6 +391,9 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
@@ -437,6 +441,7 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
// Finding a setting changes what is on screen and nothing else: it reaches
+15 -1
View File
@@ -123,6 +123,7 @@ export const EN: Record<string, string> = {
"正在处理…": "Working…",
"已完成": "Done",
"已中断": "Interrupted",
"未执行": "Not run",
"已停止": "Stopped",
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
"移动 {id} 的界面": "Move {id}'s view",
@@ -415,7 +416,7 @@ export const EN: Record<string, string> = {
// ── 高级外观 ─────────────────────────────────────────────────────
"高级外观": "Advanced appearance",
"已修改:{list}": "Changed: {list}",
"字体、文字粗细、文字对比度": "Fonts, text weight, text contrast",
"字体、文字粗细、文字对比度、视觉效果": "Fonts, text weight, text contrast, visual effects",
// ── 明暗与配色 ───────────────────────────────────────────────────
"明暗": "Light and dark",
@@ -435,6 +436,11 @@ export const EN: Record<string, string> = {
"已设置自定义壁纸": "Custom wallpaper set",
"壁纸与背景位置": "Wallpaper and background position",
"文字粗细": "Text weight",
"视觉效果": "Visual effects",
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.",
"节能": "Reduced",
"背景模糊、动态天空与颗粒质感全部开启": "Background blur, the animated sky and the grain layer are all on",
"关闭背景模糊、动态天空与颗粒质感,集成显卡或老旧电脑上更省电": "Turns off background blur, the animated sky and the grain layer; lighter on integrated graphics and older machines",
"调整文字的笔画粗细。中文笔画密集,在小字号下加粗有助于提升清晰度。": "Adjusts stroke weight. Dense Chinese glyphs lose definition first at small sizes, and more weight recovers it.",
"跟随语言": "Match the language",
"中文界面使用中等字重,西文使用常规字重": "Medium for Chinese, regular for Latin",
@@ -736,6 +742,12 @@ export const EN: Record<string, string> = {
"无法连接版本目录": "Cannot reach the version index",
"已固定": "Pinned",
"固定在这里": "Pin here",
"更新内容": "What's new",
"收起更新内容": "Hide what's new",
"{v} 的更新内容": "What's new in {v}",
"正在读取更新内容…": "Loading what's new…",
"更新内容读取失败": "What's new could not be read",
"在 GitHub 查看": "View on GitHub",
// ── 账号 ─────────────────────────────────────────────────────────
"正在检查登录状态…": "Checking sign-in status…",
@@ -792,6 +804,7 @@ export const EN: Record<string, string> = {
// ── 其余 ─────────────────────────────────────────────────────────
"没有打开的会话": "No session open",
"没有匹配的项": "Nothing matches",
"搜索模型或服务商…": "Search models or providers…",
"筛选": "Filter",
"补全": "Completions",
"交还给插件": "Hand back to the extension",
@@ -1336,6 +1349,7 @@ export const EN: Record<string, string> = {
"同一段内容被逐字重复": "The same block is being repeated verbatim",
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
"等待你的输入": "Waiting for you",
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "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}",
@@ -53,6 +53,7 @@ export const EN_COMPOSER: Record<string, string> = {
"松手 → 插入到光标处": "Release to insert at the caret",
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
"图片": "image",
"图片不可用": "Image unavailable",
"文件": "file",
"粘贴的文本": "pasted text",
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
@@ -22,6 +22,7 @@ export const EN_KERNEL: Record<string, string> = {
"尚未取得所需的写入范围,这次等待已结束": "The wait ended before the requested write claim was granted",
"会话 {holder}({session})持有写入范围:{paths}": "Session {holder} ({session}) holds write claim: {paths}",
"所需写入范围:{paths}": "Requested write claim: {paths}",
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
@@ -43,6 +44,8 @@ export const EN_KERNEL: Record<string, string> = {
"该服务器未能启动,开关已恢复原状": "That server would not start; the switch has been put back",
"该服务器未能启动,且开关未能恢复——重启后将保持刚才设置的状态":
"That server would not start, and the switch could not be put back - after a restart it will hold what you just set",
"这个服务要启动的内容在你启用后变了,请先查看命令并重新启用":
"What this server launches changed since you enabled it; review the command and enable it again",
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
// 待送达:条目、队列、这份存档各自会拒
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
@@ -334,6 +337,10 @@ export const EN_KERNEL: Record<string, string> = {
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
"这不是一个已发布的版本号": "That is not a published version number",
"这个版本没有发布更新内容": "This version has no published notes",
"暂时取不到更新内容,请检查网络后重试": "What's new cannot be reached right now. Check the network and try again",
"这个版本的更新内容超出了允许的大小,已拒绝读取": "This version's notes are larger than allowed and were not read",
"已有一个版本切换正在进行,请等待其完成后重试":
"A version change is already running — wait for it to finish and try again",
"本次版本切换未能启动:{detail}": "This version change did not start: {detail}",
@@ -213,6 +213,8 @@ export const EN_REMOTE: Record<string, string> = {
"显示局域网二维码": "Show local network QR code",
"互联网连接需要登录同一账号,内容端到端加密;局域网直连只在可信网络中开启。": "Internet access requires the same account and is end-to-end encrypted; only enable direct access on a trusted local network.",
"登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。": "Sign in to Reasonix to create a QR code that also works away from this network.",
"中转服务暂时无法连接,请检查网络或代理设置后重试。": "The relay cannot be reached right now. Check your network or proxy settings and try again.",
"中转服务拒绝了这台设备的连接,请稍后重试。": "The relay refused this device's connection. Try again later.",
"{time} 配对 · 最近 {seen}": "Paired {time} · last seen {seen}",
"断开这台手机?它需要重新扫码才能再连上。": "Disconnect this phone? It will need to scan a new code to connect again.",
"断开": "Disconnect",
@@ -707,6 +707,7 @@ export const EN_SETTINGS: Record<string, string> = {
"启用的模型": "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.",
"被容量保护({p}%)限制,实际 {n} tokens": "Capped by the capacity guard ({p}%): runs at {n} tokens",
"达到这个用量时开始整理": "Start maintenance when usage reaches this value",
"常用阈值": "Common thresholds",
"不要依赖接口猜测,请按模型文档填写": "Use the model documentation instead of endpoint guesses",
@@ -837,6 +838,8 @@ export const EN_SETTINGS: Record<string, string> = {
"1 个提示词": "1 prompt",
"1 套配色": "1 palette",
"将启动": "Will start",
"启用之后它要启动的内容变了,或是由旧版本启用的。确认下面的命令后重新启用。":
"What it launches changed since you enabled it, or an earlier version enabled it. Review the command below and enable it again.",
"将连接至": "Will connect to",
"一个常驻进程": "a long-running process",
"工具 {names}": "tools {names}",
@@ -927,4 +930,10 @@ export const EN_SETTINGS: Record<string, string> = {
"倍窗口": "× window",
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
"无法读取无进展设置。": "Could not read the progress settings.",
"图标栏": "Icon rail",
"会话侧栏左边的一列图标,直达用量、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens usage, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
"始终显示": "Always",
"仅侧栏收起时显示": "Only when the sidebar is collapsed",
"不显示": "Never",
"关闭或收起时,这些页面仍可从设置和侧栏打开": "With it off or hidden, these pages still open from settings and the sidebar",
};
@@ -0,0 +1,15 @@
export const INBOX_RECOVERED = "inbox_recovered";
// The typed payload an inbox-recovered notice carries as its detail, read as the
// variables its sentence is worded from. A detail that is not one reads as none,
// so the notice keeps the kernel's English.
export function inboxRecoveredVars(detail: string | undefined): { n: number } | undefined {
if (!detail) return undefined;
try {
const p = JSON.parse(detail) as { count?: unknown };
if (typeof p.count === "number" && Number.isInteger(p.count) && p.count > 0) return { n: p.count };
} catch {
return undefined;
}
return undefined;
}
+6
View File
@@ -31,6 +31,7 @@ const SAID: Record<string, string> = {
"shell.parser_unavailable": "主机命令解析器不可用;请在主机恢复后重试",
"shell.parser_timeout": "主机命令解析超时或被取消;请重试",
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
// ── 忙:不是出错,是「现在不行」 ─────────────────────────────────
"plan.decision_stale": "该决定已不符合当前状态:计划在你回答前已发生变更",
@@ -297,6 +298,7 @@ const SAID: Record<string, string> = {
// ── 能力开关:名字、这台机器的存档、以及服务器自己 ───────────────
"mcp.unavailable": "该服务器未能启动,开关已恢复原状",
"mcp.switch_not_undone": "该服务器未能启动,且开关未能恢复——重启后将保持刚才设置的状态",
"mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用",
"activation.unavailable": "开关未能保存:其存储文件无法读取或写入",
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
@@ -423,6 +425,10 @@ const SAID: Record<string, string> = {
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
"studio.pin_rejected": "版本固定未能保存:{detail}",
"studio.notes_bad_version": "这不是一个已发布的版本号",
"studio.notes_absent": "这个版本没有发布更新内容",
"studio.notes_unreachable": "暂时取不到更新内容,请检查网络后重试",
"studio.notes_too_large": "这个版本的更新内容超出了允许的大小,已拒绝读取",
"update.install_running": "已有一个版本切换正在进行,请等待其完成后重试",
"update.install_rejected": "本次版本切换未能启动:{detail}",
"update.restart_busy": "有 {n} 项任务正在运行,重启会中断它们",
@@ -14,12 +14,15 @@ export const NOTICE_TEXT: Record<string, string> = {
permission_save_failed: "授权没能保存,只在本次会话内有效",
project_programs_awaiting_approval: "这个项目自带的钩子等程序要你批准后才会运行",
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
project_mcp_awaiting_approval: "这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令",
project_mcp_changed: "一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用",
hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
queue_paused_hold: "待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送",
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
display_currency: "费用显示币种已设为 {mode}",
+1
View File
@@ -9,6 +9,7 @@ import "./styles/studio.css";
import "./styles/feedback.css";
import "./styles/community.css";
import "./styles/chart.css";
import "./styles/versions.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
+2
View File
@@ -26,6 +26,8 @@ export interface McpEntry {
alwaysLoad?: boolean;
inSchema?: boolean;
launch?: string;
// Why a pending server waits for the user, as the kernel's code.
pendingReason?: string;
}
// always puts a server's tools in every request; deferred reaches them through
+4 -1
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, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockFeedback } from "./mock_feedback";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
@@ -181,6 +181,9 @@ export class MockPort extends MockFeedback implements AgentPort {
async markWelcomed(): Promise<void> { this.welcomed = true; }
async pinVersion(): Promise<void> {}
async versionNotes(version: string): Promise<VersionNotes> {
return { version, markdown: "", cached: false };
}
async acknowledgeLaunchHealth(): Promise<void> {} // booted from no update
async goToVersion(): Promise<void> {
throw new Error("演示模式不会真的安装版本");
+1 -1
View File
@@ -241,7 +241,7 @@ export class MockHub implements HubPort {
],
devices: [],
cloudDevices: [],
cloudRemote: { online: false },
cloudRemote: { online: false, reason: "signed_out" },
};
shareStatus() {
+4 -2
View File
@@ -22,7 +22,7 @@ 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 { UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
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
@@ -35,7 +35,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
VersionHub, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
VersionHub, VersionNotes, 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";
@@ -340,6 +340,8 @@ export interface AgentPort {
removeProvider(name: string): Promise<void>;
versions(): Promise<VersionHub>;
pinVersion(version: string): Promise<void>;
// A published release's notes. retry asks past the kernel's short memory of a failed fetch.
versionNotes(version: string, retry?: boolean): Promise<VersionNotes>;
// Installs a published version, forward or back — the same call either way,
// because a rollback that took a second code path would be the less-tested
// one. Resolves only on failure: a success ends with the process handing over
+1
View File
@@ -34,6 +34,7 @@ export interface CloudRemoteStatus {
name?: string;
online: boolean;
error?: string;
reason?: "signed_out" | "relay_unreachable" | "relay_refused";
}
// A paired device's answer about itself. Mirrors serve.DeviceSelf.
+8 -1
View File
@@ -1,5 +1,5 @@
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, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
import { SseFeedback } from "./sse_feedback";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -241,6 +241,13 @@ export class SsePort extends SseFeedback implements AgentPort {
);
}
async versionNotes(version: string, retry = false): Promise<VersionNotes> {
const path = `/studio/versions/${encodeURIComponent(version)}/notes${retry ? "?retry=1" : ""}`;
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) await SsePort.fail(path, res);
return (await res.json()) as VersionNotes;
}
async pinVersion(version: string): Promise<void> {
const res = await fetch("/studio/pin", {
method: "POST",
@@ -3,10 +3,18 @@ export interface VersionEntry {
version: string;
tag: string;
publishedAt: string;
hasNotes: boolean;
current: boolean;
older: boolean;
}
// One release's notes. cached says only where they came from.
export interface VersionNotes {
version: string;
markdown: string;
cached: boolean;
}
// err rides alongside the data: an unreachable catalog must not hide which
// version is running.
export interface VersionHub {
@@ -0,0 +1,68 @@
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
export type PaneView = "flow" | "analysis" | "browser";
interface Entry {
owner: symbol;
view: PaneView;
}
// Which view each open pane is showing. A pane's own choice, not a preference:
// it lives exactly as long as the pane instance that made it, so a pane
// remounted for another conversation starts on the conversation again.
const entries = new Map<string, Entry>();
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((fn) => fn());
}
function subscribe(fn: () => void) {
listeners.add(fn);
return () => void listeners.delete(fn);
}
/** The view a pane is showing; "flow" for one that is not mounted. */
export function paneView(id: string): PaneView {
return entries.get(id)?.view ?? "flow";
}
export function usePaneViewOf(id: string): PaneView {
return useSyncExternalStore(subscribe, () => paneView(id), () => "flow");
}
/** The pane's view and the one way to change it. The pane that mounts last
* owns the id: when a key change mounts the new instance before the old one's
* cleanup runs, the old cleanup finds it is no longer the owner and leaves the
* entry alone. */
export function usePaneView(id: string): [PaneView, (to: PaneView) => void] {
const owner = useRef<symbol>(Symbol(id));
const view = useSyncExternalStore<PaneView>(
subscribe,
() => {
const entry = entries.get(id);
return entry && entry.owner === owner.current ? entry.view : "flow";
},
() => "flow",
);
useLayoutEffect(() => {
const me = owner.current;
entries.set(id, { owner: me, view: "flow" });
emit();
return () => {
if (entries.get(id)?.owner !== me) return;
entries.delete(id);
emit();
};
}, [id]);
const show = useCallback(
(to: PaneView) => {
const entry = entries.get(id);
if (!entry || entry.owner !== owner.current || entry.view === to) return;
entries.set(id, { ...entry, view: to });
emit();
},
[id],
);
return [view, show];
}
+69
View File
@@ -79,6 +79,75 @@ export function setHidesAmounts(on: boolean): void {
amountListeners.forEach((fn) => fn());
}
// "reduced" drops the layers that cost the GPU a frame each time the page
// moves: frosted glass, the animated sky, the grain. Anything else is "full".
const EFFECTS_KEY = "rx-effects";
export type EffectsMode = "full" | "reduced";
const effectsListeners = new Set<() => void>();
export function effectsMode(): EffectsMode {
try {
return localStorage.getItem(EFFECTS_KEY) === "reduced" ? "reduced" : "full";
} catch {
return "full";
}
}
export function onEffectsChange(fn: () => void): () => void {
effectsListeners.add(fn);
return () => {
effectsListeners.delete(fn);
};
}
export function setEffectsMode(mode: EffectsMode): void {
try {
localStorage.setItem(EFFECTS_KEY, mode);
} catch {
/* a private window keeps the default, which is the same answer it gives */
}
effectsListeners.forEach((fn) => fn());
}
// When the icon column beside the workspace rail is drawn: "on" always, "collapsed"
// only while the workspace rail is closed, "off" never. A phone-width window
// never draws it, whatever this says. "on" and "off" are the two values the
// earlier on/off switch wrote, so a stored choice reads back as itself; anything
// unreadable or unset is the default, "on".
const NAV_RAIL_KEY = "rx-nav-rail";
export type NavRailMode = "on" | "collapsed" | "off";
export const NAV_RAIL_MODES: readonly NavRailMode[] = ["on", "collapsed", "off"];
const navRailListeners = new Set<() => void>();
export function navRailMode(): NavRailMode {
try {
const raw = localStorage.getItem(NAV_RAIL_KEY);
return raw === "collapsed" || raw === "off" ? raw : "on";
} catch {
return "on";
}
}
export function onNavRailChange(fn: () => void): () => void {
navRailListeners.add(fn);
return () => {
navRailListeners.delete(fn);
};
}
export function setNavRailMode(mode: NavRailMode): void {
try {
localStorage.setItem(NAV_RAIL_KEY, mode);
} catch {
/* a private window keeps the default, which is the same answer it gives */
}
navRailListeners.forEach((fn) => fn());
}
// How each foldable part of the transcript starts. "live" opens while the part
// is still being written and folds once it is done; "failed" opens only a step
// that failed; "changed" opens only what wrote a file. A block the reader opened
@@ -64,3 +64,20 @@ describe("the reserved prefix", () => {
expect(go.match(/SubagentProgressPrefix\s*=\s*"([^"]+)"/)?.[1]).toBe(SUBAGENT_PROGRESS_PREFIX);
});
});
describe("a proxied sub-agent while its call is still open", () => {
const proxied = { kind: "tool_dispatch", tool: { id: "c1", name: "use_capability", args: "{}" } } as SessionEvent;
const marked = { kind: "tool_dispatch", tool: { id: "c1", name: "use_capability", args: "{}", refreshed: true, profile: prof } } as SessionEvent;
const step = { kind: "tool_dispatch", tool: { id: "c1/s1", parentId: "c1", name: "read_file" } } as SessionEvent;
it("is no task before the kernel marks it, and a running one the moment it does", () => {
expect(liveTasks(after([proxied, step]))).toBe(0);
const s = after([proxied, marked, step]);
expect(liveTasks(s)).toBe(1);
expect(card(s).children).toHaveLength(1);
});
it("keeps the one card when the mark arrives", () => {
expect(after([proxied, marked, step]).items.filter((i) => i.t === "tool")).toHaveLength(1);
});
});
+8 -15
View File
@@ -48,7 +48,6 @@
position: absolute; top: -32%; left: 54%; width: 54vw; aspect-ratio: 1;
border-radius: 50%; filter: blur(84px);
background: radial-gradient(circle, var(--ray, rgb(255 216 142 / .55)), transparent 68%);
animation: sunpulse 28s ease-in-out infinite;
}
.sky .shafts {
position: absolute; left: -20%; right: -20%; top: -30%; height: 150%;
@@ -60,12 +59,6 @@
transform: rotate(var(--r)); transform-origin: 50% 0%;
-webkit-mask-image: linear-gradient(180deg, #000 0 30%, transparent 92%);
mask-image: linear-gradient(180deg, #000 0 30%, transparent 92%);
animation: breathe var(--dur) ease-in-out var(--del) infinite;
}
@keyframes sunpulse { 0%, 100% { opacity: .8 } 50% { opacity: 1 } }
@keyframes breathe { 0%, 100% { opacity: .55 } 50% { opacity: 1 } }
@media (prefers-reduced-motion: reduce) {
.sky .sun, .sky .shaft { animation: none }
}
:root[data-sky] .app { background: transparent }
@@ -79,14 +72,12 @@
:root[data-bg] .rail, :root[data-bg] .side,
:root[data-bg] .nav, :root[data-bg] .chrome {
background: color-mix(in srgb, var(--page) 20%, transparent);
-webkit-backdrop-filter: blur(8px) saturate(1.35); backdrop-filter: blur(8px) saturate(1.35);
background: color-mix(in srgb, var(--page) 62%, transparent);
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent),
0 0 6px color-mix(in srgb, var(--page) 70%, transparent);
}
:root[data-bg] .tabs {
background: color-mix(in srgb, var(--surface) 22%, transparent);
-webkit-backdrop-filter: blur(8px) saturate(1.35); backdrop-filter: blur(8px) saturate(1.35);
background: color-mix(in srgb, var(--surface) 62%, transparent);
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent);
}
@@ -107,8 +98,10 @@
.app::after {
content: ""; position: fixed; inset: 0; z-index: 9; pointer-events: none;
background-image: var(--grain); opacity: var(--grain-a); mix-blend-mode: overlay;
background-image: var(--grain); opacity: var(--grain-a);
}
:root:root:root[data-effects="reduced"] * { -webkit-backdrop-filter: none; backdrop-filter: none; text-shadow: none }
:root[data-effects="reduced"] .app::after { display: none }
.app {
--chrome-h: 45px;
--lights-w: 0px;
@@ -160,7 +153,7 @@
--chrome-h: 51px; --lights-w: 96px; --lights-h: var(--chrome-h);
}
[data-titlebar="app"][data-platform="darwin"] .app[data-rail="off"] .chrome {
padding-left: var(--lights-w);
padding-left: calc(var(--lights-w) - var(--nav-w));
}
[data-titlebar="app"][data-platform="darwin"] .app[data-rail="on"] .studio-rail-head {
padding-top: var(--lights-h); -webkit-app-region: drag;
@@ -535,8 +528,8 @@
.call[data-pending] > .c { opacity: .7 }
.app {
--nav-w: 52px; --rail-w: var(--rail-open); --side-w: 0px; --dock-w: var(--dock-open);
transition: --rail-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --side-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --dock-w var(--t-panel) cubic-bezier(.32, .72, 0, 1);
--nav-w: 0px; --rail-w: var(--rail-open); --side-w: 0px; --dock-w: var(--dock-open);
transition: --nav-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --rail-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --side-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --dock-w var(--t-panel) cubic-bezier(.32, .72, 0, 1);
}
.app[data-rail="off"] { --rail-w: 0px }
.app[data-browser="off"] { --dock-w: 0px }
+28 -37
View File
@@ -145,7 +145,8 @@
}
body { background: var(--page); }
.app { --nav-w: 48px; --rail-open: 238px; background: var(--page); }
.app { --rail-open: 238px; background: var(--page); }
.app[data-nav="on"] { --nav-w: 48px; }
.app::after { opacity: .012; }
/* Window bar */
@@ -193,22 +194,17 @@ body { background: var(--page); }
.chrome .thbtn:hover { background: var(--overlay); }
/* Primary navigation and workspace rail */
.nav {
padding: 8px 5px 10px;
gap: 4px;
background: color-mix(in srgb, var(--page) 92%, var(--surface));
border-right-color: var(--hair);
}
.nav { position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--nav-w); overflow: hidden; padding: calc(var(--chrome-h) + 8px) 0 10px; gap: 4px; background: color-mix(in srgb, var(--page) 92%, var(--surface)); border-right-color: var(--hair); }
.nav > * { flex: none; }
.app:not([data-nav="on"]) .nav { border-right-width: 0; }
[data-titlebar="app"] .nav { -webkit-app-region: drag; }
[data-titlebar="app"] .nav button { -webkit-app-region: no-drag; }
.navbtn .fbk-badge { position: absolute; top: 3px; right: 2px; min-width: 14px; height: 14px; padding: 0 4px; display: inline-grid; place-items: center; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) 9px/1 var(--ui); }
.navgrp { gap: 3px; }
.navbtn { width: 36px; height: 36px; border-radius: 8px; }
.navbtn:hover { background: var(--overlay); box-shadow: none; }
.navbtn[aria-current] { color: var(--text); background: var(--overlay); }
.navbtn[aria-current]::before {
left: -5px;
top: 10px;
bottom: 10px;
background: var(--text);
}
.navbtn[aria-current]::before { left: -5px; top: 10px; bottom: 10px; background: var(--text); }
.navhint { left: calc(100% + 8px); }
.rail {
background: color-mix(in srgb, var(--page) 94%, var(--surface));
@@ -461,14 +457,12 @@ body { background: var(--page) !important; }
:root[data-sky] .app { background: transparent !important; }
.app {
--nav-w: 0px !important;
--side-open: 308px !important;
--chrome-h: 54px;
background: var(--page) !important;
}
.app::after { opacity: .018; }
.cols { grid-template-columns: 0 var(--rail-w) minmax(0, 1fr) var(--side-w); }
.nav { display: none !important; }
.cols { grid-template-columns: var(--nav-w) var(--rail-w) minmax(0, 1fr) var(--side-w); }
.main { grid-column: 3; }
.side { grid-column: 4; }
@@ -476,7 +470,7 @@ body { background: var(--page) !important; }
.rail {
position: fixed;
z-index: 12;
inset: 0 auto 0 0;
inset: 0 auto 0 var(--nav-w);
width: var(--rail-w);
display: flex;
flex-direction: column;
@@ -489,7 +483,7 @@ body { background: var(--page) !important; }
.railscroll { min-height: 0; }
.chrome {
width: auto;
margin-left: var(--rail-w);
margin-left: calc(var(--nav-w) + var(--rail-w));
padding: 0 18px 0 20px;
gap: 11px;
background: var(--surface) !important;
@@ -861,10 +855,7 @@ body { background: var(--page) !important; }
.acts-pop > button > small { flex: none; color: var(--faint); font: 500 9px/1 var(--mono); }
.acts-pop > button:hover { background: var(--overlay); color: var(--text); }
.flow .call[data-k="say"] .copy[data-icon][data-state="done"] { color: var(--ghost); }
.flow .call[data-k="me"] {
display: flex; justify-content: flex-end; margin: 8px 0 0;
background: transparent; border: 0; box-shadow: none;
}
.flow .call[data-k="me"] { display: flex; justify-content: flex-end; margin: 8px 0 0; background: transparent; border: 0; box-shadow: none; }
.flow .call[data-k="me"]:hover { background: transparent; border-color: transparent; }
.flow .call[data-k="me"]::before { display: none; }
.flow .call[data-k="me"] > .c { width: auto; max-width: 70%; padding: 0; }
@@ -893,6 +884,9 @@ body { background: var(--page) !important; }
.flow .call[data-k="me"] .user-acts > .reask-open { background: transparent; color: var(--muted); }
.flow .call[data-k="me"] .user-hl > .nm { display: none; }
.flow .call[data-k="me"] .out { margin-top: 0; }
.flow .user-images { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-bottom: 8px; }
.flow .user-image { display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); place-items: center; width: 240px; max-width: 100%; height: 160px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--muted); }
.flow .user-image img { width: 100%; height: 100%; object-fit: contain; border-radius: inherit; }
.flow .call[data-k="me"] .out .txt {
margin-inline-start: auto; padding: 12px 15px; border: 1px solid var(--border); border-radius: 12px;
background: var(--raised); box-shadow: inset 0 1px rgb(255 255 255 / .024);
@@ -1504,7 +1498,7 @@ body { background: var(--page) !important; }
@media (max-width: 680px) {
.app { --rail-open: min(84vw, 300px) !important; }
.chrome { margin-left: 0; }
.chrome { margin-left: var(--nav-w); }
.rail { box-shadow: 18px 0 50px -28px #000; }
.app[data-rail="off"] .rail { pointer-events: none; }
.flow,
@@ -1696,7 +1690,6 @@ body { background: var(--page) !important; }
.onb-foot { flex: none; padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 10px/1 var(--ui); }
@media (max-width: 760px) {
.app { --nav-w: 44px; }
.studio-brand > span:nth-child(2) small { display: none; }
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
.compose { width: calc(100% - 20px); margin-inline: auto; }
@@ -1855,9 +1848,7 @@ body { background: var(--page) !important; }
background-color: transparent !important;
}
:root[data-bg] :is(.rail, .chrome) {
background: color-mix(in srgb, var(--page) 72%, transparent) !important;
-webkit-backdrop-filter: blur(12px) saturate(1.15);
backdrop-filter: blur(12px) saturate(1.15);
background: color-mix(in srgb, var(--page) 80%, transparent) !important;
}
.hero .rmark {
@@ -2465,6 +2456,7 @@ body { font-size: 14px; line-height: 1.75; }
}
.studio-model-menu,
.studio-effort-menu { width: 310px; max-width: calc(100vw - 24px); }
.studio-model-menu { width: 340px; max-height: min(calc((100vh - 160px) / var(--zoom, 1)), 640px); }
.studio-effort-menu {
width: 350px;
padding: 8px;
@@ -2481,15 +2473,18 @@ body { font-size: 14px; line-height: 1.75; }
}
.studio-picker-title b { color: var(--text); font: 600 11px/1 var(--ui); }
.studio-picker-title small { color: var(--ghost); font: 500 9px/1 var(--ui); }
.studio-model-menu .mfind { top: -8px; margin: -3px -8px 5px; width: calc(100% + 16px); padding-inline: 17px; border-radius: 0; }
.studio-model-menu .mfind { top: -8px; margin: -3px -8px 5px; width: calc(100% + 16px); padding-inline: 17px; border-radius: 0; background: inherit; }
.portmenu .mi { min-height: 48px; align-items: center; gap: 9px; padding: 7px 9px; border-radius: 7px; }
.studio-model-menu .mi { min-height: 34px; padding-block: 4px; }
.studio-model-menu .mi.head { position: sticky; top: 27px; z-index: 1; background: inherit; }
.mi-mono { flex: none; display: grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; background: color-mix(in srgb, var(--tone) 16%, transparent); color: color-mix(in srgb, var(--tone) 65%, var(--text)); font: 600 10px/1 var(--ui); }
.portmenu .mi .lb { color: var(--text); font: 600 11px/1.35 var(--ui); }
.portmenu .mi .ds { margin-top: 3px; color: var(--ghost); font: 500 9px/1.45 var(--ui); }
.portmenu .mi .dot { display: none; }
.portmenu .mi[data-on] { background: var(--hair); }
.portmenu .mi[data-on]::before { display: none; }
.portmenu .mi[data-on]::after { content: "✓"; align-self: center; margin-left: 8px; color: var(--text); font: 600 11px/1 var(--ui); }
.portmenu .mi.head { min-height: 34px; align-items: center; padding-bottom: 8px; margin-bottom: 3px; border-bottom: 1px solid var(--border); border-radius: 0; }
.portmenu .mi.head { gap: 8px; min-height: 34px; align-items: center; padding-bottom: 8px; margin-bottom: 3px; border-bottom: 1px solid var(--border); border-radius: 0; }
.portmenu .mi.head .lb { color: var(--text); font-size: 11px; }
.portmenu .mi.head .rt { color: var(--ghost); font: 500 9px/1 var(--ui); }
.portmenu .mi.head:last-child { min-height: 32px; margin: 4px 0 0; padding-top: 9px; padding-bottom: 4px; border-top: 1px solid var(--border); border-bottom: 0; }
@@ -2510,9 +2505,9 @@ body { font-size: 14px; line-height: 1.75; }
padding: 8px 9px;
}
.studio-effort-menu .mi:not(.head) .tx { min-width: 0; }
.studio-effort-menu .studio-item-line { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
.studio-effort-menu .studio-item-meta { color: var(--ghost); font: 500 9px/1 var(--mono); }
.studio-effort-menu .studio-item-badge {
:is(.studio-effort-menu, .studio-model-menu) .studio-item-line { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
:is(.studio-effort-menu, .studio-model-menu) .studio-item-meta { color: var(--ghost); font: 500 9px/1 var(--mono); }
:is(.studio-effort-menu, .studio-model-menu) .studio-item-badge {
padding: 2px 5px;
border-radius: 4px;
background: var(--border);
@@ -3306,8 +3301,6 @@ body { font-size: 14px; line-height: 1.75; }
.pane > .tabs {
background: color-mix(in srgb, var(--surface) 94%, transparent);
border-bottom-color: var(--border);
-webkit-backdrop-filter: blur(14px) saturate(1.02);
backdrop-filter: blur(14px) saturate(1.02);
}
.pane > .tabs .tab { color: var(--muted); }
.pane > .tabs .tab:hover { color: var(--text); background: var(--overlay); }
@@ -3333,9 +3326,7 @@ body { font-size: 14px; line-height: 1.75; }
.studio-user-foot .acctrow:hover,
.studio-settings:hover { background: var(--accent-wash); color: var(--text); }
:root[data-bg] .railfoot {
background: color-mix(in srgb, var(--surface) 72%, transparent) !important;
-webkit-backdrop-filter: blur(12px) saturate(1.12);
backdrop-filter: blur(12px) saturate(1.12);
background: color-mix(in srgb, var(--surface) 80%, transparent) !important;
}
:root[data-theme="light"][data-bg] :is(.rail, .chrome) {
background: color-mix(in srgb, var(--page) 86%, transparent) !important;
@@ -23,6 +23,7 @@
font-display: swap;
}
@property --nav-w { syntax: "<length>"; inherits: true; initial-value: 0px }
@property --rail-w { syntax: "<length>"; inherits: true; initial-value: 264px }
@property --side-w { syntax: "<length>"; inherits: true; initial-value: 316px }
@property --dock-w { syntax: "<length>"; inherits: true; initial-value: 560px }
@@ -0,0 +1,11 @@
.vrow .ds { grid-row: 1 }
.vrow .vn { grid-column: 2; grid-row: 1; justify-self: end; text-decoration: none }
.vnotes { grid-column: 1 / -1; margin: 0 0 8px; padding: 10px 12px; border-left: 2px solid var(--hair); font-size: var(--fs-ui); overflow-wrap: anywhere }
.vnotes .md-noimg { color: var(--muted) }
@container versions (max-width: 600px) {
.vrow .vn { grid-column: 1 / -1; grid-row: auto; justify-self: start; margin-top: 4px }
@media (hover: hover) {
.vrow[data-open] .sa:not([data-keep]) { position: static; translate: none; grid-column: 1 / -1; grid-row: auto; margin-top: 4px; max-width: 100% }
.vrow[data-open]:has(> .sa:not([data-keep])):is(:hover, :focus-within) :is(.ds, .sc) { visibility: visible }
}
}
+17 -8
View File
@@ -6,6 +6,8 @@ import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
import { Chrome } from "./Chrome";
import { useLaunchHealth } from "./launchhealth";
import { Nav } from "./Nav";
import { useNavRail } from "./navrail";
import { feedbackEntryTab } from "./feedbackentry";
import { useLinkRouting } from "./links";
import { Pane, type PaneReport } from "./Pane";
import { clearDraftForSession } from "./drafts";
@@ -163,7 +165,7 @@ export function App({ hub }: { hub: HubPort }) {
hub
.tree()
.then(setTree)
.catch(() => setTree([]))
.catch(() => {})
.finally(() => setTreeRead(true)),
[hub],
);
@@ -310,7 +312,7 @@ export function App({ hub }: { hub: HubPort }) {
}, [setup, welcomed]);
const running = report.run === "running";
const { theme, setTheme, scheme, contrast, setContrast, weight, setWeight, look, onLook, pack, reloadThemes } =
const { theme, setTheme, scheme, contrast, setContrast, weight, setWeight, effects, look, onLook, pack, reloadThemes } =
usePaint(hub, runtimes, running, fail);
// A pane with no session file has never been written to — the empty one every
// window opens with. Opening a conversation takes it over instead of parking
@@ -362,6 +364,7 @@ export function App({ hub }: { hub: HubPort }) {
const needsProject = treeRead && !claimed && tree.every((ws) => !ws.remembered);
useFoldAway("rail", setRail);
const navRail = useNavRail(rail);
useDrawerCloses(setRail, active, settings);
useEffect(() => onRoomWidth((width) => setDockLimit(dockMax(width))), []);
const shownDockW = Math.min(dockW, dockLimit);
@@ -550,6 +553,7 @@ export function App({ hub }: { hub: HubPort }) {
className="app"
data-run={report.run}
data-rail={rail ? "on" : "off"}
data-nav={navRail.shown ? "on" : "off"}
data-browser={browser ? "on" : "off"}
data-plan={report.status?.plan ? "on" : "off"}
data-apv={report.status?.toolApprovalMode ?? "ask"}
@@ -578,14 +582,19 @@ export function App({ hub }: { hub: HubPort }) {
hub={hub} onError={fail}
/>
{pack?.sky && <Sky />}
{pack?.sky && effects !== "reduced" && <Sky />}
<div className="cols">
<Nav
at={settings === false ? null : settings === true ? "" : settings}
onGo={showPrefs}
onHome={hidePrefs}
/>
{navRail.drawn && (
<Nav
at={settings === false ? null : settings === true ? "" : settings}
onGo={showPrefs}
onHome={hidePrefs}
onFeedback={() => setFeedback(feedbackEntryTab(feedbackUnread))}
feedbackUnread={feedbackUnread}
shown={navRail.shown}
/>
)}
<Sidebar
hub={hub}
collapsed={!rail}
+29 -2
View File
@@ -1,5 +1,6 @@
import { ApplyNote } from "./Group";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import { effectsMode, onEffectsChange, setEffectsMode, type EffectsMode } from "../state/prefs";
import type { AgentPort, Appearance as Look, ThemePack } from "../port/port";
import { MONO_FAMILIES, UI_FAMILIES, installed, readSizeOf, readSteps } from "./look";
import { STORAGE as LANG_KEY, t } from "../i18n";
@@ -8,6 +9,7 @@ import { reason } from "../i18n/kernel";
import { Notifications } from "./Notifications";
import { Folding } from "./Folding";
import { WindowSection } from "./WindowSection";
import { NavRailSection } from "./NavRailSection";
import { ThemeImport } from "./ThemeImport";
import { useThemeInventory } from "./useThemeInventory";
@@ -75,6 +77,11 @@ const MEASURES: [string, string, string][] = [
["full", "铺满窗口", "正文跟随窗口宽度,宽屏上不留两侧空白"],
];
const EFFECTS: [EffectsMode, string, string][] = [
["full", "完整", "背景模糊、动态天空与颗粒质感全部开启"],
["reduced", "节能", "关闭背景模糊、动态天空与颗粒质感,集成显卡或老旧电脑上更省电"],
];
const CONTRASTS: [string, string, string][] = [
["", "跟随系统", "系统已开启「增强对比度」时使用最强档"],
["soft", "柔和", "正文不易刺眼,长时间阅读更省力"],
@@ -122,6 +129,7 @@ function useCrop(url: string | undefined, box: HTMLElement | null) {
export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, onWeight, reloadThemes, look, onLook }: Props) {
const { packs, unread, load } = useThemeInventory(port);
const effects = useSyncExternalStore(onEffectsChange, effectsMode);
// Activating repaints through App's own theme effect, so this only refreshes
// the list and asks App to re-read which pack is active.
@@ -213,6 +221,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
look.fontUi ? t("字体") : "",
weight ? t("文字粗细") : "",
contrast ? t("文字对比度") : "",
effects === "reduced" ? t("视觉效果") : "",
].filter(Boolean);
return (
@@ -523,6 +532,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<WindowSection port={port} />
<NavRailSection />
<Folding />
<Notifications port={port} />
@@ -534,7 +544,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<summary>
<span className="tx">
<span className="lb">{t("高级外观")}</span>
<span className="ds">{changed.length ? t("已修改:{list}", { list: changed.join(" · ") }) : t("字体、文字粗细、文字对比度")}</span>
<span className="ds">{changed.length ? t("已修改:{list}", { list: changed.join(" · ") }) : t("字体、文字粗细、文字对比度、视觉效果")}</span>
</span>
</summary>
<section className="grp" id="set-font" data-setting="font">
@@ -572,6 +582,23 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<ApplyNote id="weight" />
</section>
<section className="grp" id="set-effects" data-setting="effects">
<div className="grp-hd">
<h3>{t("视觉效果")}</h3>
</div>
<p className="hint">{t("窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。")}</p>
<div className="grp-items">
<div className="seg" data-text role="group" aria-label={t("视觉效果")}>
{EFFECTS.map(([id, name, why]) => (
<button key={id} data-action="appearance.effects" data-value={id} aria-pressed={effects === id} title={t(why)} onClick={() => setEffectsMode(id)}>
{t(name)}
</button>
))}
</div>
</div>
<ApplyNote id="effects" />
</section>
<section className="grp" id="set-contrast" data-setting="contrast">
<div className="grp-hd">
<h3>{t("文字对比度")}</h3>
@@ -74,4 +74,20 @@ describe("capacity-based context maintenance", () => {
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("90000");
expect(screen.getByText(/经济维护阈值会先到/)).toBeTruthy();
});
it("says on the threshold field itself when the capacity guard caps it", async () => {
await open(port({ soft_limit_tokens: 850000, ratio: 0.5, trigger: 500000 }));
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("850000");
const row = screen.getByRole("textbox").closest(".threshold-row")!;
expect(row.textContent).toContain("500k");
expect(row.textContent).toContain("50%");
expect(row.textContent).not.toContain("达到这个用量时开始整理");
});
it("keeps the plain hint when the threshold is the one that applies", async () => {
await open(port({ soft_limit_tokens: 90000, trigger: 90000 }));
const row = screen.getByRole("textbox").closest(".threshold-row")!;
expect(row.textContent).toContain("达到这个用量时开始整理");
expect(row.textContent).not.toContain("容量保护");
});
});
+6 -1
View File
@@ -91,6 +91,7 @@ export function Compaction({ port, onChanged }: { port: AgentPort; onChanged: ()
const capacity = Math.round(win * box.ratio);
const off = win === 0;
const economicWins = !off && mode !== "capacity" && box.trigger < capacity;
const capped = !off && box.soft_limit_tokens > 0 && box.trigger < box.soft_limit_tokens;
const pct = off || !used ? 0 : Math.min((used / box.trigger) * 100, 100);
return (
@@ -158,7 +159,11 @@ export function Compaction({ port, onChanged }: { port: AgentPort; onChanged: ()
<div className="lrow threshold-row">
<span className="tx">
<label className="lb" htmlFor={field}>{t("阈值")}</label>
<span className="ds">{t("达到这个用量时开始整理")}</span>
<span className="ds">
{capped
? t("被容量保护({p}%)限制,实际 {n} tokens", { p: String(Math.round(box.ratio * 100)), n: tokens(box.trigger) })
: t("达到这个用量时开始整理")}
</span>
</span>
<div className="threshold-control">
<span className="unit-field">
@@ -715,6 +715,8 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
current={status?.modelRef}
items={modelMenu(models, providerOrder)}
menuClassName="studio-model-menu"
searchAlways
searchPlaceholder={t("搜索模型或服务商…")}
menuTitle={<><b>{t("选择模型")}</b><small>{t("用于后续任务")}</small></>}
onOpen={loadModels}
pending={busy["model"]}
@@ -20,6 +20,18 @@ afterEach(() => {
const png = (name = "a.png", size = 64) => new File([new Uint8Array(size)], name, { type: "image/png" });
function pauseImageReads() {
const original = FileReader.prototype.readAsDataURL;
const pending: (() => Promise<void>)[] = [];
const spy = vi.spyOn(FileReader.prototype, "readAsDataURL").mockImplementation(function (this: FileReader, file: Blob) {
pending.push(() => new Promise((resolve) => {
this.addEventListener("loadend", () => resolve(), { once: true });
original.call(this, file);
}));
});
return { pending, restore: () => spy.mockRestore() };
}
function setup(over: Partial<FeedbackEnv> = {}, tab: "send" | "mine" = "send") {
const port = new MockPort() as unknown as AgentPort;
const base = port.feedbackEnv.bind(port);
@@ -91,6 +103,69 @@ describe("feedback form", () => {
});
describe("screenshots", () => {
it("does not attach an old pending image to a fresh report", async () => {
const reads = pauseImageReads();
try {
const { port } = setup();
const spy = vi.spyOn(port, "sendFeedback");
await fill();
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("previous.png"));
expect(reads.pending).toHaveLength(1);
await userEvent.click(send());
await userEvent.click(await screen.findByRole("button", { name: "再写一条" }));
await userEvent.type(body(), "另一件事");
await act(reads.pending[0]!);
await userEvent.click(send());
await screen.findByText("已收到你的反馈");
expect(spy.mock.calls[1]![0].images).toEqual([]);
} finally {
reads.restore();
}
});
it("keeps a pending image in the same draft after a failed send so retry can include it", async () => {
const reads = pauseImageReads();
try {
const { port } = setup();
const spy = vi.spyOn(port, "sendFeedback").mockRejectedValueOnce(new Error("offline"));
await fill();
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("retry.png"));
await userEvent.click(send());
await screen.findByRole("button", { name: "重试发送" });
await act(reads.pending[0]!);
expect(screen.getByAltText("retry.png")).toBeTruthy();
await userEvent.click(send());
await screen.findByText("已收到你的反馈");
expect(spy.mock.calls[1]![0].images.map((image) => image.name)).toEqual(["retry.png"]);
} finally {
reads.restore();
}
});
it("keeps the new report's completed image when an old image finishes later", async () => {
const reads = pauseImageReads();
try {
const { port } = setup();
const spy = vi.spyOn(port, "sendFeedback");
await fill();
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("previous.png"));
await userEvent.click(send());
await userEvent.click(await screen.findByRole("button", { name: "再写一条" }));
await userEvent.type(body(), "另一件事");
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("current.png"));
await act(reads.pending[1]!);
expect(screen.getByAltText("current.png")).toBeTruthy();
await act(reads.pending[0]!);
expect(screen.queryByAltText("previous.png")).toBeNull();
expect(screen.getByAltText("current.png")).toBeTruthy();
await userEvent.click(send());
await screen.findByText("已收到你的反馈");
expect(spy.mock.calls[1]![0].images.map((image) => image.name)).toEqual(["current.png"]);
} finally {
reads.restore();
}
});
it("adds from the picker, previews, and removes", async () => {
setup();
await ready();
@@ -383,6 +458,16 @@ describe("dialog", () => {
opener.remove();
});
it("moves focus into the body once the environment has loaded, even if a frame fires before the commit", async () => {
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 0;
});
setup();
await ready();
await waitFor(() => expect(document.activeElement).toBe(body()));
});
it("moves between the two tabs with the arrow keys", async () => {
setup();
await ready();
@@ -58,6 +58,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
const field = useRef<HTMLTextAreaElement>(null);
const submit = useRef<HTMLButtonElement>(null);
const done = useRef<HTMLHeadingElement>(null);
const shotEpoch = useRef(0);
const heldShots = useRef(shots);
heldShots.current = shots;
@@ -68,15 +69,16 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
.then((e) => {
setEnv(e);
setName((prev) => prev || e.displayName);
requestAnimationFrame(() => {
if (document.activeElement?.closest('[role="tablist"]')) field.current?.focus();
});
})
.catch((e) => setEnvFailure(feedbackFailure(e)));
}, [port]);
useEffect(load, [load]);
useEffect(() => {
if (env && document.activeElement?.closest('[role="tablist"]')) field.current?.focus();
}, [env]);
useEffect(() => {
if (phase !== "sent") holdDraft({ category, body, contact, shots });
}, [category, body, contact, shots, phase]);
@@ -105,7 +107,9 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
const { taken, refused: no } = admit(heldShots.current.length, files, limits);
setRefused(no);
if (taken.length === 0) return;
const epoch = shotEpoch.current;
const read = await Promise.allSettled(taken.map(readShot));
if (epoch !== shotEpoch.current) return;
const ok = read.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
setShots((prev) => [...prev, ...ok].slice(0, limits.images));
},
@@ -156,6 +160,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
locale: document.documentElement.lang,
images: payload(shots),
});
shotEpoch.current++;
dropDraft();
setSent(receipt);
setPhase("sent");
+19 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useEscape } from "./dismiss";
import { t } from "../i18n";
import type { AgentPort, HookCatalog, HookDryRun, HookEntry } from "../port/port";
@@ -71,6 +71,12 @@ interface Props {
onChanged: () => void;
}
interface Trial {
rule: HookEntry;
result?: HookDryRun;
error?: string;
}
export function Hooks({ port, onChanged }: Props) {
const [cat, setCat] = useState<HookCatalog | null>(null);
const [scope, setScope] = useState<"user" | "project">("user");
@@ -78,7 +84,8 @@ export function Hooks({ port, onChanged }: Props) {
useEscape(expert, () => setExpert(false));
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [tried, setTried] = useState<Record<string, HookDryRun>>({});
const [tried, setTried] = useState<Record<string, Trial>>({});
const mine = useMemo(() => cat?.hooks.filter((h) => h.scope === (scope === "user" ? "global" : "project")) ?? [], [cat, scope]);
const reload = () => {
port
@@ -93,7 +100,6 @@ export function Hooks({ port, onChanged }: Props) {
if (!cat) return <div className="empty">{t("无法读取 hooks 配置。")}</div>;
const mine = cat.hooks.filter((h) => h.scope === (scope === "user" ? "global" : "project"));
const plugin = cat.hooks.filter((h) => h.scope === "plugin");
const broken = cat.sources.filter((s) => s.status === "malformed" || s.status === "unreadable");
@@ -121,13 +127,16 @@ export function Hooks({ port, onChanged }: Props) {
};
const tryOne = async (h: HookEntry, key: string) => {
const trial: Trial = { rule: h };
setTried((t) => ({ ...t, [key]: trial }));
setBusy(key);
setError("");
try {
const res = await port.dryRunHook(h);
setTried((t) => ({ ...t, [key]: res }));
const result = await port.dryRunHook(h);
setTried((t) => t[key] === trial ? { ...t, [key]: { ...trial, result } } : t);
} catch (e) {
setError(reason(e));
const error = reason(e);
setTried((t) => t[key] === trial ? { ...t, [key]: { ...trial, error } } : t);
} finally {
setBusy("");
}
@@ -210,7 +219,7 @@ function Expert({
rules: HookEntry[];
plugin: HookEntry[];
busy: string;
tried: Record<string, HookDryRun>;
tried: Record<string, Trial>;
onSave: (next: HookEntry[]) => void;
onTry: (h: HookEntry, key: string) => void;
}) {
@@ -226,7 +235,7 @@ function Expert({
{draft.map((h, i) => {
const meta = info(h.event);
const key = `${scope}-${i}`;
const res = tried[key];
const trial = tried[key]?.rule === h ? tried[key] : undefined;
return (
<div className="rule" key={i} data-blocking={meta?.blocking ? "" : undefined}>
<div className="line">
@@ -269,7 +278,8 @@ function Expert({
button that checks syntax and one that might delete a file. */}
<span className="note">{t("将实际执行该命令")}</span>
</div>
{res && <DryRun res={res} blocking={!!meta?.blocking} />}
{trial?.result && <DryRun res={trial.result} blocking={!!meta?.blocking} />}
{trial?.error && <div className="why">{trial.error}</div>}
</div>
);
})}
@@ -18,10 +18,10 @@ function loader() {
let Markdown = loader();
export function LazyMarkdown({ text, streaming, local }: { text: string; streaming?: boolean; local?: LocalRefs }) {
export function LazyMarkdown({ text, streaming, local, images }: { text: string; streaming?: boolean; local?: LocalRefs; images?: boolean }) {
return (
<Suspense fallback={<div className="md" style={{ whiteSpace: "pre-wrap" }}>{text}</div>}>
<Markdown text={text} streaming={streaming} local={local} />
<Markdown text={text} streaming={streaming} local={local} images={images} />
</Suspense>
);
}
+12 -7
View File
@@ -166,7 +166,7 @@ export interface LocalRefs {
// live is the tail of a message still arriving; tail alone is only the last
// block, which a settled message has too.
const Block = memo(function Block({ src, math, emoji, code, tail, live, local }: { src: string; math: Plugin | null; emoji: Plugin | null; code: Plugin | null; tail?: boolean; live?: boolean; local?: LocalRefs }) {
const Block = memo(function Block({ src, math, emoji, code, tail, live, local, images }: { src: string; math: Plugin | null; emoji: Plugin | null; code: Plugin | null; tail?: boolean; live?: boolean; local?: LocalRefs; images: boolean }) {
// Inside the memo, so a settled block normalises once instead of per chunk.
const body = normalizeMath(tail ? balanceFences(src) : src);
return (
@@ -200,9 +200,12 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local }:
</a>
);
},
img: ({ src, alt, title }) => (
<img src={(local && typeof src === "string" && local.image(src)) || (src as string | undefined)} alt={alt} title={title} />
),
img: ({ src, alt, title }) =>
images ? (
<img src={(local && typeof src === "string" && local.image(src)) || (src as string | undefined)} alt={alt} title={title} />
) : (
<span className="md-noimg">{alt}</span>
),
pre: ({ children }) => (
<CodeBlock lang={langOf(children)} source={textOf(children)} live={live}>{children}</CodeBlock>
),
@@ -218,7 +221,9 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local }:
);
});
export function Markdown({ text, streaming, local }: { text: string; streaming?: boolean; local?: LocalRefs }) {
/** images=false is for text nobody in the conversation wrote: a picture
* address in it is a request the reader never chose to make. */
export function Markdown({ text, streaming, local, images = true }: { text: string; streaming?: boolean; local?: LocalRefs; images?: boolean }) {
const shown = useRevealed(text, streaming);
const math = usePlugin(MATH.test(text), KATEX);
const emoji = usePlugin(EMOJI.test(text), EMOJIS);
@@ -237,9 +242,9 @@ export function Markdown({ text, streaming, local }: { text: string; streaming?:
// handing a listing over would hand over something that was never said.
<div className="md" data-live={streaming ? "" : undefined}>
{parts.map((p, i) => (
<Block key={i} src={p} math={math} emoji={emoji} code={code} local={local} />
<Block key={i} src={p} math={math} emoji={emoji} code={code} local={local} images={images} />
))}
<Block src={shown.slice(at)} math={math} emoji={emoji} code={code} local={local} tail live={streaming} />
<Block src={shown.slice(at)} math={math} emoji={emoji} code={code} local={local} images={images} tail live={streaming} />
{streaming && <span className="caret" />}
</div>
);
+1 -1
View File
@@ -416,7 +416,7 @@ export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSig
if (!handle) return;
setDraft({ port, handle, pkg }); setView("publish");
}} />}
{at === "publish" && handle && <PublishForm port={port} handle={handle} initial={draft?.pkg} onMine={() => { setDraft(null); setView("mine"); }} />}
{at === "publish" && handle && <PublishForm port={port} handle={handle} initial={draft?.pkg} onApplying={applyingChanged} onMine={() => { setDraft(null); setView("mine"); }} />}
</Group>
);
}
@@ -41,7 +41,7 @@ interface Draft {
const EMPTY: Draft = { kind: "skill", name: "", source: "", summary: "", description: "", repoUrl: "", version: "", tags: [], tagInput: "", private: false };
interface PublishProps { port: AgentPort; handle: string; onMine: () => void; initial?: MarketPackage }
interface PublishProps { port: AgentPort; handle: string; onMine: () => void; onApplying?: (applying: boolean) => void; initial?: MarketPackage }
export function PublishForm(props: PublishProps) {
const [owner, setOwner] = useState({ port: props.port, handle: props.handle, generation: 0 });
@@ -53,7 +53,7 @@ export function PublishForm(props: PublishProps) {
// The form only collects; which sources are publishable and what the registry
// accepts are the kernel's and the registry's answers, shown as they come back.
function PublishDraft({ port, handle, onMine, initial }: PublishProps) {
function PublishDraft({ port, handle, onMine, onApplying, initial }: PublishProps) {
const [d, setD] = useState<Draft>(() => initial ? {
...EMPTY, kind: initial.kind, name: initial.name, summary: initial.summary,
description: initial.description, repoUrl: initial.repoUrl, tags: [...initial.tags],
@@ -64,6 +64,11 @@ function PublishDraft({ port, handle, onMine, initial }: PublishProps) {
const [done, setDone] = useState<MarketPublished | null>(null);
const set = (k: keyof Draft) => (e: { target: { value: string } }) => setD({ ...d, [k]: e.target.value });
useEffect(() => {
onApplying?.(busy);
return () => onApplying?.(false);
}, [busy, onApplying]);
const submit = async () => {
setBusy(true);
setError("");
+30 -7
View File
@@ -22,6 +22,8 @@ export interface MenuItem {
// through twice.
header?: boolean;
disabled?: boolean;
// A letter chip leading the row or heading, standing in for a vendor mark.
mono?: { letter: string; tone: number };
// A switch rather than a choice: picking it flips this state, and the row
// says which state it is in instead of being marked as the current value.
toggle?: boolean;
@@ -55,13 +57,17 @@ interface Props {
// Opening is when the reader asks what the choices are, so a menu whose rows
// come from elsewhere re-reads them here rather than showing its mount's copy.
onOpen?: () => void;
// The field is the menu's way in rather than its answer to length: a model
// list is searched for by name however few entries it has.
searchAlways?: boolean;
searchPlaceholder?: string;
}
// data-* rides through to the item that raises the pick, the way Switch and Seg
// carry theirs: the action's identity is written at the call site, and the
// answer this menu gives is the item's own value.
export function Picker({
label, items, current, onPick, place, align = "start", className, title, pending, triggerAction, ariaPressed, wrapClassName, menuClassName, menuTitle, onOpen, ...id
label, items, current, onPick, place, align = "start", className, title, pending, triggerAction, ariaPressed, wrapClassName, menuClassName, menuTitle, onOpen, searchAlways, searchPlaceholder, ...id
}: Props & { [K in `data-${string}`]?: string }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@@ -71,11 +77,12 @@ export function Picker({
const find = useRef<HTMLInputElement>(null);
const choosable = items.filter((it) => !it.header && !it.plain).length;
const filtering = choosable > FILTER_FROM;
const filtering = searchAlways || choosable > FILTER_FROM;
const shown = useMemo(() => (filtering ? matching(items, query) : items), [filtering, items, query]);
// What Enter takes while the field still has focus. Only shown once the
// query narrows something, or every menu would open pre-selected.
const lead = query ? shown.find((it) => !it.header)?.value : undefined;
const lead = query ? shown.find((it) => !it.header && !it.plain)?.value : undefined;
const none = filtering && !shown.some((it) => !it.header && !it.plain);
useEffect(() => {
if (!open) {
@@ -84,6 +91,7 @@ export function Picker({
}
if (filtering) find.current?.focus();
else menu.current?.querySelector<HTMLElement>("button.mi")?.focus();
menu.current?.querySelector<HTMLElement>("button.mi[data-on]")?.scrollIntoView({ block: "nearest" });
// The menu is not inside the wrapper any more, so without asking it too a
// press on one of its own rows would read as a press outside.
const onDown = (e: MouseEvent) => {
@@ -140,6 +148,11 @@ export function Picker({
// Headings are divs and never take focus, so walking the buttons is what
// keeps one arrow press from landing on nothing.
const arrows = (e: React.KeyboardEvent) => {
const typed = e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.metaKey && !e.altKey;
if (filtering && typed && e.target !== find.current) {
find.current?.focus();
return;
}
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
const all = [...(menu.current?.querySelectorAll<HTMLElement>("button.mi") ?? [])];
if (!all.length) return;
@@ -152,6 +165,11 @@ export function Picker({
return;
}
const to = e.key === "ArrowDown" ? i + 1 : i - 1;
if (to < 0 && filtering) {
e.preventDefault();
find.current?.focus();
return;
}
if (to < 0 || to >= all.length) return;
e.preventDefault();
all[to].focus();
@@ -200,7 +218,7 @@ export function Picker({
ref={find}
className="mfind"
value={query}
placeholder={t("筛选 {n} 项", { n: choosable })}
placeholder={searchPlaceholder ?? t("筛选 {n} 项", { n: choosable })}
aria-label={t("筛选")}
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
@@ -211,11 +229,13 @@ export function Picker({
}}
/>
)}
{none && <div className="mnone">{t("没有匹配的项")}</div>}
{shown.map((it, i) => (
<Fragment key={it.value}>
{it.divide && i > 0 && <div className="div" />}
{it.header ? (
<div className="mi head">
{it.mono && <Mono {...it.mono} />}
<span className="lb">{it.label}</span>
{it.right && <span className="rt">{it.right}</span>}
</div>
@@ -233,7 +253,7 @@ export function Picker({
data-recommended={it.recommended ? "" : undefined}
onClick={() => take(it.value)}
>
<span className="dot" />
{it.mono ? <Mono {...it.mono} /> : <span className="dot" />}
<span className="tx">
<span className="studio-item-line">
<span className="lb">{it.label}</span>
@@ -253,7 +273,6 @@ export function Picker({
)}
</Fragment>
))}
{filtering && shown.length === 0 && <div className="mnone">{t("没有匹配的项")}</div>}
</div>,
document.body,
)}
@@ -261,6 +280,10 @@ export function Picker({
);
}
function Mono({ letter, tone }: { letter: string; tone: number }) {
return <i className="mi-mono" data-tone={tone} style={{ "--tone": `var(--cat-${tone + 1})` } as React.CSSProperties} aria-hidden="true">{letter}</i>;
}
// Group order is kept rather than ranked by relevance: a list that reorders
// itself as you type moves the row out from under the cursor.
function matching(items: MenuItem[], query: string): MenuItem[] {
@@ -277,7 +300,7 @@ function matching(items: MenuItem[], query: string): MenuItem[] {
// Actions are not data — filtering them out would take "打开其他目录…"
// away exactly when the query found nothing and the user needs it.
if (it.plain) return false;
const hay = `${head}|${normalize(`${it.label} ${it.right ?? ""} ${it.desc ?? ""}`)}`;
const hay = `${head}|${normalize(`${it.label} ${it.right ?? ""} ${it.meta ?? ""} ${it.desc ?? ""}`)}`;
return words.every((w) => hay.includes(w));
});
return items.filter((it, i) => {
@@ -0,0 +1,87 @@
import { useCallback, useRef, useState, type CSSProperties } from "react";
import { t } from "../i18n";
import type { ContextBreakdown, JobEntry, McpEntry } from "../port/port";
import type { Metrics } from "../state/session_types";
import { DeckChips, type Deck } from "./DeckChips";
import { ContextSummaryCard } from "./ContextSummaryCard";
import { useDismiss } from "./dismiss";
import type { Task } from "./panels/Agents";
import { Spark } from "./Spark";
import { StudioIcon } from "./StudioIcon";
import { setHidesAmounts } from "../state/prefs";
import { MASK, type Wallet } from "./wallet";
import type { SpeedSummary } from "./speed";
/** The numbers about this turn, in one row under the composer. */
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, cost, wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob }: {
tps: number;
trail: number[];
running: boolean;
speed: SpeedSummary;
metrics: Pick<Metrics, "hit" | "miss">;
ctx: ContextBreakdown | null;
mcp: McpEntry[];
cost: string;
wallet: Wallet;
hideAmounts: boolean;
tasks: Task[];
jobs: JobEntry[];
onSettings: (section?: string) => void;
onCancelJob: (id: string) => Promise<void>;
}) {
const [meterOpen, setMeterOpen] = useState(false);
const meterRef = useRef<HTMLDivElement>(null);
const closeMeter = useCallback(() => setMeterOpen(false), []);
useDismiss(meterOpen, meterRef, closeMeter);
const [deck, setDeck] = useState<Deck>("");
const cacheTokens = metrics.hit + metrics.miss;
const cacheRate = cacheTokens > 0 ? Math.round((metrics.hit / cacheTokens) * 100) : null;
const contextPercent = ctx && ctx.window > 0 ? Math.min(100, Math.round((ctx.used / ctx.window) * 100)) : 0;
return (
<div className="studio-meterrail" ref={meterRef} aria-label={t("运行统计")}>
<div className="studio-speed-anchor">
<button
className="studio-meter-static studio-meter-speed"
type="button"
aria-describedby="studio-speed-detail"
aria-label={t("查看生成速度详情")}
>
<Spark points={trail} w={44} h={13} />
<b>{tps > 0 ? tps.toFixed(1) : "—"}</b><span>tok/s</span><i data-live={running ? "" : undefined} aria-hidden="true" />
</button>
<div className="studio-speed-detail" id="studio-speed-detail" role="tooltip">
<header><b>{t("生成速度")}</b><small>{running ? t("实时更新") : t("最近一轮")}</small></header>
<dl>
<div><dt>{t("当前速度")}</dt><dd>{tps > 0 ? `${tps.toFixed(1)} tok/s` : "—"}</dd></div>
<div><dt>{t("整轮平均")}</dt><dd>{speed.average > 0 ? `${speed.average.toFixed(1)} tok/s` : "—"}</dd></div>
<div><dt>{t("本轮输出")}</dt><dd>{speed.output > 0 ? t("{n} tokens", { n: speed.output.toLocaleString() }) : "—"}</dd></div>
<div><dt>{t("模型耗时")}</dt><dd>{speed.modelSeconds > 0 ? `${speed.modelSeconds.toFixed(1)}s` : "—"}</dd></div>
</dl>
<p>{t("当前速度按最近 4 秒流式文本估算;整轮平均使用服务商返回的输出 Token 除以模型回合耗时。")}</p>
</div>
</div>
<span className="studio-meter-static studio-meter-cache" title={cacheRate === null ? t("尚无缓存数据") : t("命中 {hit} · 未命中 {miss}", { hit: metrics.hit.toLocaleString(), miss: metrics.miss.toLocaleString() })}>
<span>{t("缓存")}</span><b>{cacheRate === null ? "—" : `${cacheRate}%`}</b>
</span>
{ctx && ctx.window > 0 && (
<div className="studio-context-anchor" data-open={meterOpen ? "" : undefined}>
<button className="studio-meter-context" data-action="metrics.details" data-value="context" aria-expanded={meterOpen} aria-haspopup="dialog" aria-label={t("查看上下文与压缩")} onClick={() => setMeterOpen((open) => !open)}>
<span className="studio-context-ring" style={{ "--fill": `${contextPercent}%` } as CSSProperties} aria-hidden="true" />
<span>{t("上下文")}</span><b>{contextPercent}%</b>
</button>
<ContextSummaryCard
className="studio-context-summary"
context={ctx}
mcp={mcp}
percent={contextPercent}
onManage={() => { closeMeter(); onSettings("ext"); }}
/>
</div>
)}
{cost && <span className="studio-meter-cost"><span>{t("本轮")}</span><b>{hideAmounts ? MASK : cost}</b></span>}
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("显示金额") : t("隐藏金额")} title={hideAmounts ? t("显示金额") : t("隐藏金额")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("余额不足")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("查看钱包余额") : `${t("查看钱包余额")}, ${t("余额不足")}`} onClick={() => onSettings("usage")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} />
</div>
);
}
+16 -3
View File
@@ -1,4 +1,6 @@
import { t } from "../i18n";
import { FeedbackBadge } from "./feedbackentry";
import { StudioIcon } from "./StudioIcon";
// Drawn on the same 16-unit grid at the same 1.5 stroke as Sym's marks, and for
// the same reason: an icon set pulled in from outside reads as a different hand
@@ -34,9 +36,13 @@ interface Props {
at: string | null;
onGo: (sec?: string) => void;
onHome: () => void;
onFeedback: () => void;
feedbackUnread: number;
/** False keeps the column mounted but out of the way and out of reach. */
shown: boolean;
}
export function Nav({ at, onGo, onHome }: Props) {
export function Nav({ at, onGo, onHome, onFeedback, feedbackUnread, shown }: Props) {
const btn = ([key, label, sec]: [keyof typeof ICON, string, string | null]) => (
<button
key={key}
@@ -52,9 +58,16 @@ export function Nav({ at, onGo, onHome }: Props) {
</button>
);
return (
<nav className="nav" aria-label={t("主导航")}>
<nav className="nav" aria-label={t("主导航")} aria-hidden={shown ? undefined : true} inert={!shown}>
<div className="navgrp">{TO.map(btn)}</div>
<div className="navgrp foot">{FOOT.map(btn)}</div>
<div className="navgrp foot">
<button className="navbtn" data-action="feedback.open" onClick={onFeedback}>
<StudioIcon name="feedback" aria-hidden="true" />
<span className="navhint" role="tooltip">{t("发送反馈")}</span>
<FeedbackBadge unread={feedbackUnread} />
</button>
{FOOT.map(btn)}
</div>
</nav>
);
}
@@ -0,0 +1,22 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { NAV_RAIL_MODES, navRailMode, onNavRailChange, setNavRailMode, type NavRailMode } from "../state/prefs";
import { Group } from "./Group";
const LABEL: Record<NavRailMode, string> = { on: "始终显示", collapsed: "仅侧栏收起时显示", off: "不显示" };
export function NavRailSection() {
const mode = useSyncExternalStore(onNavRailChange, navRailMode, navRailMode);
return (
<Group id="navrail" title={t("图标栏")} hint={t("会话侧栏左边的一列图标,直达用量、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。")}>
<div className="seg" data-text role="group" aria-label={t("图标栏")}>
{NAV_RAIL_MODES.map((m) => (
<button key={m} data-action="appearance.nav-rail" data-value={m} aria-pressed={mode === m} onClick={() => setNavRailMode(m)}>
{t(LABEL[m])}
</button>
))}
</div>
<p className="note">{t("关闭或收起时,这些页面仍可从设置和侧栏打开")}</p>
</Group>
);
}
+47 -278
View File
@@ -1,20 +1,16 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { money } from "../i18n/format";
import { reason } from "../i18n/kernel";
import { t } from "../i18n";
import { hasPendingDecision, posture, runState } from "./decisions";
import { createPortal } from "react-dom";
import { HttpError, type AgentPort, type Checkpoint, type ChipCall, type ContextBreakdown, type JobEntry, type McpEntry, type SessionStatus, type WorkspaceChanges } from "../port/port";
import type { RuntimeView } from "../port/hub";
import type { Checkpoint, ContextBreakdown, JobEntry, McpEntry, SessionStatus, WorkspaceChanges } from "../port/port";
import type { TrajectoryRead } from "../port/wire";
import { chipLabel, currentStep, fromHistory, initialState, localId, quoteAmount, reduce, stepDone, stepLabel } from "../state/session";
import { chipLabel, fromHistory, initialState, quoteAmount, reduce } from "../state/session";
import { pairCheckpoints } from "../state/checkpoints";
import { DeckChips, type Deck } from "./DeckChips";
import { Plan } from "./Plan";
import { useReplyActions } from "./reply";
import { useGateActions } from "./gates";
import { useQueueActions } from "./queueactions";
import { RunTokens } from "./RunTokens";
import { useRewindActions } from "./rewind";
import { initialTraj, reduceTraj } from "../state/trajectory";
import { Transcript } from "./Transcript";
@@ -24,18 +20,19 @@ import { Composer } from "./Composer";
import { draftKey } from "./drafts";
import { Queue, waiting } from "./Queue";
import { SlottedView } from "./SlottedView";
import { key as slotKey, placement } from "./slots";
import { key as slotKey } from "./slots";
import { Metrics } from "./Metrics";
import { railOf } from "./panels/derive";
import { MASK, accountOf, useHidesAmounts, useWallet } from "./wallet";
import { setHidesAmounts } from "../state/prefs";
import { swapping } from "./swap";
import { PaneNav, type PaneView } from "./PaneNav";
import { accountOf, useHidesAmounts, useWallet } from "./wallet";
import { PaneNav } from "./PaneNav";
import { useShowView } from "./showview";
import { useSubmit } from "./usesubmit";
import { useSurfaceSlots } from "./usesurfaceslots";
import { MeterRail } from "./MeterRail";
import { PlanFold } from "./PlanFold";
import { RunLine } from "./RunLine";
import type { PaneProps, PaneReport } from "./panetypes";
import { useRate, useTrail } from "./num";
import { Spark } from "./Spark";
import { StudioIcon } from "./StudioIcon";
import { useDismiss } from "./dismiss";
import { ContextSummaryCard } from "./ContextSummaryCard";
import { DOCK, Gutter } from "./Gutter";
import { Find } from "./Find";
import { useFind } from "./usefind";
@@ -44,34 +41,21 @@ import { useBrowserTabs } from "./BrowserPanel";
import { useRevealAgentPages, useRevealBrowserOpen } from "./browserreveal";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { refreshTodos } from "../state/restore";
import { RMark } from "./RMark";
import { speedOf } from "./speed";
import { RuntimeBar } from "./RuntimeBar";
import { PostureNote } from "./PostureNote";
import { useStatusPoll } from "./useStatusPoll";
import { useCheckpointRefresh } from "./useCheckpointRefresh";
import { LiveWork, useLiveWork } from "../state/foldpref";
// PaneReport is what the window's own chrome needs from whichever pane has
// focus: everything else about a session stays inside the pane that owns it.
export interface PaneReport {
status: SessionStatus | null;
title: string;
steer: number;
run: string;
// Whether the turn is actually moving or waiting on you. "halt" cannot answer
// this: a reopened history sits at halt too, and closing that costs nothing.
live: boolean;
cost: string;
contextPercent: number | null;
context: ContextBreakdown | null;
mcp: McpEntry[];
wallet: string;
}
export type { PaneReport };
// A shared constant, not `?? []`: a fresh empty array every render reads as a
// changed prop to the rail below it.
const NO_JOBS: JobEntry[] = [];
type SessionRead = { kind: "pending" } | { kind: "settled"; status: SessionStatus | null };
const totalsOf = (st: SessionStatus) => ({
kind: "__totals",
hit: st.cacheHit,
@@ -82,52 +66,13 @@ const totalsOf = (st: SessionStatus) => ({
incompleteReason: st.sessionCostQuote?.incompleteReason,
});
interface Props {
port: AgentPort;
rt: RuntimeView;
title: string;
active: boolean;
// Where the metrics rail lives. Only the focused pane renders into it, so the
// column stays on the window's edge instead of appearing between two panes.
sideHost: HTMLElement | null;
side: boolean;
onFocus: () => void;
// Every pane reports, not just the focused one: a tab has to show that the
// conversation behind it is still working.
onReport: (id: string, report: PaneReport) => void;
// Off-screen panes stay mounted — their stream, transcript and scroll
// position are exactly what a tab switch must not throw away.
visible: boolean;
onSessionChanged: () => void;
onTurnDone?: (id: string) => void;
// Bumped when something outside this pane changed a setting that belongs to
// its session. /status is polled only while a turn runs, so without this the
// pane keeps reporting the posture it had when it opened.
pulse: number;
findPulse: number;
onSettings: (section?: string) => void;
// 这个窗口还没有人选过的项目文件夹。空转录是唯一说得出这句话的地方 —— 那里
// 本来就在替一段还没开始的对话说明它该怎么开始。
needsProject: boolean;
onOpenProject: () => void;
onKeepHere: () => void;
// A prop, not a document read: this pane is memoised past an attribute flip.
theme: string;
// Rides on `.app`: that is where the divider writes while a drag is in flight.
dockW: number;
dockMax: number;
onDockW: (w: number) => void;
manualBrowser?: boolean;
onManualBrowser?: (on: boolean) => void;
// The window's failed-request notice, handed only to the pane in front.
alert?: ReactNode;
}
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: Props) {
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
const [s, dispatch] = useReducer(reduce, initialState);
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
const [status, setStatus] = useState<SessionStatus | null>(null);
const [tab, setTab] = useState<PaneView>("flow");
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
const status = sessionRead.kind === "settled" ? sessionRead.status : null;
const ready = sessionRead.kind === "settled";
const [tab, showView] = useShowView(rt.id, onManualBrowser);
const [pinned, setPinned] = useState(true);
const [jump, setJump] = useState(0);
const [mcp, setMcp] = useState<McpEntry[]>([]);
@@ -135,7 +80,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const [tree, setTree] = useState<WorkspaceChanges | null>(null);
const [ctx, setCtx] = useState<ContextBreakdown | null>(null);
const [checkpoints, setCheckpoints] = useState<Checkpoint[]>([]);
const [slots, setSlots] = useState<Record<string, string>>({});
const pages = useBrowserTabs(port, s.browserTabsMoved);
const revealBrowser = useCallback(() => onManualBrowser?.(true), [onManualBrowser]);
useRevealAgentPages(pages, active && !rt.host, revealBrowser);
@@ -146,10 +90,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
// for exactly the horizontal/vertical space the analysis view explains.
const docked = manualBrowser && tab === "flow";
const workbench = docked || tab === "browser";
const [meterOpen, setMeterOpen] = useState(false);
const meterRef = useRef<HTMLDivElement>(null);
const closeMeter = useCallback(() => setMeterOpen(false), []);
useDismiss(meterOpen, meterRef, closeMeter);
const flow = useRef<HTMLDivElement>(null);
const running = s.running || !!status?.running; // A paired device can join after turn_started.
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
@@ -189,7 +129,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
// answers are word-for-word the previous one. Swapping in an equal object
// would repaint the rail and the composer for no news at all.
const applyStatus = useCallback((next: SessionStatus) => {
setStatus((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
setSessionRead((prev) => (prev.kind === "settled" && prev.status && JSON.stringify(prev.status) === JSON.stringify(next)
? prev : { kind: "settled", status: next }));
}, []);
const refreshStatus = useCallback(() => port.status().then(applyStatus).catch(() => {}), [port, applyStatus]);
@@ -248,7 +189,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
useEffect(() => {
let alive = true;
port.trajectory().then((r) => alive && replayTrajectory(r)).catch(() => {});
port.checkpoints().then((cps) => alive && setCheckpoints(cps)).catch(() => {});
// The record and the numbers over it are two reads, not one. /status can go
// to the network — the provider's wallet endpoint rides it — and pairing the
// two made the conversation wait on a round trip that has nothing to do with
@@ -260,13 +200,15 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
});
port.status().then((st) => {
if (!alive) return;
setStatus(st);
applyStatus(st);
dispatch(totalsOf(st) as never);
}).catch(() => {
if (alive) setSessionRead((prev) => prev.kind === "pending" ? { kind: "settled", status: null } : prev);
});
return () => {
alive = false;
};
}, [port]);
}, [port, applyStatus]);
useEffect(() => {
if (!running) {
@@ -307,8 +249,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
return history;
}, [port, applyStatus, refreshWallet, onSessionChanged, replayTrajectory]);
const { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession);
// Both of these read only the user and tool cards, so they key off the
// revision rather than the items array: a streamed answer leaves every card
// they look at untouched, and recomputing them per chunk is the whole reason
@@ -318,13 +258,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const paired = useMemo(() => pairCheckpoints(s.items, checkpoints), [s.revision, checkpoints]);
const rail = useMemo(() => railOf(s.items, s.executions, s.subagentPhase), [s.revision, s.executions, s.subagentPhase]);
// Sub-agents and background processes: built as panels, never drawn.
const [deck, setDeck] = useState<Deck>("");
// The list belongs above the line that narrates the turn, because that is
// what it is about. Plan was built and only ever mounted inside an inspector
// nothing renders, which is why it had never been seen.
const todoOpen = s.plan.length > 0 && tab === "flow";
const [todoShown, setTodoShown] = useState(false);
const jobs = status?.jobs ?? NO_JOBS;
const counts = useMemo(() => {
let steps = 0;
@@ -339,13 +272,12 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
// An MCP server connects lazily and fails at first use, so a turn boundary is
// also when its status can have changed — no timer of its own needed.
useEffect(() => {
reloadMcp();
// A finished turn is exactly when the kernel has one more checkpoint.
port.checkpoints().then(setCheckpoints).catch(() => {});
}, [reloadMcp, port, status?.sessionPath, running]);
if (ready) reloadMcp();
}, [ready, reloadMcp, status?.sessionPath, running]);
useCheckpointRefresh(port, status?.sessionPath, running, setCheckpoints);
// A call that may write can have moved the tree before the turn ends.
const refreshTree = useCallback(() => void port.changes().then(setTree).catch(() => setTree(null)), [port]);
useEffect(refreshTree, [refreshTree, status?.sessionPath, running, counts.wrote]);
useEffect(() => { if (ready || counts.wrote) refreshTree(); }, [ready, refreshTree, status?.sessionPath, running, counts.wrote]);
// One turn can be dozens of model round trips — the session this was measured
// on ran thirty, from 9k tokens to 57k. Reading the gauge only at the turn
@@ -359,8 +291,9 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const folds = s.items.reduce((n, i) => n + (i.t === "compaction" && i.done ? 1 : 0), 0);
const roundTrips = s.metrics.hit + s.metrics.miss;
useEffect(() => {
if (!ready && !roundTrips && !folds) return;
port.context().then(setCtx).catch(() => setCtx(null));
}, [port, roundTrips, folds, status?.sessionPath, running]);
}, [ready, port, roundTrips, folds, status?.sessionPath, running]);
// The sidebar has to hear about this pane's session twice: when the first
// turn mints the file (before that there is no row to show) and when the turn
@@ -374,90 +307,19 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
// not echo, so a live turn has to re-read it rather than infer from events.
useStatusPoll(running && visible, refreshStatus);
useEffect(() => {
void port.surfaceSlots().then(setSlots).catch(() => setSlots({}));
}, [port]);
const { slots, moveSurface, atComposer, inRail } = useSurfaceSlots(port, s.views, fail);
// Where the user put each extension surface. Updated in place: a move is the
// user's own action, so waiting for a round trip would read as a dead click.
const moveSurface = useCallback(
async (ext: { pluginId: string; surfaceId: string }, slot: string) => {
const id = `${ext.pluginId}:${ext.surfaceId}`;
setSlots((prev) => {
const next = { ...prev };
if (slot) next[id] = slot;
else delete next[id];
return next;
});
await port.assignSurface(id, slot).catch(fail);
},
[port, fail],
);
// Split once per change rather than per frame: a new array each render is a
// changed prop, and that alone would keep the rail re-rendering all turn.
const [atComposer, inRail] = useMemo(() => {
const at: typeof s.views = [];
const rail: typeof s.views = [];
for (const v of s.views) (placement(v, slots) === "composer-trailing" ? at : rail).push(v);
return [at, rail];
}, [s.views, slots]);
const submit = useSubmit({ port, running, dispatch, trajDispatch, refreshStatus, fail });
// The wire never echoes what was typed, so the row is the client's to add —
// and its to take back when the line did not leave. Reporting the refusal is
// not enough on its own: the transcript would keep showing a turn that never
// happened, and the composer was emptied on the way in.
const submit = useCallback(
async (text: string, chips?: ChipCall) => {
const steering = running;
const id = localId();
dispatch({ kind: "__user", text, pending: steering, id } as never);
trajDispatch({ kind: "__user", text });
try {
if (steering) {
// The row is already on screen; the receipt is what gives it a name
// to be taken back by while it waits at the tool boundary.
const queued = chips ? await port.queueFollowup(text, chips) : await port.steer(text);
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: chips || queued.paused ? "followup" : "steer", paused: queued.paused } as never);
} else {
await submitOrQueue(text, id, chips);
}
return true;
} catch (e) {
dispatch({ kind: "__unsent", id } as never);
fail(e);
return false;
}
},
[port, running, refreshStatus, fail],
);
const { queue, restored, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel } = useQueueActions({
const { queue, restored, onRestoreText, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel } = useQueueActions({
port,
dispatch,
fail,
moved: s.queueMoved,
sessionState: sessionRead.kind,
sessionPath: status?.sessionPath,
});
// The kernel refuses a submit it cannot start with a code, not a sentence:
// the words are fine, the timing is not. Queueing them is what that code
// asks for, and showing anyone the refusal instead is how a race between
// "the turn is done" on screen and the turn actually landing became an error
// nobody could act on.
const submitOrQueue = useCallback(
async (text: string, id: string, chips?: ChipCall) => {
try {
const held = await port.submit(text, chips);
if (held?.paused && held.itemId) dispatch({ kind: "__queued", id, itemId: held.itemId, queued: "followup", paused: true } as never);
refreshStatus();
} catch (e) {
if (!(e instanceof HttpError) || e.reason?.code !== "busy.session_running") throw e;
const queued = await port.queueFollowup(text, chips);
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: "followup", paused: queued.paused } as never);
}
},
[port, refreshStatus],
);
const { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession, onRestoreText);
const { onApprove, onFullAccess, onPlan, onForget, onExtInvoke, onExtSubmit, onAnswer } = useGateActions({
port,
@@ -467,25 +329,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
onRevise: () => setAskFocus((n) => n + 1),
});
// Every route into a view goes through one place, so the menu never grows a
// motion language of its own: it says which view, and this says how a pane
// changes from one to another.
// The workbench is the one view that sits beside the conversation rather than
// over it, because it is read against what was said. Picking it from the nav
// opens the same dock the globe does; there was no reason for one control to
// take the conversation away and the other to keep it.
const showView = useCallback(
(to: PaneView) => {
if (to === "browser") {
onManualBrowser?.(true);
swapping(() => setTab("flow"), "tab");
return;
}
if (to === "flow") onManualBrowser?.(false);
swapping(() => setTab(to), "tab");
},
[onManualBrowser],
);
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]));
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
@@ -502,8 +345,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const blocked = hasPendingDecision(status);
const run = runState({ blocked, running, hasItems: s.items.length > 0, terminal: s.terminal });
const cost = money(s.metrics.cost, s.metrics.currency);
const cacheTokens = s.metrics.hit + s.metrics.miss;
const cacheRate = cacheTokens > 0 ? Math.round((s.metrics.hit / cacheTokens) * 100) : null;
const contextPercent = ctx && ctx.window > 0 ? Math.min(100, Math.round((ctx.used / ctx.window) * 100)) : null;
const walletDisplay = wallet.kind === "read" ? wallet.reading.display : "";
@@ -549,7 +390,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
<PaneShown.Provider value={shown}>
<LiveWork.Provider value={live}>
<Transcript
<Transcript port={port}
reply={reply}
onResend={onResend}
items={s.items}
@@ -635,40 +476,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
</span>
{/* Everything stacked above the input box shares one ceiling, so no
child of this region may grow without bound. */}
{todoOpen && (
<div className="studio-todo" data-open={todoShown ? "" : undefined}>
{/* One line by default. The composer already carries the run line,
the outbox and its own toolbar; a plan opened over all of them
pushes the box a person types in off the bottom of a laptop. */}
<button
type="button"
className="studio-todo-sum"
data-action="plan.fold"
aria-expanded={todoShown}
onClick={() => setTodoShown((on) => !on)}
>
<StudioIcon name="list" />
<b>{t("计划")}</b>
<span>{stepLabel(s.plan[currentStep(s.plan)] ?? s.plan[0])}</span>
<small>{s.plan.filter(stepDone).length}/{s.plan.length}</small>
<StudioIcon name="down" className="studio-todo-fold" />
</button>
{todoShown && <div className="studio-todo-body"><Plan steps={s.plan} /></div>}
</div>
)}
{tab === "flow" && (
<div
className="studio-runstate"
role="status"
aria-live="polite"
data-running={running && !blocked ? "" : undefined}
data-waiting={blocked ? "" : undefined}
data-idle={running || blocked ? undefined : ""}
>
<span className="studio-runlabel"><RMark /><span>{t(chipLabel(s, running))}</span></span>
<RunTokens sent={sent} received={received} estimated={s.outLive > 0} />
</div>
)}
<PlanFold plan={s.plan} shown={tab === "flow"} />
{tab === "flow" && <RunLine label={chipLabel(s, running)} running={running} blocked={blocked} sent={sent} received={received} estimated={s.outLive > 0} />}
<div className="composeaux">
<Queue
queue={queue}
@@ -702,51 +511,11 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
</div>
{alert && <div className="cmpalert">{alert}</div>}
<Composer port={port} status={status} running={running} quote={quote} restore={restored} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={fail} onSettings={onSettings} changeCount={tree?.repo ? tree.changes.length : 0} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<div className="studio-meterrail" ref={meterRef} aria-label={t("运行统计")}>
<div className="studio-speed-anchor">
<button
className="studio-meter-static studio-meter-speed"
type="button"
aria-describedby="studio-speed-detail"
aria-label={t("查看生成速度详情")}
>
<Spark points={trail} w={44} h={13} />
<b>{tps > 0 ? tps.toFixed(1) : "—"}</b><span>tok/s</span><i data-live={running ? "" : undefined} aria-hidden="true" />
</button>
<div className="studio-speed-detail" id="studio-speed-detail" role="tooltip">
<header><b>{t("生成速度")}</b><small>{running ? t("实时更新") : t("最近一轮")}</small></header>
<dl>
<div><dt>{t("当前速度")}</dt><dd>{tps > 0 ? `${tps.toFixed(1)} tok/s` : "—"}</dd></div>
<div><dt>{t("整轮平均")}</dt><dd>{speed.average > 0 ? `${speed.average.toFixed(1)} tok/s` : "—"}</dd></div>
<div><dt>{t("本轮输出")}</dt><dd>{speed.output > 0 ? t("{n} tokens", { n: speed.output.toLocaleString() }) : "—"}</dd></div>
<div><dt>{t("模型耗时")}</dt><dd>{speed.modelSeconds > 0 ? `${speed.modelSeconds.toFixed(1)}s` : "—"}</dd></div>
</dl>
<p>{t("当前速度按最近 4 秒流式文本估算;整轮平均使用服务商返回的输出 Token 除以模型回合耗时。")}</p>
</div>
</div>
<span className="studio-meter-static studio-meter-cache" title={cacheRate === null ? t("尚无缓存数据") : t("命中 {hit} · 未命中 {miss}", { hit: s.metrics.hit.toLocaleString(), miss: s.metrics.miss.toLocaleString() })}>
<span>{t("缓存")}</span><b>{cacheRate === null ? "—" : `${cacheRate}%`}</b>
</span>
{ctx && ctx.window > 0 && (
<div className="studio-context-anchor" data-open={meterOpen ? "" : undefined}>
<button className="studio-meter-context" data-action="metrics.details" data-value="context" aria-expanded={meterOpen} aria-haspopup="dialog" aria-label={t("查看上下文与压缩")} onClick={() => setMeterOpen((open) => !open)}>
<span className="studio-context-ring" style={{ "--fill": `${Math.min(100, Math.round((ctx.used / ctx.window) * 100))}%` } as CSSProperties} aria-hidden="true" />
<span>{t("上下文")}</span><b>{Math.min(100, Math.round((ctx.used / ctx.window) * 100))}%</b>
</button>
<ContextSummaryCard
className="studio-context-summary"
context={ctx}
mcp={mcp}
percent={Math.min(100, Math.round((ctx.used / ctx.window) * 100))}
onManage={() => { closeMeter(); onSettings("ext"); }}
/>
</div>
)}
{cost && <span className="studio-meter-cost"><span>{t("本轮")}</span><b>{hideAmounts ? MASK : cost}</b></span>}
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("显示金额") : t("隐藏金额")} title={hideAmounts ? t("显示金额") : t("隐藏金额")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("余额不足")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("查看钱包余额") : `${t("查看钱包余额")}, ${t("余额不足")}`} onClick={() => onSettings("usage")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
<DeckChips tasks={rail.tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={(id) => port.cancelJob(id).then(refreshStatus, fail)} />
</div>
<MeterRail
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} cost={cost}
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
onCancelJob={(id) => port.cancelJob(id).then(refreshStatus, fail)}
/>
{/* Below the box, under a ceiling of their own. Both arrive unbidden and
both are dismissed one at a time, so nothing else bounds how many can
be on screen at once. */}
@@ -88,6 +88,22 @@ it("shows the account-gated Internet QR before the optional LAN pairing code", a
expect(offerShare).not.toHaveBeenCalled();
});
it.each([
[undefined, "登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。"],
["signed_out", "登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。"],
["relay_unreachable", "中转服务暂时无法连接,请检查网络或代理设置后重试。"],
["relay_refused", "中转服务拒绝了这台设备的连接,请稍后重试。"],
] as const)("explains why Internet access is unavailable: %s", async (reason, note) => {
const remote = { ...status(false), cloudRemote: { online: false, reason, error: "account: not signed in" } };
const hub = { shareStatus: vi.fn(async () => remote), offerShare: vi.fn() } as unknown as HubPort;
render(<PhonePop hub={hub} />);
await userEvent.click(await screen.findByRole("button", { name: "设备访问" }));
expect(await screen.findByText(note)).toBeTruthy();
if (reason === "relay_unreachable" || reason === "relay_refused") {
expect(screen.queryByText("登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。")).toBeNull();
}
});
it("labels Internet controllers by their ordinal, not their list position", async () => {
const now = new Date().toISOString();
const cloud = (id: string, ordinal: number) => ({ id, name: "Web Studio", connectedAt: now, lastSeen: now, ordinal });
+16 -4
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { HubPort } from "../port/hub";
import type { CloudDevice, PairedDevice } from "../port/share";
import type { CloudDevice, CloudRemoteStatus, PairedDevice } from "../port/share";
import { copyText } from "./CopyButton";
import { useDismiss } from "./dismiss";
import { clock, deviceLabel, type Share, useShare } from "./PhoneAccess";
@@ -109,6 +109,20 @@ export function cloudPresenceNote(prev: CloudDevice[], next: CloudDevice[]): str
return disconnected ? t("Web Studio 已断开") : "";
}
function cloudRemoteNote(status: CloudRemoteStatus | undefined): string {
if (status?.online) {
return t("互联网连接需要登录同一账号,内容端到端加密;局域网直连只在可信网络中开启。");
}
switch (status?.reason) {
case "relay_unreachable":
return t("中转服务暂时无法连接,请检查网络或代理设置后重试。");
case "relay_refused":
return t("中转服务拒绝了这台设备的连接,请稍后重试。");
default:
return t("登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。");
}
}
/** A line under the button when a phone comes or goes, for as long as it takes
* to read. Not while the card is open: the list there already shows it. */
function usePresenceNote(devices: PairedDevice[] | undefined, open: boolean): string {
@@ -306,9 +320,7 @@ function PhoneCard({ share, failure }: { share: Share; failure: string }) {
)}
<p className="pc-note">
{st.cloudRemote?.online
? t("互联网连接需要登录同一账号,内容端到端加密;局域网直连只在可信网络中开启。")
: t("登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。")}
{cloudRemoteNote(st.cloudRemote)}
</p>
</div>
);
+34
View File
@@ -0,0 +1,34 @@
import { useState } from "react";
import { t } from "../i18n";
import { currentStep, stepDone, stepLabel, type PlanStep } from "../state/session";
import { Plan } from "./Plan";
import { StudioIcon } from "./StudioIcon";
/** The list belongs above the line that narrates the turn, because that is
* what it is about. Mounted whether or not it is shown, so the fold survives a
* visit to another view. */
export function PlanFold({ plan, shown }: { plan: PlanStep[]; shown: boolean }) {
const [open, setOpen] = useState(false);
if (plan.length === 0 || !shown) return null;
return (
<div className="studio-todo" data-open={open ? "" : undefined}>
{/* One line by default. The composer already carries the run line,
the outbox and its own toolbar; a plan opened over all of them
pushes the box a person types in off the bottom of a laptop. */}
<button
type="button"
className="studio-todo-sum"
data-action="plan.fold"
aria-expanded={open}
onClick={() => setOpen((on) => !on)}
>
<StudioIcon name="list" />
<b>{t("计划")}</b>
<span>{stepLabel(plan[currentStep(plan)] ?? plan[0])}</span>
<small>{plan.filter(stepDone).length}/{plan.length}</small>
<StudioIcon name="down" className="studio-todo-fold" />
</button>
{open && <div className="studio-todo-body"><Plan steps={plan} /></div>}
</div>
);
}
+8 -1
View File
@@ -98,6 +98,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
// shut, because the row's own text is the only thing that may fill it.
const [unread, setUnread] = useState<{ id: string; why: string } | null>(null);
const box = useRef<HTMLTextAreaElement>(null);
const readEpoch = useRef(0);
// The body arrives after the click, so focus waits for the field to exist.
useEffect(() => {
@@ -108,17 +109,20 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
async (id: string) => {
// Opening on the preview would put a cut-off line in the box and save it
// back as the whole instruction.
const epoch = ++readEpoch.current;
setUnread(null);
let body: string;
try {
body = await onRead(id);
} catch (e) {
if (epoch !== readEpoch.current) return;
// A read that failed is not an empty instruction. Filling the box with
// "" would have the user retype a line they never saw, and the save
// replaces the whole entry with it.
setUnread({ id, why: reason(e) });
return;
}
if (epoch !== readEpoch.current) return;
setDraft(body);
setEditing(id);
},
@@ -212,7 +216,10 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") setEditing("");
if (e.key === "Escape") {
e.preventDefault();
setEditing("");
}
// Enter commits; the line is one instruction, and a queue
// row is not where a paragraph gets composed.
if (e.key === "Enter" && !e.shiftKey && !touchKeyboard() && !e.nativeEvent.isComposing) {
+26
View File
@@ -0,0 +1,26 @@
import { t } from "../i18n";
import { RMark } from "./RMark";
import { RunTokens } from "./RunTokens";
export function RunLine({ label, running, blocked, sent, received, estimated }: {
label: string;
running: boolean;
blocked: boolean;
sent: number;
received: number;
estimated: boolean;
}) {
return (
<div
className="studio-runstate"
role="status"
aria-live="polite"
data-running={running && !blocked ? "" : undefined}
data-waiting={blocked ? "" : undefined}
data-idle={running || blocked ? undefined : ""}
>
<span className="studio-runlabel"><RMark /><span>{t(label)}</span></span>
<RunTokens sent={sent} received={received} estimated={estimated} />
</div>
);
}
@@ -46,6 +46,13 @@ describe("what the runtime has to say about itself", () => {
expect(screen.getByRole("status").textContent).toContain("recovered a forked session");
});
it("words a recovered inbox from its count, not the payload", () => {
render(<RuntimeBar notices={[notice({ code: "inbox_recovered", text: "Recovered 3 pending instruction(s).", detail: '{"count":3}' })]} onSeen={() => {}} />);
const bar = screen.getByRole("status");
expect(bar.textContent).toContain("已恢复 3 条未完成的指令");
expect(bar.textContent).not.toContain("count");
});
it("hands back the id of the one dismissed", async () => {
const onSeen = vi.fn();
render(<RuntimeBar notices={[notice(), notice({ id: "r2" })]} onSeen={onSeen} />);
+5 -3
View File
@@ -1,6 +1,7 @@
import { t } from "../i18n";
import { NOTICE_TEXT } from "../i18n/notices";
import { EXTENSION_SKIPPED, extensionSkippedVars } from "../i18n/extension_skipped";
import { INBOX_RECOVERED, inboxRecoveredVars } from "../i18n/inbox_recovered";
import type { RuntimeNotice } from "../state/session";
import type { SessionState } from "../state/session_types";
import { stallShown } from "../state/stall";
@@ -24,11 +25,12 @@ export function RuntimeBar({ notices, onSeen, watch, onStall, onSettings }: {
{stall && onStall && <StallBar stall={stall} onAct={onStall} />}
{notices.map((n) => {
const skipped = n.code === EXTENSION_SKIPPED ? extensionSkippedVars(n.detail) : undefined;
const said = n.code && (n.code !== EXTENSION_SKIPPED || skipped) ? NOTICE_TEXT[n.code] : undefined;
const recovered = n.code === INBOX_RECOVERED ? inboxRecoveredVars(n.detail) : undefined;
const said = n.code && (n.code !== EXTENSION_SKIPPED || skipped) && (n.code !== INBOX_RECOVERED || recovered) ? NOTICE_TEXT[n.code] : undefined;
return (
<div key={n.id} className="rtbar" data-lvl={n.level} role="status">
<span className="t">{said ? t(said, skipped) : n.text}</span>
{n.detail && !skipped && n.code !== EXTENSION_SKIPPED && <span className="why">{n.detail}</span>}
<span className="t">{said ? t(said, skipped ?? recovered) : n.text}</span>
{n.detail && !skipped && !recovered && n.code !== EXTENSION_SKIPPED && n.code !== INBOX_RECOVERED && <span className="why">{n.detail}</span>}
{skipped && onSettings && (
<button data-action="settings.section" data-value="ext" onClick={() => onSettings("ext")}>{t("打开「工具与集成」")}</button>
)}
+11 -18
View File
@@ -21,52 +21,45 @@ export function useRuntimeReload(port: AgentPort, onDone: () => void) {
const currentConnection = useRef(connection);
currentConnection.current = connection;
const attempts = useRef(0);
const [state, setState] = useState<State>("");
const [note, setNote] = useState("");
const [feedback, setFeedback] = useState<{ state: State; note: string }>({ state: "", note: "" });
const { state, note } = feedback;
if (connection.port !== port) {
setConnection({ port });
setState("");
setNote("");
setFeedback({ state: "", note: "" });
}
const go = useCallback(async () => {
const attempt = ++attempts.current;
setState("run");
setNote(t("正在重启常驻进程,重新扫描技能、命令和钩子…"));
setFeedback({ state: "run", note: t("正在重启常驻进程,重新扫描技能、命令和钩子…") });
try {
await port.reloadExtensions();
if (currentConnection.current !== connection || attempts.current !== attempt) return;
setState("ok");
setNote(t("已生效,下一轮开始用新的扩展"));
setFeedback({ state: "ok", note: t("已生效,下一轮开始用新的扩展") });
onDone();
} catch (e) {
if (currentConnection.current !== connection || attempts.current !== attempt) return;
// A refusal is the kernel's, and it knows why: a turn in flight, a
// background job, a session that moved. Say its reason, not a generic one.
setState("bad");
setNote(reason(e));
setFeedback({ state: "bad", note: reason(e) });
}
}, [port, onDone, connection]);
useEffect(() => {
if (state !== "ok") return;
if (feedback.state !== "ok") return;
const id = setTimeout(() => {
setState("");
setNote("");
setFeedback({ state: "", note: "" });
}, SETTLED_MS);
return () => clearTimeout(id);
}, [state]);
}, [feedback]);
return {
applied: () => {
attempts.current++;
setState("ok");
setNote(t("已生效,下一轮开始用新的扩展"));
setFeedback({ state: "ok", note: t("已生效,下一轮开始用新的扩展") });
},
report: (message: string) => {
attempts.current++;
setState("bad");
setNote(t("更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。", { reason: message }));
setFeedback({ state: "bad", note: t("更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。", { reason: message }) });
},
action: (
<button className="act reload" data-action="extensions.reload" data-s={state || undefined} disabled={state === "run"} onClick={go}>
+17 -1
View File
@@ -22,6 +22,12 @@ const MCP_STATE: Record<string, string> = {
pending: "待授权 · 来自项目声明",
};
// Why a pending server waits, keyed by the kernel's code. The state label
// already says a project declared it; a code with no entry adds nothing.
const PENDING_WHY: Record<string, string> = {
changed_since_enabled: "启用之后它要启动的内容变了,或是由旧版本启用的。确认下面的命令后重新启用。",
};
// A tag only when the schema carries the server's tools or config asks it to:
// deferred is the default, and a badge on every row is a badge on none.
function loadTag(m: McpEntry): { text: string; pending: boolean } | null {
@@ -173,12 +179,21 @@ export function ServerRow({
)}
</div>
);
const why = failed || m.error;
// Approving a repository's server approves what it runs, so the line it
// launches sits beside the switch that approves it.
const launch = m.state === "pending" && !!m.launch && (
<div className="srv-ab" data-launch>
<span className="ds">{t("将启动")} · <code>{m.launch}</code></span>
</div>
);
const pendingWhy = m.state === "pending" && m.pendingReason ? PENDING_WHY[m.pendingReason] : undefined;
const why = failed || (pendingWhy ? t(pendingWhy) : m.error);
if (!tools.length) {
return (
<div className="srv mcp-server" data-st={m.state} data-local={m.localOverride ? "" : undefined} aria-busy={!!busy}>
<div className="srv-hd">{head}</div>
{about}
{launch}
{why && <div className="why">{why}</div>}
{confirm}
</div>
@@ -190,6 +205,7 @@ export function ServerRow({
<details className="srv mcp-server" data-st={m.state} data-local={m.localOverride ? "" : undefined} aria-busy={!!busy} open={confirming || undefined}>
<summary>{head}</summary>
{about}
{launch}
{why && <div className="why">{why}</div>}
{confirm}
{load}
+10 -9
View File
@@ -162,20 +162,21 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
.catch((e) => { if (current()) { setSkills([]); setExtErrors((errors) => ({ ...errors, skills: reason(e) })); } });
void Promise.allSettled([mcpRead, packageRead, skillRead]).then(() => { if (current()) setExtRefreshing(false); });
}, [port, scopeAt]);
const currentExt = useRef({ port, reloadExt, onChanged });
currentExt.current = { port, reloadExt, onChanged };
const afterExtChange = useCallback(() => {
if (currentExt.current.port !== port) return;
currentExt.current.reloadExt();
currentExt.current.onChanged();
}, [port]);
const reload = useRuntimeReload(port, afterExtChange);
// Adding or removing a source changes what the picker above can offer, so
// the list is reloadable rather than read once at mount.
const homePort = networkPort ?? port;
const loadModels = useModelCatalog(port, homePort, setModels);
const currentExt = useRef({ port, reloadExt, loadModels, onChanged });
currentExt.current = { port, reloadExt, loadModels, onChanged };
const afterExtChange = useCallback(() => {
if (currentExt.current.port !== port) return;
currentExt.current.reloadExt();
currentExt.current.loadModels();
currentExt.current.onChanged();
}, [port]);
const reload = useRuntimeReload(port, afterExtChange);
// Three sections whose row used to report nothing. Loaded here rather than in
// reloadExt because none of them moves with the scope the extension lists are
// read at, and dropped on failure: a table of contents that says "—" where a
-798
View File
@@ -1,798 +0,0 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { t } from "../i18n";
import { listenAction } from "./listen";
import { useRuntimeReload } from "./RuntimeReload";
import { HttpError } from "../port/port";
import type { AccountState, AgentPort, Appearance as Look, CapabilityScope, McpEntry, ModelEntry, PluginPackage, SessionStatus, SkillEntry } from "../port/port";
import { arrowTabs } from "./tablist";
import { bytes, tokens as fmtTokens } from "../i18n/format";
import { ICON, NAV, SECTION_NAME, SETTINGS, settingMatches } from "./prefsnav";
import { Group, SCOPE_SAID } from "./Group";
import type { Section } from "./prefsnav";
import { AddServer } from "./AddServer";
import { PhoneAccess } from "./PhoneAccess";
import { Remotes } from "./Remotes";
import type { HubPort } from "../port/hub";
import type { RemoteHost } from "../port/remote";
import { AddPlugin } from "./AddPlugin";
import { Packages } from "./Packages";
import { ExtTabs, MarketGroup } from "./Market";
import { InstalledLocation } from "./InstalledLocation";
import { Switch } from "./Switch";
import { ServerRow } from "./ServerRow";
import { SkillRow } from "./SkillRow";
import { Hooks } from "./Hooks";
import { Network } from "./Network";
import { Shell as ShellPicker } from "./Shell";
import { Rules } from "./Rules";
import { ConfigTrouble } from "./ConfigTrouble";
import { Compaction } from "./Compaction";
import { Sandbox } from "./Sandbox";
import { BrowserTools } from "./BrowserTools";
import { ProgressWatch } from "./ProgressWatch";
import { Account } from "./Account";
import { Backup } from "./Backup";
import { Providers } from "./Providers";
import { activeKind, groupVendors } from "./Models";
import { ModelUsage } from "./ModelUsage";
<<<<<<< HEAD
import { useModelCatalog } from "./useModelCatalog";
=======
import { useRoles } from "./useRoles";
>>>>>>> 1c1231892 (fix(settings): say when a subagent_models entry outranks the subagent model)
import { KIND_LABEL } from "./vendors";
import { planProtocolSwitch } from "./protocolswitch";
import { Boundary } from "./Boundary";
import { Path } from "./Path";
import { About } from "./About";
import { Memory } from "./Memory";
import { DEFAULT_DAYS } from "./Usage";
import { UsageSettings } from "./UsageSettings";
import { Storage } from "./Storage";
import { Appearance, SCHEMES } from "./Appearance";
import { ScopeBar } from "./CapabilityScope";
import { SAVED_NOT_APPLIED, reason } from "../i18n/kernel";
import { SettingsHeading } from "./SettingsHeading";
import { APPROVALS, approvalName, approvalNote } from "./approvals";
// What still lives in the old desktop app. Bots are not on the roadmap, so
// they are not a promise to keep here either. Signing in and reading
// versions landed here; downloading and applying an update did not, and naming
// only that half keeps this list a fact rather than a promise.
// Everything that used to live here has a home now, so the 「高级」 tab filters
// itself out below. Keep the list rather than the tab: the next thing that is
// real but not built yet belongs in one line here, not in a half-made panel.
const ELSEWHERE: string[] = [];
// The user's question is "is it there and does it work", so the state is the
// label. A failed server keeps its error on the row that names it.
const NET_MODE: Record<string, string> = { auto: "跟随系统", env: "环境变量", custom: "手动", off: "直连" };
interface Props {
// The window's own port, not a pane's: remote machines belong to the window,
// and their endpoints sit above every pane's prefix.
hub: HubPort;
onError: (e: unknown) => void;
port: AgentPort;
// Network belongs to a machine rather than a pane; host names it when that
// machine is not this one.
networkPort?: AgentPort;
networkHost?: string;
status: SessionStatus | null;
theme: string;
reloadThemes: () => void;
onTheme: (t: string) => void;
contrast: string;
weight: string;
onWeight: (v: string) => void;
look: Look;
onLook: (look: Look) => void;
onContrast: (c: string) => void;
onClose: () => void;
onChanged: () => void;
onSessionsRecovered?: () => void;
at?: string;
account: AccountState | null;
accountUnread: string;
reloadAccount: () => void;
workspaceRoot?: string;
}
export function Settings({ hub, onError, port, networkPort, networkHost, status, theme, onTheme, contrast, onContrast, weight, onWeight, look, onLook, onClose, onChanged, onSessionsRecovered, reloadThemes, at: opened, account: acct, accountUnread, reloadAccount, workspaceRoot }: Props) {
const [openedSection, openedAnchor = ""] = (opened ?? "").split(":", 2);
const [at, setAt] = useState<Section>((openedSection as Section) || "session");
// 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到
// 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。
const [query, setQuery] = useState("");
const [landed, setLanded] = useState(openedAnchor);
// Opened from the composer's effort menu: the running source's editor opens
// on its reasoning fields. The model ref's first segment is the source name.
const declare = openedAnchor === "effort-declare" ? status?.modelRef?.split("/")[0] : undefined;
const [models, setModels] = useState<ModelEntry[]>([]);
const { roles, overrides, loadRoles } = useRoles(port);
const [protocol, setProtocol] = useState<Record<string, string>>({});
const [mcp, setMcp] = useState<McpEntry[]>([]);
const [scope, setScope] = useState<CapabilityScope | null>(null);
const [scopes, setScopes] = useState<CapabilityScope[]>([]);
// Which project the extension tab is answering for. Empty is the running one,
// so the common case stays exactly what it was.
const [scopeAt, setScopeAt] = useState("");
const [skills, setSkills] = useState<SkillEntry[]>([]);
const [implicit, setImplicit] = useState(true);
const [live, setLive] = useState(true);
const [busy, setBusy] = useState("");
const [note, setNote] = useState<{ text: string; unapplied: boolean } | null>(null);
const setFailed = useCallback((text: string) => setNote(text ? { text, unapplied: false } : null), []);
const [adding, setAdding] = useState(false);
const [remoteBook, setRemoteBook] = useState<RemoteHost[] | null>(null);
const [packages, setPackages] = useState<PluginPackage[]>([]);
const [addingPkg, setAddingPkg] = useState(false);
const [extTab, setExtTab] = useState<"installed" | "market">(openedAnchor === "market" ? "market" : "installed");
const [installedTarget, setInstalledTarget] = useState<{ kind: string; name: string } | null>(null);
const [extRefreshing, setExtRefreshing] = useState(false);
const [extErrors, setExtErrors] = useState<Partial<Record<"mcp" | "packages" | "skills", string>>>({});
const extRefresh = useRef(0);
const [updatingPkg, setUpdatingPkg] = useState({ name: "", applying: false });
const applyingChanged = useCallback((applying: boolean) => setUpdatingPkg((p) => ({ ...p, applying })), []);
const [hookCount, setHookCount] = useState(0);
const [netMode, setNetMode] = useState("");
const [memCount, setMemCount] = useState(0);
const [ruleCount, setRuleCount] = useState(0);
const root = useRef<HTMLDivElement>(null);
const veiled = useRef(false);
const reloadExt = useCallback(() => {
const refresh = ++extRefresh.current;
const current = () => extRefresh.current === refresh;
setExtRefreshing(true);
setExtErrors({});
const where = scopeAt || undefined;
port.capabilityScopes().then((c) => { if (current()) setScopes(c); }).catch(() => { if (current()) setScopes([]); });
const mcpRead = port.mcp(where).then((c) => {
if (!current()) return;
setMcp(c.servers);
setScope(c.scope);
setLive(c.live !== false);
}).catch((e) => { if (current()) { setMcp([]); setExtErrors((errors) => ({ ...errors, mcp: reason(e) })); } });
const packageRead = port.plugins().then((c) => { if (current()) setPackages(c); }).catch((e) => { if (current()) { setPackages([]); setExtErrors((errors) => ({ ...errors, packages: reason(e) })); } });
port.hooks().then((c) => { if (current()) setHookCount(c.hooks.length); }).catch(() => { if (current()) setHookCount(0); });
port.network().then((n) => { if (current()) setNetMode(t(NET_MODE[n.mode] ?? n.mode)); }).catch(() => { if (current()) setNetMode(""); });
port.memories().then((c) => { if (current()) setMemCount(c.memories.length); }).catch(() => { if (current()) setMemCount(0); });
port.permissions().then((p) => { if (current()) setRuleCount(p.deny.length + p.ask.length + p.allow.length); }).catch(() => { if (current()) setRuleCount(0); });
const skillRead = port.skills(where).then((c) => {
if (!current()) return;
setSkills(c.skills);
setImplicit(c.implicit);
})
.catch((e) => { if (current()) { setSkills([]); setExtErrors((errors) => ({ ...errors, skills: reason(e) })); } });
void Promise.allSettled([mcpRead, packageRead, skillRead]).then(() => { if (current()) setExtRefreshing(false); });
}, [port, scopeAt]);
const currentExt = useRef({ port, reloadExt, onChanged });
currentExt.current = { port, reloadExt, onChanged };
const afterExtChange = useCallback(() => {
if (currentExt.current.port !== port) return;
currentExt.current.reloadExt();
currentExt.current.onChanged();
}, [port]);
const reload = useRuntimeReload(port, afterExtChange);
// Adding or removing a source changes what the picker above can offer, so
// the list is reloadable rather than read once at mount.
const homePort = networkPort ?? port;
const loadModels = useModelCatalog(port, homePort, setModels);
// Three sections whose row used to report nothing. Loaded here rather than in
// reloadExt because none of them moves with the scope the extension lists are
// read at, and dropped on failure: a table of contents that says "—" where a
// number failed to arrive has invented a fact about the section.
const [tally, setTally] = useState<Partial<Record<Section, string>>>({});
useEffect(() => {
const put = (k: Section, v: string) => setTally((prev) => ({ ...prev, [k]: v }));
port.storage().then((st) => put("storage", bytes(st.roots.reduce((n, r) => n + (r.bytes || 0), 0)))).catch(() => {});
// The same window Usage itself opens on, or the list and the page it opens
// would report two different totals for one word.
port.usage(DEFAULT_DAYS).then((u) => put("usage", fmtTokens(u.tokens))).catch(() => {});
port.versions().then((v) => put("versions", v.current || "dev")).catch(() => {});
}, [port]);
// Focus lands once, when the pane opens, and goes back where it came from on
// the way out. onClose is a fresh arrow on every parent render, so re-running
// this with it would pull focus out of whatever the user is holding — a
// native select closes its dropdown the instant it is blurred, which reads as
// the menu refusing to open. Closing used to leave focus on <body>, which
// left a reader on the keyboard with no position at all.
useEffect(() => {
const from = document.activeElement as HTMLElement | null;
root.current?.focus();
return () => {
if (from?.isConnected) from.focus();
};
}, []);
useEffect(() => {
const onKey = (e: Event) => (e as KeyboardEvent).key === "Escape" && !updatingPkg.applying && onClose();
return listenAction(window, "keydown", { action: "settings.close", listener: onKey });
}, [onClose, updatingPkg.applying]);
useEffect(() => {
loadModels();
loadRoles();
reloadExt();
}, [loadModels, loadRoles, reloadExt]);
// Read once for the tab's own count. Remotes keeps its own copy: it also
// needs the ssh_config candidates, which a tab label has no use for.
useEffect(() => {
hub
.remoteHosts()
.then(setRemoteBook)
.catch(() => setRemoteBook(null));
}, [hub]);
// A refused switch has to say so. The kernel turns one down while a turn or a
// background job is running, and swallowing that leaves the click looking like
// the row simply does not work.
const run = async (what: string, fn: () => Promise<void>) => {
setBusy(what);
setNote(null);
try {
await fn();
onChanged();
} catch (e) {
setNote({ text: reason(e), unapplied: e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "") });
} finally {
setBusy("");
}
};
// The levels the selected model's endpoint actually accepts. A fixed list
// here offered every model six of them and let the user set depths the
// provider then ignored or rejected.
const vendors = groupVendors(models);
const kindFor = (key: string) => {
const v = vendors.find((x) => x.key === key);
return v ? activeKind(v, status?.modelRef) : "";
};
const switchProtocol = (key: string, kind: string) => {
const plan = planProtocolSwitch(vendors.find((x) => x.key === key), models, status?.modelRef, kind);
if (plan.do === "stay") {
setFailed(t("{door} 不提供 {model},请在下方选择该来源支持的模型。", {
door: t(KIND_LABEL[kind] ?? kind),
model: plan.model,
}));
return;
}
setProtocol((p) => ({ ...p, [key]: kind }));
if (plan.do === "switch") run(plan.ref, () => port.setModel(plan.ref));
};
const efforts = models.find((m) => m.ref === status?.modelRef)?.efforts ?? [];
const preset = t("自动");
const approval = approvalName(status?.toolApprovalMode, "—");
const broken = mcp.filter((m) => m.state === "failed").length;
// A package owns what it brought, and its own row already lists it. What is
// left is what the user added by hand, which is the only thing these two
// lists can act on without contradicting the package above them. Ownership
// is read off the packages themselves: a live server reports the config
// layer it was merged from, which is not the same question.
const owned = new Set(packages.flatMap((p) => p.mcpServers?.map((s) => s.name) ?? []));
const looseMcp = mcp.filter((m) => !owned.has(m.name));
const looseSkills = skills.filter((s) => !s.plugin);
const looseOn = looseSkills.filter((s) => s.enabled).length;
// Null means this kernel does not do remote panes at all, which is what
// takes the whole tab out rather than showing an empty one.
const online = (remoteBook ?? []).filter((h) => h.status === "connected" || h.status === "degraded").length;
const remoteTally = remoteBook === null ? "" : online ? t("{n} 台在线", { n: online }) : remoteBook.length ? `${remoteBook.length}` : "";
// The table of contents is also the status board: the value that matters for
// each section rides on its own row, so the risky one is legible from here.
const nav: Record<Section, string> = {
session: status?.plan ? t("计划模式") : preset,
model: status?.modelRef?.split("/").pop() ?? "—",
providers: models.length ? t("{n} 个服务", { n: groupVendors(models).length }) : "",
tools: ruleCount ? `${approval} · ${ruleCount}` : approval,
hooks: hookCount ? t("{n} 条", { n: hookCount }) : t("关"),
ext: broken ? t("{n} 个异常", { n: broken }) : packages.length ? t("{n} 个包", { n: packages.length }) : `${looseMcp.length + looseOn}`,
network: netMode,
remote: remoteTally,
memory: memCount ? t("{n} 条", { n: memCount }) : "",
usage: tally.usage ?? "",
storage: tally.storage ?? "",
account: acct === null ? "" : acct.signedIn ? (acct.user?.label ?? t("已登录")) : t("未登录"),
versions: tally.versions ?? "",
appearance: t(SCHEMES.find(([id]) => id === theme)?.[1] ?? ""),
advanced: ELSEWHERE.length ? `${ELSEWHERE.length}` : "",
};
const danger = (id: Section) =>
(id === "tools" && status?.toolApprovalMode === "yolo") || (id === "ext" && broken > 0);
// Null remote means this kernel does not do remote panes; advanced holds
// whatever has moved out of the config file and has not moved in yet.
const shown = (id: Section) =>
(id !== "remote" || remoteBook !== null) && (id !== "advanced" || ELSEWHERE.length > 0);
// 搜索命中的是这张表里的条目,包括当前没有挂载的那些分区;能不能显示由 shown
// 决定,和它有没有被渲染过无关。
const found = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return null;
return SETTINGS.filter((e) => shown(e.section) && settingMatches(e, q));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, remoteBook]);
// 落点:跳过去之后短暂标一下「就是这里」。这是导航反馈,不是状态变化。
const go = (section: Section, anchor: string) => {
setAt(section);
setQuery("");
setLanded(anchor);
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
};
useEffect(() => {
if (!landed) return;
const el = root.current?.querySelector<HTMLElement>(`#set-${CSS.escape(landed)}`);
// 折叠起来的块在 DOM 里,但不在屏幕上 —— 滚过去会停在一片空白上。要开哪一
// 个由结构回答:不需要再维护一张「哪些设置是高级」的表,也不需要把落点穿到
// 每个页面里去。
for (let d = el?.closest("details"); d; d = d.parentElement?.closest("details") ?? null) d.open = true;
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
const aim = () => el?.scrollIntoView({ block: "start", behavior });
aim();
// The page is still growing under the scroll: theme packs and the font list
// arrive late and push the anchor below a target already locked in. A jump
// owns its landing until it lapses, so re-aim for as long as it is owed.
const grew = new ResizeObserver(aim);
const col = root.current?.querySelector(".prefs-col");
if (col) grew.observe(col);
// 「就是这里」是一次导航反馈,不是这个设置变了 —— 所以它只是元素上短暂的
// 一个标记,不进 React 状态,也不用往每个块穿一个纯表现的 prop。
el?.setAttribute("data-landed", "");
const done = setTimeout(() => setLanded(""), 900);
return () => {
clearTimeout(done);
grew.disconnect();
el?.removeAttribute("data-landed");
};
}, [landed, at]);
return (
<div
className="prefs"
ref={root}
tabIndex={-1}
onMouseDown={(e) => { veiled.current = e.target === e.currentTarget; }}
// Both ends of the press have to land on the veil. A drag that started
// inside the sheet and let go outside is a selection, not a dismissal.
onMouseUp={(e) => { if (veiled.current && e.target === e.currentTarget && !updatingPkg.applying) onClose(); }}
data-action-mouseup="settings.close"
>
<div className="prefs-sheet" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
<div className="prefs-hd">
<h1 id="prefs-title">{t("设置")}</h1>
{/* 原来这里挂着一句「改动立刻生效;需要重建运行时的……」。它对三档里的
两档说了话,而且要读的人自己判断手上这一项属于哪一档 —— 那句话现在
落在每一个设置块自己身上。 */}
<input
className="prefs-find"
value={query}
// Typing filters and Escape takes the filter back: two events on one
// box, both operating the same one thing.
data-action-change="settings.search"
data-action-keydown="settings.search"
placeholder={t("搜索设置…")}
aria-label={t("搜索设置")}
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
// 一层一层退:有搜索就先退搜索,没有才退整个设置。
if (e.key === "Escape" && query) {
e.stopPropagation();
setQuery("");
}
}}
/>
<button className="btn sm" data-action="settings.close" disabled={updatingPkg.applying} onClick={onClose}>
{t("关闭")} <span className="esc">Esc</span>
</button>
{found !== null && (
<div className="prefs-found" role="listbox" aria-label={t("搜索设置")}>
{found.length === 0 ? (
<p className="none">{t("没有匹配的设置")}</p>
) : (
found.map((e) => (
<button
key={e.anchor}
role="option"
aria-selected={false} disabled={updatingPkg.applying}
data-action="settings.section"
data-value={e.section}
data-target={e.anchor}
onClick={() => go(e.section, e.anchor)}
>
<span className="where">{t(SECTION_NAME[e.section] ?? e.section)}</span>
<span className="what">{t(e.title)}</span>
{/* Search is where a setting is met with no page around it,
so it is where ownership is worth most: "代理" turning up
under 网络 says nothing about whether it is this project's
proxy or this machine's. Same catalogue as the block. */}
<span className="cost">
{[
t(SCOPE_SAID[e.scope]),
e.apply === "runtime-rebuild" ? t("重建运行时") : e.apply === "restart" ? t("重启后生效") : "",
].filter(Boolean).join(" · ")}
</span>
</button>
))
)}
</div>
)}
</div>
<ConfigTrouble port={port} onRepaired={onChanged} />
<div className="prefs-body">
<nav className="prefs-nav" role="tablist" aria-label={t("设置分类")} onKeyDown={arrowTabs}>
{NAV.map(([group, items]) => {
const rows = items.filter(([id]) => shown(id));
// A heading over nothing is worse than no heading: remote is absent
// on a kernel without it, advanced until something moves in.
if (rows.length === 0) return null;
return (
<Fragment key={group}>
<div className="navsec">{t(group)}</div>
{/* One tab stop for the list, arrows move inside it: twelve
stops made Tab the wrong way through this screen. */}
{rows.map(([id, name]) => (
<button key={id} id={`prefs-${id}`} role="tab" title={t(name)}
data-action="settings.section" data-value={id}
tabIndex={at === id ? 0 : -1}
aria-selected={at === id} disabled={updatingPkg.applying} onClick={() => setAt(id)}>
<svg viewBox="0 0 16 16" aria-hidden="true">{ICON[id]}</svg>
<span className="nm">{t(name)}</span>
<span className="nv" title={nav[id] || undefined} data-danger={danger(id) ? "" : undefined}>
{nav[id]}
</span>
</button>
))}
</Fragment>
);
})}
</nav>
<div className="prefs-main" role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
<Boundary
fallback={
<div className="find" data-lvl="err" role="alert">
<span className="t">{t("这个设置分区出错了")}</span>
<span className="why">{t("其他分区与当前会话不受影响;关闭设置后重新打开可重试。")}</span>
</div>
}
>
{/* Keyed on the section: replacing this column is the navigation,
and it is the one thing here whose mount really is the cause. */}
<div className="prefs-col" key={at}>
<SettingsHeading section={at} value={nav[at]} danger={danger(at)} />
{/* Every refused write on every page lands in one state, so it is
said in one place. Copied onto the pages somebody remembered, it
was missing from the third that writes it: 创建隔离副本 was
refused by the kernel and the screen showed nothing at all. */}
{note && (
<div className="find" data-lvl="warn" role={note.unapplied ? "status" : "alert"}>
<span className="t">{t(note.unapplied ? "已保存,尚未生效" : "操作未完成")}</span>
<span className="why">{note.text}</span>
</div>
)}
{at === "session" && (
<>
<Group id="preset" title={t("完成标准")} now={preset} hint={t("系统根据任务风险自动决定需要的验证程度,不再要求手动选择均衡或交付。")}>
<p className="note">{t("日常问答保持轻量;涉及文件修改、代码执行或明确交付物时自动增加验证与复核。")}</p>
</Group>
{/* An on/off that reverses by clicking again is a switch, not two
options — the same control the recipes and the sandbox's
network egress use. */}
<Group id="plan-mode" title={t("计划模式")} hint={t("开启时 agent 无法获得写权限。该限制由工具本身实施,不依赖提示词中的约定。")}>
<div className="lrow">
<span className="tx">
<span className="lb">{t("仅生成计划,不写入")}</span>
<span className="ds">
{t(status?.plan ? "仅生成只读计划,经你批准后由内核自动关闭" : "正常执行:已批准的写入操作直接执行")}
</span>
</span>
<Switch
data-action="plan.mode"
on={status?.plan === true}
busy={busy === "plan"}
label={t("计划模式")}
onClick={() => run("plan", () => port.setPlanMode(!status?.plan))}
/>
</div>
</Group>
<ProgressWatch port={port} />
<Group id="session-dir" title={t("会话写入位置")}>
<div className="kv">
<span className="k">{t("工作目录")}</span>
<span className="v"><Path of={status?.cwd ?? "—"} /></span>
</div>
<p className="note">
{t("在左栏管理文件夹:底部添加,展开后可新建会话。会话归属于创建它的文件夹,不会移动到其他位置。")}
</p>
{/* A thing that happens once, not a state to sit in — so it is
a button. As an option row it carried a selected look it can
never have. */}
<div className="lrow">
<span className="tx">
<span className="lb">{t("创建隔离副本")}</span>
<span className="ds">{t("在 Git worktree 中创建独立副本,修改不会影响当前分支")}</span>
</span>
<button className="act" data-action="workspace.isolate" disabled={!!busy} onClick={() => run("isolate", () => port.isolateWorkspace())}>
{t(busy === "isolate" ? "启用中…" : "创建")}
</button>
</div>
</Group>
</>
)}
{at === "providers" && (
<Group id="providers" title={t("模型服务")}
hint={t("选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。")}>
<Providers port={port} onChanged={() => { loadModels(); onChanged(); }} onFailed={setFailed} protocol={protocol}
activeKindFor={(a) => kindFor(a.key)}
declare={declare}
onProtocol={(a, kind) => switchProtocol(a.key, kind)} />
</Group>
)}
{at === "model" && (
<>
<Group id="model" title={t("按用途选择模型")} now={nav.model}
hint={t("默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。切换会保留对话并重建运行时,任务执行期间无法修改。")}>
<ModelUsage models={models} roles={roles} main={status?.modelRef} busy={busy} protocol={protocol}
onMain={(ref) => run(ref, async () => {
await port.setModel(ref);
await homePort.setDefaultModel(ref);
// The row reads the catalogue's default, so the list has to
// be re-read or the controlled select snaps back.
loadModels();
})}
onRole={(role, ref) => run(`role:${role}`, () => port.setRole(role, ref).finally(loadRoles))}
overrides={overrides} onClearOverride={(role, key) => run(`role:${role}`, () => port.clearRoleOverride(role, key).finally(loadRoles))} />
</Group>
{efforts.length > 0 ? (
<Group id="effort" title={t("推理强度")} hint={t("以下档位由当前模型的端点支持,auto 表示使用端点自身的默认值。")}>
<div className="seg" role="group" aria-label={t("推理强度")}>
{efforts.map((e) => (
<button key={e} data-action="reasoning.effort" data-value={e}
aria-pressed={(status?.effort || "auto") === e}
onClick={() => run(e, () => port.setEffort(e))}>
{e}
</button>
))}
</div>
</Group>
) : (
<Group id="effort" title={t("推理强度")} hint={t("当前模型未提供可调的推理档位,因此不显示该选项。")} />
)}
<Group id="context" title={t("上下文维护")}
hint={t("默认按模型容量自动整理。自定义中转站若无法提供最大上下文,先按 160k 计算;你可以在这里填写实际容量。")}>
<Compaction port={port} onChanged={onChanged} />
</Group>
</>
)}
{at === "tools" && (
<>
<Group id="approval" title={t("工具批准")} now={approval} hint={t("这是 agent 访问文件前的唯一审批入口,被拦截的操作没有其他途径可以绕过。")}>
{/* Four rows of label-and-description was 190px for one choice,
and it was the only choice in this pane shaped that way. The
description follows the selection instead: what a档 does is
read when it is picked, not compared four at a time. */}
<div className="seg" data-text data-danger={status?.toolApprovalMode === "yolo" ? "" : undefined}
role="radiogroup" aria-label={t("工具批准")}>
{APPROVALS.map(([id, name]) => (
<button key={id} role="radio" data-action="tool-approval.mode" data-value={id}
aria-checked={status?.toolApprovalMode === id} disabled={!!busy}
onClick={() => run(id, () => port.setApprovalMode(id))}>
{t(name)}
</button>
))}
</div>
<p className="note">{approvalNote(status?.toolApprovalMode)}</p>
</Group>
<Group id="rules"
title={t("明确的规则")}
hint={t("上一项决定是否请求确认,此处决定哪些操作始终禁止、哪些无需确认。修改会重建运行时,任务运行期间无法变更。")}
>
<Rules port={port} onChanged={onChanged} />
</Group>
<Group id="sandbox"
title={t("沙箱")}
hint={t("批准之后可以操作的范围。该限制不依赖 agent 自觉:写入范围由工具实施,命令隔离由操作系统实施。")}
>
<Sandbox port={port} onChanged={onChanged} />
</Group>
<Group id="shell"
title={t("命令执行程序")}
hint={t("agent 的所有命令都由该程序执行,它也决定命令使用哪种语法;选择错误会导致每条命令都执行失败。下方只列出本机已安装的程序。更换需要重建运行时,任务运行期间无法修改。")}
>
<ShellPicker port={port} onChanged={onChanged} />
</Group>
<Group id="browser-tools"
title={t("内置浏览器")}
hint={t("agent 用来打开和操作网页的工具。该开关单独保存,不受命令行版本浏览器设置的影响。修改会重建运行时,任务运行期间无法变更。")}
>
<BrowserTools port={port} onChanged={onChanged} />
</Group>
</>
)}
{at === "hooks" && (
<Group id="hooks"
title={t("自动化")}
hint={t("在 agent 执行任务前后运行自定义命令。这些命令在本机以当前用户权限运行;可拦截 agent 的两个事件已在下方标出。")}
>
<Hooks port={port} onChanged={afterExtChange} />
</Group>
)}
{at === "ext" && (
<>
<ExtTabs at={extTab} disabled={updatingPkg.applying} onPick={(tab) => { setInstalledTarget(null); setExtTab(tab); }} />
{extTab === "installed" && <InstalledLocation root={root} target={installedTarget} packages={packages} skills={skills} mcp={mcp} refreshing={extRefreshing} onFound={setInstalledTarget} />}
{extTab === "market" && <MarketGroup port={port} onApplying={applyingChanged} onInstalled={afterExtChange} onViewInstalled={(kind, name) => { setInstalledTarget({ kind, name }); setExtTab("installed"); }} account={acct} onSignIn={() => go("account", "account")} />}
{extTab === "installed" && (
<>
{scope && <ScopeBar scope={scope} scopes={scopes} onPick={setScopeAt} />}
{/* 装完、改完、删完都要过这一步才算数——把它放在包列表上面,
因为它管的是整个运行时,不是某一个包。 */}
<Group id="ext-runtime"
title={t("运行时")}
hint={t("修改扩展代码,或安装、删除、启用或停用插件包之后,用它使改动生效。当前这一轮不受影响,下一轮开始使用新的配置。")}
action={reload.action}
>
{reload.note}
</Group>
<Group id="plugins"
title={t("插件包")}
now={packages.length ? t("{n} 个", { n: packages.length }) : undefined}
hint={t("一个包可以同时提供技能、命令、自动化钩子和外部服务。安装与导入是同一个操作:提供一个仓库地址,或本机的一个文件夹。")}
action={
addingPkg ? undefined : (
<button className="act" data-action="extensions.add" disabled={!!updatingPkg.name} onClick={() => setAddingPkg(true)}>
{t("添加")}
</button>
)
}
>
{addingPkg && (
<AddPlugin port={port} onClose={() => setAddingPkg(false)} onInstalled={afterExtChange} />
)}
{updatingPkg.name && (
<AddPlugin
port={port}
updating={packages.find((p) => p.name === updatingPkg.name)}
onApplying={applyingChanged}
onClose={() => setUpdatingPkg({ name: "", applying: false })} onInstalled={afterExtChange}
/>
)}
<Packages
port={port}
packages={packages}
onChanged={afterExtChange} onReloadError={reload.report} onReloaded={reload.applied}
updating={updatingPkg.name}
onUpdate={(name) => setUpdatingPkg({ name, applying: false })}
/>
{extErrors.packages && <div className="rnote" data-s="bad" role="alert">{extErrors.packages} <button className="act" data-action="extensions.refresh" disabled={extRefreshing} onClick={reloadExt}>{t("重试")}</button></div>}
{packages.length === 0 && !addingPkg && !extRefreshing && !extErrors.packages && <div className="empty">{t("尚未安装插件包。")}</div>}
</Group>
{/* Below the packages: what was added by hand. A server the user
typed in themselves is not part of anyone's package, and
filing it under one would misname where it came from. */}
<Group id="mcp"
title={t("外部工具")}
now={looseMcp.length ? t("{n} 个服务", { n: looseMcp.length }) : undefined}
hint={t("已接入的 MCP 服务。其提供的能力与内置工具等同,列出的每一项均可访问文件与数据。关闭后立即从本轮工具列表中移除,重启后保持关闭。")}
action={
adding || !live ? undefined : (
<button className="act" onClick={() => setAdding(true)}>
{t("接入服务")}
</button>
)
}
>
{adding && live && (
<AddServer
port={port}
canProject={!!status?.workspaceRoot}
onClose={() => setAdding(false)}
onInstalled={afterExtChange}
/>
)}
{looseMcp.map((m) => (
<ServerRow key={m.name} m={m} port={port} onDone={afterExtChange} root={scopeAt} live={live} />
))}
{extErrors.mcp && <div className="rnote" data-s="bad" role="alert">{extErrors.mcp} <button className="act" data-action="extensions.refresh" disabled={extRefreshing} onClick={reloadExt}>{t("重试")}</button></div>}
{looseMcp.length === 0 && !adding && !extRefreshing && !extErrors.mcp && <div className="empty">{t("尚未接入外部服务。")}</div>}
</Group>
<Group id="skills"
title={t("技能")}
now={looseSkills.length ? t("{on}/{all} 已启用", { on: looseOn, all: looseSkills.length }) : undefined}
hint={t(
implicit
? "工作目录与「我的」中的技能。带 / 的可以由你直接调用,其余的由模型根据任务判断是否使用。关掉一个立刻生效:从你的下一条消息起模型不再拿到它,直接点名也调不动,不用新建会话。"
: "模型自动发现已关闭:仅显式指定的技能会运行。开关立即生效,自下一条消息起,无需新建会话。",
)}
>
{looseSkills.map((sk) => (
<SkillRow key={sk.name} sk={sk} implicit={implicit} port={port} onDone={afterExtChange} root={scopeAt} onFailed={setFailed} />
))}
{extErrors.skills && <div className="rnote" data-s="bad" role="alert">{extErrors.skills} <button className="act" data-action="extensions.refresh" disabled={extRefreshing} onClick={reloadExt}>{t("重试")}</button></div>}
{looseSkills.length === 0 && !extRefreshing && !extErrors.skills && <div className="empty">{t("当前工作目录下没有技能。")}</div>}
</Group>
</>
)}
</>
)}
{at === "network" && (
<Group id="network"
title={t("网络")}
hint={t("模型请求、MCP 远程服务和网页抓取都经由此处。配置错误通常表现为聊天无响应,建议先测试连接,测试会指出中断的环节。")}
>
<Network port={networkPort ?? port} host={networkHost ?? ""} />
</Group>
)}
{at === "remote" && (
<Group id="remote"
title={t("远程")}
hint={t("接入另一台机器上的工作区:内核在远程运行,界面仍在本机。远程面板与本地面板并排显示,每处都会标明所在的机器。")}
>
<Remotes hub={hub} onError={onError} />
</Group>
)}
{at === "remote" && <PhoneAccess hub={hub} onError={onError} />}
{at === "account" && (
<Group id="account"
title={t("账号")}
hint={t("Reasonix 本身不需要账号,仅在需要联网的功能中使用:社区发帖、崩溃问题跟进,以及后续的技能发布。")}
>
<Account port={port} state={acct} unread={accountUnread} reload={reloadAccount} />
</Group>
)}
{at === "account" && acct?.signedIn && acct.user && (
<Group id="backup" title={t("云备份")} hint={t("把本机的模型、扩展、记忆等配置加密保存到账号,换机器或重装后按项恢复。")}>
<Backup port={port} />
</Group>
)}
{at === "versions" && <About port={port} />}
{at === "memory" && (
<Group id="memory"
title={t("记忆")}
hint={t("agent 自动记录的内容:未经配置,但会据此执行。此处按触发时机分组,并标出上一轮实际使用的条目。")}
>
<Memory port={port} />
</Group>
)}
{at === "usage" && <UsageSettings port={port} onChanged={onChanged} />}
{at === "storage" && (
<Group id="storage" title={t("存储")} hint={t("数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。")}><Storage port={port} hub={hub} workspace={workspaceRoot ?? status?.workspaceRoot ?? ""} onRecovered={() => { onChanged(); onSessionsRecovered?.(); }} /></Group>
)}
{at === "appearance" && (
<Appearance port={port} theme={theme} onTheme={onTheme} contrast={contrast} onContrast={onContrast} weight={weight} onWeight={onWeight} reloadThemes={reloadThemes} look={look} onLook={onLook} />
)}
{at === "advanced" && (
<Group id="elsewhere" title={t("本版本尚未提供")} hint={t("以下项目尚未提供设置界面,此处仅说明它们当前的位置。")}>
{ELSEWHERE.map((x) => (
<div className="lrow" key={x}>
<span className="ds">{x}</span>
<span className="sc">{t("旧版桌面端")}</span>
</div>
))}
</Group>
)}
</div>
</Boundary>
</div>
</div>
</div>
</div>
);
}
+3 -7
View File
@@ -1,3 +1,4 @@
import { FeedbackBadge, feedbackEntryTab } from "./feedbackentry";
import { useEffect, useMemo, useRef, useState } from "react";
import { t } from "../i18n";
import type { AccountState } from "../port/port";
@@ -273,15 +274,10 @@ export function Sidebar({
</div>
<div className="railfoot">
<button className="studio-wallet" data-action="settings.section" data-value="usage" onClick={() => onSettings("usage")}><span aria-hidden="true"><StudioIcon name="wallet" /></span><b>{t("钱包与用量")}</b>{wallet && <small>{hideAmounts ? MASK : wallet}</small>}</button>
<button className="studio-wallet studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackUnread > 0 ? "mine" : "send")}>
<button className="studio-wallet studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackEntryTab(feedbackUnread))}>
<span aria-hidden="true"><StudioIcon name="feedback" /></span>
<b>{t("发送反馈")}</b>
{feedbackUnread > 0 && (
<>
<i className="fbk-badge" aria-hidden="true" title={t("{n} 项待查看", { n: feedbackUnread })}>{feedbackUnread > 9 ? "9+" : feedbackUnread}</i>
<span className="sr-only">{t("{n} 项待查看", { n: feedbackUnread })}</span>
</>
)}
<FeedbackBadge unread={feedbackUnread} />
</button>
<div className="studio-user-foot">
<AccountRow account={account} unread={accountUnread} onOpen={() => onSettings("account")} />
+33 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { onPresence, present } from "./presence";
// Where each puff sits, how big it is, and how fast it drifts. Three bands
// rather than one spread: parallax is what makes a flat blur read as depth,
@@ -13,7 +14,7 @@ const PUFFS = 34;
// viewport, so drawing it at window size would cost pixels nobody can see.
const W = 400;
const H = 225;
const FRAME = 1000 / 30;
const FRAME = 1000 / 15;
interface Puff {
x: number;
@@ -87,41 +88,48 @@ export function Sky() {
};
let raf = 0;
let timer = 0;
let last = 0;
const tick = (now: number) => {
// Waiting between frames on a timer, not on a rAF loop: a loop that does
// nothing on 3 of 4 callbacks still wakes the compositor on every one.
const frame = (now: number) => {
raf = 0;
if (still.matches || document.hidden) return;
const elapsed = now - last;
if (elapsed >= FRAME) {
paint(true, Math.min(3, elapsed / (1000 / 60)));
last = now;
}
raf = requestAnimationFrame(tick);
paint(true, Math.min(6, (now - last) / (1000 / 60)));
last = now;
timer = window.setTimeout(wake, FRAME);
};
const wake = () => {
timer = 0;
raf = requestAnimationFrame(frame);
};
const halt = () => {
if (raf) cancelAnimationFrame(raf);
if (timer) clearTimeout(timer);
raf = timer = 0;
};
const sync = () => {
if (still.matches || document.hidden) {
if (raf) cancelAnimationFrame(raf);
raf = 0;
if (still.matches || !present()) {
halt();
paint(false);
return;
}
if (!raf) {
if (!raf && !timer) {
last = performance.now();
raf = requestAnimationFrame(tick);
wake();
}
};
const theme = new MutationObserver(() => {
readTheme();
if (still.matches || document.hidden) paint(false);
if (still.matches || !present()) paint(false);
});
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-pack", "style"] });
still.addEventListener("change", sync);
document.addEventListener("visibilitychange", sync);
const unwatch = onPresence(sync);
sync();
return () => {
if (raf) cancelAnimationFrame(raf);
halt();
still.removeEventListener("change", sync);
document.removeEventListener("visibilitychange", sync);
unwatch();
theme.disconnect();
};
}, []);
@@ -144,11 +152,11 @@ export function Sky() {
// differently on every mount, and a backdrop that is not the same twice is a
// backdrop somebody notices.
const SHAFTS = [
{ "--x": "8%", "--w": "8%", "--r": "7deg", "--dur": "19s", "--del": "0s" },
{ "--x": "22%", "--w": "5%", "--r": "5deg", "--dur": "23s", "--del": "-4s" },
{ "--x": "38%", "--w": "11%", "--r": "3deg", "--dur": "17s", "--del": "-9s" },
{ "--x": "54%", "--w": "4%", "--r": "-1deg", "--dur": "26s", "--del": "-2s" },
{ "--x": "64%", "--w": "14%", "--r": "-3deg", "--dur": "21s", "--del": "-13s" },
{ "--x": "81%", "--w": "6%", "--r": "-6deg", "--dur": "24s", "--del": "-6s" },
{ "--x": "91%", "--w": "9%", "--r": "-9deg", "--dur": "18s", "--del": "-11s" },
{ "--x": "8%", "--w": "8%", "--r": "7deg" },
{ "--x": "22%", "--w": "5%", "--r": "5deg" },
{ "--x": "38%", "--w": "11%", "--r": "3deg" },
{ "--x": "54%", "--w": "4%", "--r": "-1deg" },
{ "--x": "64%", "--w": "14%", "--r": "-3deg" },
{ "--x": "81%", "--w": "6%", "--r": "-6deg" },
{ "--x": "91%", "--w": "9%", "--r": "-9deg" },
];
+8 -4
View File
@@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useContext, useEffect, useId, useLayoutEff
import { t } from "../i18n";
import type { Item, Waiting } from "../state/session";
import type { ExtensionSurface } from "../port/wire";
import type { ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope } from "../port/port";
import type { AgentPort, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope } from "../port/port";
import { RMark } from "./RMark";
import { Await } from "./Await";
import { ToolCard } from "./cards/ToolCard";
@@ -31,6 +31,7 @@ import { landingBox, useFindLanding, useFindPaint } from "./findland";
interface Props {
items: Item[];
port?: Pick<AgentPort, "workspaceImageURL">;
// Changes only when the transcript's composition does — see state/session.
revision: number;
waiting: Waiting;
@@ -72,7 +73,7 @@ interface Props {
onPrepareRewind: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
onPrepareFileRevert: (path: string) => Promise<RewindPlan>;
onCommitFileRevert: (planId: string, resolution?: string) => Promise<RewindResult>;
onCommitRewind: (planId: string) => Promise<RewindResult>;
onCommitRewind: (planId: string, text?: string) => Promise<RewindResult>;
onUndoRewind: (transactionId: string) => Promise<void>;
/** Cards that have not had their one entrance yet. Owed by the projection,
* spent by the first render that draws them — never by the animation, which
@@ -81,7 +82,7 @@ interface Props {
onEntered: (ids: string[]) => void;
}
export function Transcript({ items, entering, onEntered, revision, waiting, scroll, hidden, onPinned, jump, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
export function Transcript({ items, port, entering, onEntered, revision, waiting, scroll, hidden, onPinned, jump, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
// A block the selection touches must not leave the DOM. Unmounting the node a
// selection is anchored to makes the browser remap that selection onto
// whatever is still mounted — which reads as "I selected up there and the
@@ -434,7 +435,7 @@ export function Transcript({ items, entering, onEntered, revision, waiting, scro
}, [entering, onEntered]);
const owed = useMemo(() => new Set(entering), [entering]);
const rowProps = { owed, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, reply, onResend };
const rowProps = { owed, port, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, reply, onResend };
// What you said, and where it sits. Derived from the same blocks the
// transcript renders, so a mark always knows which block holds it — that is
@@ -558,6 +559,7 @@ const Block = memo(function Block({
interface RowHandlers {
owed: Set<string>;
port: Props["port"];
onApprove: Props["onApprove"];
onFullAccess: Props["onFullAccess"];
onPlan: Props["onPlan"];
@@ -631,6 +633,7 @@ const ActivityGroup = memo(function ActivityGroup({
// chunk than parsing the message did.
const Row = memo(function Row({
it,
port,
owed,
onApprove,
onFullAccess,
@@ -666,6 +669,7 @@ const Row = memo(function Row({
return (
<UserCard
item={it}
port={port}
cp={cp}
onResend={onResend}
onPrepareRewind={onPrepareRewind}
@@ -15,8 +15,8 @@ const hub: VersionHub = {
latest: "2.21.0",
newer: true,
versions: [
{ version: "2.21.0", tag: "studio-v2.21.0", publishedAt: "", current: false, older: false },
{ version: "2.20.0", tag: "studio-v2.20.0", publishedAt: "", current: true, older: false },
{ version: "2.21.0", tag: "studio-v2.21.0", publishedAt: "", hasNotes: false, current: false, older: false },
{ version: "2.20.0", tag: "studio-v2.20.0", publishedAt: "", hasNotes: false, current: true, older: false },
],
};
@@ -0,0 +1,180 @@
// @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 { Versions } from "./Versions";
import { HttpError, type UpdateProgress, type VersionHub, type VersionNotes } from "../port/port";
afterEach(cleanup);
const row = (version: string, hasNotes: boolean, current = false, older = false) => ({
version,
tag: `studio-v${version}`,
publishedAt: "",
hasNotes,
current,
older,
});
const hub: VersionHub = {
current: "2.20.0",
pinned: "",
stalePin: false,
latest: "2.21.0",
newer: true,
versions: [row("2.21.0", true), row("2.20.0", true, true), row("2.19.0", false, false, true)],
};
type Notes = (v: string, retry?: boolean) => Promise<VersionNotes>;
function portWith(versionNotes: Notes, h: VersionHub = hub) {
return {
versions: () => Promise.resolve(h),
pinVersion: vi.fn(() => Promise.resolve()),
goToVersion: vi.fn(() => Promise.resolve()),
restartToVersion: vi.fn(() => Promise.resolve()),
onUpdateProgress: (_cb: (p: UpdateProgress) => void) => () => {},
versionNotes: vi.fn(versionNotes),
};
}
const doc = (version: string, markdown: string): Notes => () => Promise.resolve({ version, markdown, cached: false });
const refused = (code: string) => new HttpError(502, "english fallback for logs", { code }, true);
describe("version notes on the version panel", () => {
it("offers the view only on rows whose catalog entry has notes, and asks for nothing until it is used", async () => {
const port = portWith(doc("2.21.0", "# hi"));
render(<Versions port={port} />);
await screen.findByText("2.19.0");
expect(screen.getAllByRole("button", { name: "更新内容" })).toHaveLength(2);
expect(port.versionNotes).not.toHaveBeenCalled();
});
it("shows no control and makes no request when the catalog names no notes at all", async () => {
const bare: VersionHub = { ...hub, versions: hub.versions.map((v) => ({ ...v, hasNotes: false })) };
const port = portWith(doc("2.21.0", "x"), bare);
render(<Versions port={port} />);
await screen.findByText("2.19.0");
expect(screen.queryByRole("button", { name: "更新内容" })).toBeNull();
expect(port.versionNotes).not.toHaveBeenCalled();
});
it("tolerates a kernel that does not send hasNotes", async () => {
const old = { ...hub, versions: hub.versions.map(({ hasNotes: _h, ...rest }) => rest) } as unknown as VersionHub;
render(<Versions port={portWith(doc("2.21.0", "x"), old)} />);
await screen.findByText("2.19.0");
expect(screen.queryByRole("button", { name: "更新内容" })).toBeNull();
});
it("expands inline with the document, and names the state for assistive tech", async () => {
const port = portWith(doc("2.21.0", "# 本版新增\n\n- 修好了一件事"));
render(<Versions port={port} />);
const [button] = await screen.findAllByRole("button", { name: "更新内容" });
expect(button.getAttribute("aria-expanded")).toBe("false");
await userEvent.click(button);
expect(await screen.findByText("修好了一件事")).toBeTruthy();
const open = screen.getByRole("button", { name: "收起更新内容" });
expect(open.getAttribute("aria-expanded")).toBe("true");
expect(document.getElementById(open.getAttribute("aria-controls") ?? "")).toBeTruthy();
expect(screen.getByRole("region", { name: "2.21.0 的更新内容" })).toBeTruthy();
expect(port.versionNotes).toHaveBeenCalledWith("2.21.0", false);
});
it("collapses on the button and on Escape, which hands focus back and does not travel on", async () => {
const port = portWith(doc("2.21.0", "- 一条"));
const onWindowKey = vi.fn();
window.addEventListener("keydown", onWindowKey);
render(<Versions port={port} />);
const [button] = await screen.findAllByRole("button", { name: "更新内容" });
await userEvent.click(button);
await screen.findByText("一条");
await userEvent.click(screen.getByRole("button", { name: "收起更新内容" }));
expect(screen.queryByText("一条")).toBeNull();
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
await screen.findByText("一条");
await userEvent.keyboard("{Escape}");
expect(screen.queryByText("一条")).toBeNull();
expect(document.activeElement).toBe(screen.getAllByRole("button", { name: "更新内容" })[0]);
expect(onWindowKey).not.toHaveBeenCalled();
window.removeEventListener("keydown", onWindowKey);
});
it("closes on Escape pressed inside the document too, and returns focus to the control", async () => {
const port = portWith(() => Promise.reject(refused("studio.notes_unreachable")));
render(<Versions port={port} />);
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
const link = await screen.findByRole("link", { name: "在 GitHub 查看" });
link.focus();
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("region")).toBeNull();
expect(document.activeElement).toBe(screen.getAllByRole("button", { name: "更新内容" })[0]);
});
it("opens one at a time", async () => {
const port = portWith((v) => Promise.resolve({ version: v, markdown: `- 内容 ${v}`, cached: false }));
render(<Versions port={port} />);
const buttons = await screen.findAllByRole("button", { name: "更新内容" });
await userEvent.click(buttons[0]);
await screen.findByText("内容 2.21.0");
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
await screen.findByText("内容 2.20.0");
expect(screen.queryByText("内容 2.21.0")).toBeNull();
expect(screen.getAllByRole("region")).toHaveLength(1);
});
it("does not ask again for a document it already has", async () => {
const port = portWith(doc("2.21.0", "- 一条"));
render(<Versions port={port} />);
const [button] = await screen.findAllByRole("button", { name: "更新内容" });
await userEvent.click(button);
await screen.findByText("一条");
await userEvent.click(screen.getByRole("button", { name: "收起更新内容" }));
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
await screen.findByText("一条");
expect(port.versionNotes).toHaveBeenCalledTimes(1);
});
it("says why it failed, keeps the GitHub page one click away, and retries past the failure", async () => {
let fail = true;
const port = portWith(() => (fail ? Promise.reject(refused("studio.notes_unreachable")) : Promise.resolve({ version: "2.21.0", markdown: "- 好了", cached: false })));
render(<Versions port={port} />);
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
expect(await screen.findByText("暂时取不到更新内容,请检查网络后重试")).toBeTruthy();
const link = screen.getByRole("link", { name: "在 GitHub 查看" });
expect(link.getAttribute("href")).toBe("https://github.com/esengine/DeepSeek-Reasonix/releases/tag/studio-v2.21.0");
fail = false;
await userEvent.click(screen.getByRole("button", { name: "重试" }));
expect(await screen.findByText("好了")).toBeTruthy();
expect(port.versionNotes).toHaveBeenLastCalledWith("2.21.0", true);
});
it("offers no retry for a release the mirror has no notes for", async () => {
const port = portWith(() => Promise.reject(new HttpError(404, "x", { code: "studio.notes_absent" }, true)));
render(<Versions port={port} />);
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
expect(await screen.findByText("这个版本没有发布更新内容")).toBeTruthy();
expect(screen.queryByRole("button", { name: "重试" })).toBeNull();
expect(screen.getByRole("link", { name: "在 GitHub 查看" })).toBeTruthy();
});
it("draws no picture and runs no markup that the document carries", async () => {
const hostile = "![track](https://evil.test/p.png)\n\n<img src=\"https://evil.test/q.png\"><script>window.__ran = 1</script>\n\n[链接](https://example.com)";
const port = portWith(doc("2.21.0", hostile));
render(<Versions port={port} />);
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
await screen.findByText("链接");
await waitFor(() => expect(document.querySelector(".vnotes img")).toBeNull());
expect(document.querySelector(".vnotes script")).toBeNull();
expect((window as unknown as { __ran?: number }).__ran).toBeUndefined();
expect(screen.getByText("链接").closest("a")?.getAttribute("href")).toBe("https://example.com");
});
it("keeps the install and pin actions beside the notes control", async () => {
const port = portWith(doc("2.21.0", "- x"));
render(<Versions port={port} />);
await screen.findAllByRole("button", { name: "更新内容" });
expect(screen.getByRole("button", { name: "安装这个版本" })).toBeTruthy();
expect(screen.getByRole("button", { name: "固定在这里" })).toBeTruthy();
});
});
+88 -3
View File
@@ -1,15 +1,18 @@
import { useCallback, useEffect, useState } from "react";
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { bytes } from "../i18n/format";
import { t } from "../i18n";
import type { UpdateProgress, VersionHub } from "../port/port";
import type { UpdateProgress, VersionHub, VersionNotes } from "../port/port";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/http_error";
import { LazyMarkdown } from "./LazyMarkdown";
import { deltaSkippedCopy, failureCopy, releasePage } from "./versionFailure";
// A shell that declared no install: a build run from source, not a failure.
const NO_INSTALL = "studio.no_install";
// Work is running and would end with the restart; the person decides.
const RESTART_BUSY = "update.restart_busy";
// The release has no notes published: nothing to retry, only the page to read.
const NOTES_ABSENT = "studio.notes_absent";
// The panel answers three questions in the order a user asks them: what am I
// running, is something wrong with it, and how do I get off it. Every action
@@ -22,8 +25,13 @@ type Port = {
goToVersion(v: string): Promise<void>;
restartToVersion(v: string, force: boolean): Promise<void>;
onUpdateProgress(cb: (p: UpdateProgress) => void): () => void;
versionNotes?(version: string, retry?: boolean): Promise<VersionNotes>;
};
// A document read stays read: it is kept for as long as the panel is mounted,
// and the kernel keeps it on disk past that.
type NotesState = { phase: "loading" } | { phase: "ok"; markdown: string } | { phase: "err"; why: string; absent: boolean };
// Phases during which a move owns the install and nothing else may start.
const MOVING = new Set<UpdateProgress["phase"]>(["downloading", "verifying", "downloaded", "applying", "authorizing", "relaunching"]);
@@ -77,6 +85,8 @@ export function Versions({ port }: { port: Port }) {
const [progress, setProgress] = useState<UpdateProgress | null>(null);
const [later, setLater] = useState("");
const [running, setRunning] = useState(0);
const [openNotes, setOpenNotes] = useState("");
const [notes, setNotes] = useState<Record<string, NotesState>>({});
// The kernel says why it cannot answer — a shell that never declared an
// install, a server that does not carry this at all. Folding that back into
@@ -149,6 +159,37 @@ export function Versions({ port }: { port: Port }) {
}
};
const loadNotes = (v: string, retry: boolean) => {
if (!port.versionNotes) return;
setNotes((n) => ({ ...n, [v]: { phase: "loading" } }));
port
.versionNotes(v, retry)
.then((r) => setNotes((n) => ({ ...n, [v]: { phase: "ok", markdown: r.markdown } })))
.catch((e) =>
setNotes((n) => ({
...n,
[v]: { phase: "err", why: reason(e), absent: e instanceof HttpError && e.reason?.code === NOTES_ABSENT },
})),
);
};
const toggleNotes = (v: string) => {
if (openNotes === v) {
setOpenNotes("");
return;
}
setOpenNotes(v);
const have = notes[v];
if (!have || have.phase === "err") loadNotes(v, false);
};
const closeNotes = (e: KeyboardEvent<HTMLElement>, v: string) => {
if (e.key !== "Escape" || openNotes !== v) return;
e.stopPropagation();
setOpenNotes("");
document.querySelector<HTMLElement>(`[aria-controls="vnotes-${v}"]`)?.focus();
};
if (uninstalled) {
return <p className="acct-note">{t("当前是从源码启动的开发版,没有可以查看或切换的版本。安装版 Studio 会在这里列出可用的更新。")}</p>;
}
@@ -270,12 +311,15 @@ export function Versions({ port }: { port: Port }) {
marked the way every other "this one" in the app is. */}
<div className="vlist">
{list.map((v, i) => (
<Fragment key={v.version}>
<div
key={v.version}
className="vrow"
data-open={openNotes === v.version ? "" : undefined}
data-on={v.current ? "" : undefined}
data-side={v.current ? "now" : v.older ? "past" : "ahead"}
style={{ animationDelay: `${Math.min(i, 8) * 34}ms` }}
data-action={v.hasNotes ? "versions.notes-close" : undefined}
onKeyDown={v.hasNotes ? (e) => closeNotes(e, v.version) : undefined}
>
<span className="nm">{v.version}</span>
<span className="ds">{t(v.current ? "正在运行" : v.older ? "更早的版本" : "更新的版本")}</span>
@@ -316,9 +360,50 @@ export function Versions({ port }: { port: Port }) {
{t("固定在这里")}
</button>
)}
{v.hasNotes && port.versionNotes && (
<button
className="vn lnk"
data-action="versions.notes"
aria-expanded={openNotes === v.version}
aria-controls={`vnotes-${v.version}`}
onClick={() => toggleNotes(v.version)}
>
{t(openNotes === v.version ? "收起更新内容" : "更新内容")}
</button>
)}
</div>
{v.hasNotes && port.versionNotes && openNotes === v.version && (
<NotesPanel id={`vnotes-${v.version}`} version={v.version} state={notes[v.version]} retry={() => loadNotes(v.version, true)} onKeyDown={(e) => closeNotes(e, v.version)} />
)}
</Fragment>
))}
</div>
</div>
);
}
function NotesPanel({ id, version, state, retry, onKeyDown }: { id: string; version: string; state: NotesState | undefined; retry: () => void; onKeyDown: (e: KeyboardEvent<HTMLElement>) => void }) {
const loading = !state || state.phase === "loading";
return (
<section id={id} className="vnotes" data-action="versions.notes-close" onKeyDown={onKeyDown} aria-label={t("{v} 的更新内容", { v: version })} aria-busy={loading}>
{loading && <p className="acct-note">{t("正在读取更新内容…")}</p>}
{state?.phase === "ok" && <LazyMarkdown text={state.markdown} images={false} />}
{state?.phase === "err" && (
<div className="find" data-lvl="warn" role="alert">
<span className="t">{t("更新内容读取失败")}</span>
<span className="why">
{state.why}
{!state.absent && (
<button className="lnk" data-action="versions.notes-retry" onClick={retry}>
{t("重试")}
</button>
)}
<a className="lnk" href={releasePage(version)} target="_blank" rel="noreferrer noopener">
{t("在 GitHub 查看")}
</a>
</span>
</div>
)}
</section>
);
}
@@ -0,0 +1,46 @@
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
export type FileMode = "file" | "diff" | "read" | "preview";
/** The open file's path, the views it can be seen in, and save. A document that
* renders (Markdown, a page) leads with that view and calls the editor
* "编辑"; any other file has only its text. */
export function WorkbenchFileBar({
path,
mode,
onMode,
readable,
previewable,
canSave,
onSave,
}: {
path: string;
mode: FileMode;
onMode: (mode: FileMode) => void;
readable: boolean;
previewable: boolean;
canSave: boolean;
onSave: () => void;
}) {
const view = (value: FileMode, label: string) => (
<button data-action="workbench.mode" data-value={value} aria-pressed={mode === value} onClick={() => onMode(value)}>
{label}
</button>
);
return (
<div className="workbench-filebar">
<strong>{path}</strong>
<div role="group">
{readable && view("read", t("阅读"))}
{previewable && view("preview", t("预览"))}
{view("file", readable || previewable ? t("编辑") : t("文件"))}
{view("diff", "Diff")}
</div>
<button className="workbench-save" data-action="workbench.save" data-target={path} disabled={!canSave} onClick={onSave}>
<StudioIcon name="check" />
{t("保存")}
</button>
</div>
);
}
+44 -45
View File
@@ -20,6 +20,8 @@ import { useGlance } from "./glance";
import { useMarkdownPoll } from "./useMarkdownPoll";
import { pinToViewport } from "./place";
import { WorkbenchExplorerHead } from "./WorkbenchExplorerHead";
import { WorkbenchFileBar, type FileMode } from "./WorkbenchFileBar";
import { useHtmlPreview } from "./useHtmlPreview";
import { setShowsHiddenFiles, showsHiddenFiles } from "../state/prefs";
import { useCommitCard } from "./CommitCard";
@@ -106,7 +108,7 @@ function treeRows(
export function WorkbenchPanel({
port,
tabs,
tabs: allTabs,
manual,
shown,
scheme,
@@ -139,6 +141,8 @@ export function WorkbenchPanel({
onSurfaces: (n: number) => void;
onExternal: (url: string) => void;
}) {
const preview = useHtmlPreview(port, allTabs, remote);
const tabs = preview.tabs;
const [files, setFiles] = useState<string[]>([]),
[directories, setDirectories] = useState<string[]>([]),
[openFiles, setOpenFiles] = useState<string[]>([]);
@@ -167,7 +171,7 @@ export function WorkbenchPanel({
const [browsers, setBrowsers] = useState<string[]>([]),
[hosts, setHosts] = useState<Record<string, string>>({});
const minted = useRef(0);
const [mode, setMode] = useState<"file" | "diff" | "read">("file");
const [mode, setMode] = useState<FileMode>("file");
const [file, setFile] = useState<WorkspaceFile | null>(null),
[draft, setDraft] = useState(""),
[diff, setDiff] = useState("");
@@ -308,7 +312,7 @@ export function WorkbenchPanel({
useEffect(() => {
const before = lastAgent.current;
lastAgent.current = agentAt;
if (before === null || !agentAt || before === agentAt) return;
if (!before || !agentAt || before === agentAt) return;
dropBlankStart();
pick(`browser:${agentTarget}`);
}, [agentAt, agentTarget, dropBlankStart, pick]);
@@ -334,10 +338,26 @@ export function WorkbenchPanel({
// source view is one click away.
const filePath = active?.kind === "file" ? active.path : "";
const readable = /\.(md|markdown|mdx)$/i.test(filePath);
const previewable = !!filePath && preview.can(filePath);
useEffect(() => {
if (readable) setMode((m) => (m === "file" ? "read" : m));
else setMode((m) => (m === "read" ? "file" : m));
}, [filePath, readable]);
setMode((m) => {
const kept = (m === "read" && !readable) || (m === "preview" && !previewable) ? "file" : m;
return kept === "file" ? (readable ? "read" : previewable ? "preview" : "file") : kept;
});
}, [filePath, readable, previewable]);
const previewTab = previewable ? preview.tabFor(filePath) : undefined;
const { open: openPreview, sync: syncPreview } = preview;
const revision = file?.path === filePath ? file.revision : undefined;
useEffect(() => {
if (mode !== "preview" || !previewable || !shown || previewTab || (!revision && !failed)) return;
openPreview(filePath, revision).catch((e) => {
setFailed(reason(e));
setMode("file");
});
}, [mode, previewable, shown, previewTab, revision, failed, filePath, openPreview, tabs]);
useEffect(() => {
if (mode === "preview" && revision) syncPreview(filePath, revision);
}, [mode, filePath, revision, syncPreview]);
const rows = useMemo(
() => treeRows(files, directories, query, collapsed),
[files, directories, query, collapsed],
@@ -394,7 +414,7 @@ export function WorkbenchPanel({
live = false;
};
}, [port, openPath, shown, changeKey, glance, running, wrote]);
useMarkdownPoll({ port, filePath: shown && readable && mode === "read" ? filePath : "", held, issued, setFailed, setFile, setDraft });
useMarkdownPoll({ port, filePath: shown && ((readable && mode === "read") || (previewable && mode === "preview")) ? filePath : "", held, issued, setFailed, setFile, setDraft });
// Picking a file is asking to read it. Docked, the list and the file share one
// column, so the list steps aside; side by side it stays where it is.
const openFile = (path: string) => {
@@ -590,48 +610,27 @@ export function WorkbenchPanel({
)}
{active?.kind === "file" && (
<>
<div className="workbench-filebar">
<strong>{active.path}</strong>
<div role="group">
{readable && (
<button data-action="workbench.mode" data-value="read" aria-pressed={mode === "read"} onClick={() => setMode("read")}>
{t("阅读")}
</button>
)}
<button
data-action="workbench.mode"
data-value="file"
aria-pressed={mode === "file"}
onClick={() => setMode("file")}
>
{readable ? t("编辑") : t("文件")}
</button>
<button
data-action="workbench.mode"
data-value="diff"
aria-pressed={mode === "diff"}
onClick={() => setMode("diff")}
>
Diff
</button>
</div>
<button
className="workbench-save"
data-action="workbench.save"
data-target={active.path}
disabled={busy || !file || draft === file.content}
onClick={() => void save()}
>
<StudioIcon name="check" />
{t("保存")}
</button>
</div>
{failed && (
<WorkbenchFileBar
path={active.path}
mode={mode}
onMode={setMode}
readable={readable}
previewable={previewable}
canSave={!busy && !!file && draft !== file.content}
onSave={() => void save()}
/>
{failed && !(mode === "preview" && previewTab) && (
<div className="workbench-error" role="alert">
{failed}
</div>
)}
{busy && !file ? (
{mode === "preview" ? (
previewTab ? (
<AgentBrowserPanel tabs={[previewTab]} shown={shown} showTabs={false} />
) : (
<div className="workbench-empty">{t("正在读取…")}</div>
)
) : busy && !file ? (
<div className="workbench-empty">{t("正在读取…")}</div>
) : mode === "diff" ? (
<div className="workbench-diff">
@@ -95,3 +95,14 @@ it("counts only running delegates, reaching 0 when the last one finishes", () =>
cleanup();
expect(n([task("a", false, "A"), task("b", false, "B")])).toBe("0");
});
it("heads a refused delegation as not run and a cut-short one as interrupted", () => {
const refused = { ...task("r", false, "explore"), tool: { id: "r", name: "task", profile: { name: "explore" }, err: "invalid arguments for explore", refusalCode: "tool.arguments_invalid" } } as unknown as Task;
const { rerender } = render(<AgentTranscript task={refused} onClose={() => {}} />);
const head = () => screen.getByRole("dialog").querySelector(".agent-tx-hd .rt")!.textContent;
expect(head()).toContain("未执行");
expect(head()).not.toContain("已中断");
const cut = { ...refused, tool: { ...refused.tool, refusalCode: undefined, err: "sub-agent: context canceled" } } as unknown as Task;
rerender(<AgentTranscript task={cut} onClose={() => {}} />);
expect(head()).toContain("已中断");
});
@@ -78,7 +78,7 @@ describe("what the appearance page says a person is doing", () => {
it("keeps neighbouring intents apart", () => {
const ids = new Set(ACTIONS.map((a) => a.id));
for (const id of ["theme.activate", "wallpaper.change", "wallpaper.remove", "appearance.scheme", "appearance.background",
"appearance.weight", "appearance.contrast"]) {
"appearance.weight", "appearance.contrast", "appearance.effects"]) {
expect(ids.has(id), `${id} is missing`).toBe(true);
}
});
+18 -6
View File
@@ -1,21 +1,33 @@
import type { AgentPort, BrowserTab } from "../port/port";
const opening = new WeakMap<AgentPort, Promise<BrowserTab>>();
const opening = new WeakMap<AgentPort, { page: Promise<BrowserTab>; pending: boolean }>();
/** The one way this window asks the kernel for a tab. What is being opened is
* remembered until the kernel answers, so a column that needs a page to show
* takes that one rather than asking for another. */
export function openPage(port: AgentPort, url: string): Promise<BrowserTab> {
const page = port.browserOpen(url, true);
opening.set(port, page);
const settled = () => {
if (opening.get(port) === page) opening.delete(port);
};
const state = { page, pending: true };
opening.set(port, state);
const settled = () => { state.pending = false; };
page.then(settled, settled);
return page;
}
/** A page for a column that has none: the one already on its way, else a blank. */
export function pageForEmptyColumn(port: AgentPort): Promise<BrowserTab> {
return opening.get(port) ?? openPage(port, "about:blank");
const current = opening.get(port);
return newestPage(port, current?.pending ? current.page : openPage(port, "about:blank"));
}
async function newestPage(port: AgentPort, page: Promise<BrowserTab>): Promise<BrowserTab> {
try {
const tab = await page;
const next = opening.get(port)?.page;
return next && next !== page ? newestPage(port, next) : tab;
} catch (error) {
const next = opening.get(port)?.page;
if (next && next !== page) return newestPage(port, next);
throw error;
}
}
@@ -112,6 +112,18 @@ describe("a coded notice with a payload", () => {
expect(auto.querySelector(".find .t")?.textContent).toBe(t("费用显示币种已设为 {mode}", { mode: "auto" }));
});
it("words the recovered inbox from its count and draws no raw payload line", () => {
const box = draw({ level: "warn", code: "inbox_recovered", text: "Recovered 1 pending instruction(s). Inbox is paused — review with /queue before resuming.", detail: '{"count":1}' });
expect(box.querySelector(".find .t")?.textContent).toBe(t("已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”", { n: 1 }));
expect(box.querySelector(".find .t")?.textContent).not.toContain("Recovered");
expect(box.querySelector(".find .why")).toBeNull();
});
it("keeps the kernel's English when the recovered-inbox payload is not readable", () => {
const box = draw({ level: "warn", code: "inbox_recovered", text: "Recovered 2 pending instruction(s).", detail: "garbled" });
expect(box.querySelector(".find .t")?.textContent).toContain("Recovered 2 pending");
});
it("wraps the user's unapplied guidance in this build's sentence and keeps their words verbatim", () => {
const box = draw({
code: "unapplied_steer",
@@ -2,6 +2,7 @@ import type { Item } from "../../state/session";
import { t } from "../../i18n";
import { NOTICE_TEXT } from "../../i18n/notices";
import { EXTENSION_SKIPPED, extensionSkippedVars } from "../../i18n/extension_skipped";
import { INBOX_RECOVERED, inboxRecoveredVars } from "../../i18n/inbox_recovered";
import { FOLD_WHY, NO_CODE_WHY } from "../../i18n/compaction_why";
import { workspaceLeaseDetail } from "../../i18n/workspace_lease";
import { Sym } from "../Sym";
@@ -33,8 +34,9 @@ export function NoticeCard({ item }: { item: Extract<Item, { t: "notice" }> }) {
const reasoned = item.code !== undefined && REASONED.has(item.code);
const reason = reasoned ? (item.detail ? FOLD_WHY[item.detail] : item.code === "compact_declined" ? NO_CODE_WHY : undefined) : undefined;
const skipped = item.code === EXTENSION_SKIPPED ? extensionSkippedVars(item.detail) : undefined;
const vars: Record<string, string | number> | undefined = stored ? { mode: item.detail || "auto" } : reason ? { why: t(reason) } : skipped;
const wording = item.code && (!reasoned || vars) && (item.code !== EXTENSION_SKIPPED || skipped) ? NOTICE_TEXT[item.code] : undefined;
const recovered = item.code === INBOX_RECOVERED ? inboxRecoveredVars(item.detail) : undefined;
const vars: Record<string, string | number> | undefined = stored ? { mode: item.detail || "auto" } : reason ? { why: t(reason) } : skipped ?? recovered;
const wording = item.code && (!reasoned || vars) && (item.code !== EXTENSION_SKIPPED || skipped) && (item.code !== INBOX_RECOVERED || recovered) ? NOTICE_TEXT[item.code] : undefined;
const claim = item.workspaceLease;
const detail = claim
? workspaceLeaseDetail(claim, item.code !== "workspace_lease_resumed") || item.detail
@@ -0,0 +1,163 @@
// @vitest-environment jsdom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../testkit";
import { FOLD_DEFAULTS, setFoldModes } from "../../state/prefs";
import { appendText, foldMessage } from "../../state/say";
import type { Item } from "../../state/session";
import { PaneShown } from "../shown";
import { SayCard } from "./SayCard";
const frames = new Map<number, FrameRequestCallback>();
let requested = 0;
let canceled = 0;
beforeEach(() => {
setFoldModes(FOLD_DEFAULTS);
frames.clear();
requested = 0;
canceled = 0;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.set(++requested, cb);
return requested;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
if (frames.delete(id)) canceled++;
});
});
afterEach(() => {
cleanup();
setFoldModes(FOLD_DEFAULTS);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function drain() {
act(() => {
let delivered = 0;
while (frames.size) {
if (++delivered > 1000) throw new Error("reveal did not settle");
const [id, cb] = frames.entries().next().value as [number, FrameRequestCallback];
frames.delete(id);
cb(performance.now());
}
});
}
function draw(shown = true) {
let items = appendText([], "考虑中", "reasoning");
const card = () => <PaneShown.Provider value={shown}><SayCard item={items[0] as Extract<Item, { t: "say" }>} /></PaneShown.Provider>;
const view = render(card());
return {
details: view.container.querySelector("details") as HTMLDetailsElement,
text: () => view.container.querySelector(".tk")?.textContent,
reasoning: () => (items[0] as Extract<Item, { t: "say" }>).reasoning,
append: (text: string, field: "text" | "reasoning" = "reasoning") => {
items = appendText(items, text, field);
view.rerender(card());
},
show: (next: boolean) => { shown = next; view.rerender(card()); },
finish: () => { items = foldMessage(items, { kind: "message" }); view.rerender(card()); },
};
}
describe("thinking text reveal", () => {
it("requests no frames for bursts arriving in a folded thought", () => {
const tick = vi.spyOn(window, "setInterval");
const card = draw();
expect(card.details.open).toBe(false);
for (const chunk of ["分析".repeat(60), "继续".repeat(60), "结论".repeat(60)]) {
card.append(chunk);
drain();
}
expect(card.text()).toBe(card.reasoning());
expect(requested).toBe(0);
expect(tick).toHaveBeenCalledOnce();
});
it.each(["live", "open"] as const)("stops on a user fold under %s and resumes from the latest text", async (mode) => {
setFoldModes({ thinking: mode });
const card = draw();
const summary = card.details.querySelector("summary") as HTMLElement;
expect(card.details.open).toBe(true);
card.append("展开时到达的内容".repeat(20));
expect(frames.size).toBe(1);
expect(card.text()).not.toBe(card.reasoning());
await userEvent.click(summary);
await waitFor(() => expect(frames.size).toBe(0));
expect(card.details.open).toBe(false);
expect(canceled).toBe(1);
const before = requested;
card.append("折叠时到达的内容".repeat(20));
expect(requested).toBe(before);
expect(card.text()).toBe(card.reasoning());
await userEvent.click(summary);
expect(card.details.open).toBe(true);
expect(card.text()).toBe(card.reasoning());
expect(frames.size).toBe(0);
card.append("重新展开后的新内容".repeat(20));
expect(frames.size).toBe(1);
expect(card.text()).not.toBe(card.reasoning());
drain();
expect(card.text()).toBe(card.reasoning());
expect(frames.size).toBe(0);
});
it("cancels a live thought's backlog when the first answer folds it", () => {
setFoldModes({ thinking: "live" });
const card = draw();
card.append("分析".repeat(60));
expect(frames.size).toBe(1);
card.append("答", "text");
expect(card.details.open).toBe(false);
expect(frames.size).toBe(0);
expect(canceled).toBe(1);
expect(card.text()).toBe(card.reasoning());
});
it("keeps expanded thoughts current while their pane is hidden", () => {
setFoldModes({ thinking: "open" });
const card = draw();
card.append("分析".repeat(60));
expect(frames.size).toBe(1);
card.show(false);
expect(frames.size).toBe(0);
const before = requested;
card.append("后台".repeat(60));
expect(requested).toBe(before);
expect(card.text()).toBe(card.reasoning());
card.show(true);
expect(frames.size).toBe(0);
card.append("前台".repeat(60));
expect(frames.size).toBe(1);
drain();
expect(card.text()).toBe(card.reasoning());
});
it("settles an expanded thought immediately on completion", () => {
setFoldModes({ thinking: "open" });
const card = draw();
card.append("分析".repeat(60));
expect(frames.size).toBe(1);
card.finish();
expect(card.details.open).toBe(true);
expect(frames.size).toBe(0);
expect(card.text()).toBe(card.reasoning());
expect(card.details.querySelector(".caret")).toBeNull();
});
it("shows expanded bursts immediately with reduced motion", () => {
setFoldModes({ thinking: "open" });
const media = matchMedia;
vi.stubGlobal("matchMedia", (query: string) => ({ ...media(query), matches: true }));
const card = draw();
card.append("分析".repeat(60));
expect(card.details.open).toBe(true);
expect(card.text()).toBe(card.reasoning());
expect(requested).toBe(0);
});
});
@@ -99,7 +99,7 @@ export function SayCard({ item, afterAnswer, reply }: { item: Extract<Item, { t:
// Thinking is the longest-running stream of the turn — 10s of it before the
// first answer token, measured — so it gets the same paced reveal the answer
// does rather than tracking the wire's bursts.
const thought = useRevealed(item.reasoning ?? "", !item.done);
const thought = useRevealed(item.reasoning ?? "", !item.done && open);
return (
<div className="call" data-k="say">
<div className="g">
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { Checkpoint, RewindPlan, RewindResult, RewindScope } from "../../port/port";
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentPort, Checkpoint, RewindPlan, RewindResult, RewindScope } from "../../port/port";
import type { Item } from "../../state/session";
import { RewindControl } from "./RewindControl";
import { CopyButton } from "../CopyButton";
@@ -10,8 +10,18 @@ import { messageSource } from "../source";
import { touchKeyboard } from "../touchKeyboard";
import { useViewer } from "../../state/viewer";
const SAVED_IMAGE = /(?:^|\s)@(\.reasonix\/attachments\/clipboard-[\d.-]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=\s|$)/gi;
function SavedImage({ path, src }: { path: string; src: string }) {
const [failed, setFailed] = useState(false);
return <div className="user-image">
{failed ? <span>{t("图片不可用")}</span> : <img src={src} alt={path.split("/").at(-1)} loading="lazy" width={240} height={160} onError={() => setFailed(true)} />}
</div>;
}
export function UserCard({
item,
port,
cp,
onResend,
onPrepareRewind,
@@ -19,10 +29,11 @@ export function UserCard({
onUndoRewind,
}: {
item: Extract<Item, { t: "user" }>;
port?: Pick<AgentPort, "workspaceImageURL">;
cp?: Checkpoint;
onResend?: (turn: number, text: string) => Promise<void>;
onPrepareRewind?: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
onCommitRewind?: (planId: string) => Promise<RewindResult>;
onCommitRewind?: (planId: string, text?: string) => Promise<RewindResult>;
onUndoRewind?: (transactionId: string) => Promise<void>;
}) {
// A rewind needs a turn the kernel claimed, and a queued line has not
@@ -35,6 +46,7 @@ export function UserCard({
const source = messageSource(item.via, useViewer());
const reopen = editable && draft === null;
const rewind = !!(cp && onPrepareRewind && onCommitRewind && onUndoRewind);
const images = useMemo(() => [...new Set(Array.from(item.text.matchAll(SAVED_IMAGE), (match) => match[1]))], [item.text]);
useEffect(() => {
const el = box.current;
@@ -69,7 +81,13 @@ export function UserCard({
</div>}
<div className="out">
{draft === null ? (
<div className="txt">{item.text}</div>
<>
{port && images.length > 0 && <div className="user-images">{images.map((path) => {
const src = port.workspaceImageURL(path);
return <SavedImage key={src} path={path} src={src} />;
})}</div>}
<div className="txt">{item.text}</div>
</>
) : (
<div className="reask">
<textarea
@@ -122,7 +140,7 @@ export function UserCard({
</button>
)}
{rewind && (
<RewindControl cp={cp!} compact onPrepare={onPrepareRewind!} onCommit={onCommitRewind!} onUndo={onUndoRewind!} />
<RewindControl cp={cp!} compact onPrepare={onPrepareRewind!} onCommit={(planId) => onCommitRewind!(planId, item.text)} onUndo={onUndoRewind!} />
)}
</div>
</div>
@@ -1,5 +1,6 @@
import type { Tool } from "../port/wire";
import { t } from "../i18n";
import { toolFailed } from "./cards/outcome";
// One reading of "what left this context". The kernel marks a dispatch with a
// profile; the tool name cannot say it, because every dispatcher is reached
@@ -18,3 +19,13 @@ export function nestLabel(who: string | undefined, count: number | undefined, st
if (who) return t("{name} 做了 {n} 步", { name: who, n: steps });
return t("子代理做了 {n} 步", { n: steps });
}
export type DelegateStatus = "running" | "refused" | "failed" | "done";
// A host refusal names a call that never started; "interrupted" is only true of
// one that began and was cut short.
export const delegateStatus = (running: boolean, tool: Tool): DelegateStatus =>
running ? "running" : tool.refusalCode ? "refused" : toolFailed(tool) ? "failed" : "done";
export const delegateStatusLabel = (status: DelegateStatus): string =>
status === "running" ? t("运行中") : status === "refused" ? t("未执行") : status === "failed" ? t("已中断") : t("已交付");
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more