Compare commits

...
Author SHA1 Message Date
esengine 573d660e1e fix(tui): read the model catalogue from the kernel's object envelope 2026-10-02 20:31:21 -07:00
esengine 183264fa10 feat(tui): second batch of 1.x command parity (work in progress) 2026-10-02 08:59:31 -07:00
YHH 7bdaae2025 Merge pull request #11731 from esengine/fix/11723-narrow-sidebar-overlap
fix(studio): keep the narrow workspace column legible
2026-10-02 06:36:17 -07:00
YHH db267ac05c Merge pull request #11732 from esengine/fix/11720-run-analysis-inline-detail
fix(studio): open a run-analysis record under the row that was clicked
2026-10-02 06:35:19 -07:00
YHH 9ef965a9d6 Merge pull request #11729 from esengine/fix/11727-menu-position-at-scale
fix(studio): place rail context menus correctly at a zoomed interface size
2026-10-02 06:34:22 -07:00
esengine 620aede3d8 fix(studio): open a run-analysis record under the row that was clicked
The record panel was rendered after the whole active-turns list, so with a few
hundred rows it sat thousands of pixels below the click. It now renders inside
the clicked row's list item (or under the signal track for a signal), the same
item stays mounted, and clicking again closes it.
2026-10-02 05:40:13 -07:00
esengine d3f2a601c3 fix(studio): keep the narrow workspace column legible
The session filter row gave four equal cells to labels with counts, so at the
old 176px floor the labels collided; the new-session and search rows wrapped
around their shortcut hints. Raise the column floor to 232px, let the filter
cells size to their labels, ellipsize labels, and drop counts and shortcut
hints below a container width.
2026-10-02 05:38:52 -07:00
YHH 91c13b306c Merge pull request #11726 from esengine/docs/release-2.26.0-notes
docs(release): prepare Studio 2.26.0 notes
2026-10-02 05:28:58 -07:00
esengine 001473b7a8 docs(release): keep 2.26.0 note bullets within the prose limit 2026-10-02 05:20:20 -07:00
esengine 1f11055c1f fix(studio): place rail context menus correctly at a zoomed interface size
The session and project menus wrote clientX/clientY into left/top, but the
interface scale is CSS zoom, so a fixed element's left/top is multiplied by the
zoom on its way in. Both now go through pinToViewport, which divides by the
zoom and clamps the measured box inside the viewport. The session menu's
max-height is divided by the zoom as well.
2026-10-02 05:17:40 -07:00
esengine 415445ffbc docs(release): mention the author line and Douyin in the 2.26.0 notes 2026-10-02 05:11:42 -07:00
esengine 0e0b79b8c5 docs(release): prepare Studio 2.26.0 notes 2026-10-02 05:11:42 -07:00
YHH 0d9792e8b0 Merge pull request #11718 from esengine/fix/studio-stale-default-model
fix(studio): start with a fallback model when the saved default model is not configured
2026-10-02 05:10:47 -07:00
YHH b33fe75d7e Merge pull request #11712 from esengine/fix/11706-subagent-stuck-running
fix(studio): keep sub-agent progress previews out of the dispatching call's card
2026-10-02 05:01:00 -07:00
esengine d1dddb53f3 fix(studio): start with a fallback model when the saved default model is not configured
The official DeepSeek entry's model list migrates the retired
deepseek-v4-flash name to deepseek-flash, and qualified refs move with it,
but a bare default_model = "deepseek-v4-flash" (the shape a shared 1.x/Studio
config carries) was left behind. It then resolved to nothing, and because an
unnamed boot preserves an unknown default for its error, the Studio host's
only pane failed to build and the host exited 1 before its handshake.

- config: migrate a bare retired flash ref (default_model and every other
  model-ref field) to the official entry's successor, unless another
  provider still serves that name.
- boot: a new OpenOnFallbackModel option, set only by the Studio host and
  hub panes, lets an unnamed build pass over an unknown non-plugin default
  like a keyless one, open on the first configured chat model, and report
  default_model_unavailable. The config file is not written. run, the TUI
  and every other build keep the actionable unknown-model error and never
  move to another provider on their own.
- serve: removing the provider in use moves the conversation to the startup
  model when the saved default names nothing, instead of answering 500
  after the removal; session titles follow the same startup model.
2026-10-02 04:54:09 -07:00
esengine d2a8d0b190 fix(studio): keep sub-agent progress out of the dispatching call's card
A background sub-agent keeps emitting reasonix.subagent.* progress after the
dispatching call has returned its job id. Folding those frames into the call's
card re-opened it, renamed it and its execution, and nothing closed it again.
Progress now lands in per-call phase state beside the cards; the Subagents
panel reads a child as running while its call is open or its last phase is not
terminal.
2026-10-02 04:51:56 -07:00
YHH efd0a4430a Merge pull request #11704 from esengine/docs/readme-alipay
docs(readme): add Alipay to the Support section
2026-10-02 04:46:07 -07:00
YHH b6f7e2e8e8 Merge pull request #11714 from esengine/fix/11708-guidance-pending-chip
fix(studio): count in-flight guidance from the queue the panel draws
2026-10-02 04:37:24 -07:00
YHH 81d1c2e709 Merge pull request #11713 from esengine/fix/11707-queue-takeback-composer
fix(studio): put a taken-back queued line back in the composer
2026-10-02 04:36:23 -07:00
esengine 6271a7e264 docs(readme): put the Douyin line in its own quote so no paragraph passes the prose width 2026-10-02 04:15:43 -07:00
esengine afb73be003 docs(readme): add Alipay next to WeChat Pay in the Support section 2026-10-02 04:12:28 -07:00
YHH 694d4d79c8 Merge pull request #11701 from esengine/docs/readme-douyin
docs(readme): add the Douyin account to the community links
2026-10-02 04:02:45 -07:00
YHH 77990ed9eb Merge pull request #11660 from esengine/feat/studio-community
feat(studio): add the community group, Discord and contributors to the app
2026-10-02 04:00:17 -07:00
esengine f4766d1369 fix(studio): put a taken-back queued line back in the composer
Take back cancelled the entry and dropped its text, so the label promised a
retrieval nothing performed. The body is now read before the entry is given
up and appended to the composer, under whatever is already typed. A line whose
body cannot be read stays queued rather than being lost.
2026-10-02 03:59:02 -07:00
esengine 7358ceb085 fix(studio): count in-flight guidance from the queue the panel draws
The header chip counted client-side pending rows, which a delivery reported
before its HTTP receipt left pending forever, so it named a line the queue
panel (the only place to act on one) no longer showed. It now counts the
kernel's waiting guidance through the same predicate the panel filters by,
and a receipt that arrives after its delivery no longer re-opens the line.
2026-10-02 03:56:57 -07:00
YHH d65ae94a50 Merge pull request #11682 from esengine/feat/cli-setup
feat(tui): first-run connection panel and /setup (/auth)
2026-10-02 03:42:17 -07:00
esengine 54ef19b12a feat(studio): add a Douyin entry and an author line to About 2026-10-02 03:42:00 -07:00
esengine ca96e644b4 docs(readme): add the Douyin account to the community links 2026-10-02 03:25:53 -07:00
esengine 5d7a599ffd feat(studio): add the community group, Discord and contributors to the app 2026-10-02 03:23:14 -07:00
YHH c0d4b5a582 Merge pull request #11688 from KHG420/codex/studio-plugin-prompt-invocations
fix(studio): show qualified plugin prompt invocations
2026-10-02 03:04:41 -07:00
YHH a2deb8cf51 Merge pull request #11684 from KHG420/codex/studio-theme-contribution-names
fix(studio): identify directory theme contributions by pack name
2026-10-02 02:54:44 -07:00
esengine 41f3c1e6dd feat(tui): add first-run connection panel and /setup (/auth)
The terminal UI opens a Configure connection panel when the session owes a
key, and /setup or /auth opens it at any time. The key is tested without
being stored, saved to the credential store only, and applied to the running
session when it belongs to the active connection; config.toml is untouched.
2026-10-02 02:54:20 -07:00
YHH ead1e05b4e Merge pull request #11683 from esengine/feat/cli-queue
feat(tui): /queue, /steer, /takeover, /status, /export and /copy
2026-10-02 02:52:49 -07:00
YHH c2b55b9824 Merge pull request #11694 from KHG420/codex/studio-native-prompt-author-example
docs(plugins): add native prompt author example
2026-10-02 02:46:36 -07:00
YHH faf26a3c81 Merge pull request #11695 from KHG420/codex/studio-plugin-hook-diagnostics
fix(studio): report plugin hook declaration warnings
2026-10-02 02:45:38 -07:00
YHH 6b9c38c2a0 Merge pull request #11686 from xxoingr/fix/deepseek-h2-preface
fix(provider): hold DeepSeek request bodies for the HTTP/2 server preface
2026-10-02 02:44:40 -07:00
YHH 790fdfca4b Merge pull request #11685 from esengine/feat/cli-misc
feat: /cls /rename /todo /forget /reload /effort /verbose /diff-fold /sandbox /output-style /reasoning-language /theme /language /currency
2026-10-02 02:27:01 -07:00
xiexian 7a8dfa5499 fix(studio): report plugin hook declaration warnings 2026-10-02 17:17:59 +08:00
xiexian 4e76141a41 docs(plugins): add native prompt author example 2026-10-02 17:09:22 +08:00
YHH 921e7bdb95 Merge pull request #11691 from esengine/fix/11338-studio-subagent-rows
fix(studio): keep fleet worker tool rows inside their worker card
2026-10-02 02:06:15 -07:00
xiexian 77a30b1c9e fix(studio): show qualified plugin prompt invocations 2026-10-02 17:05:49 +08:00
esengine 6565ce0c85 fix(cli): warn when the project config overrides /language; use slices.Backward 2026-10-02 01:53:09 -07:00
esengine 7a70fa4bf8 feat(tui): add /queue, /steer, /takeover, /status, /export and /copy slash commands
The Studio terminal UI answers the 1.x commands that read or steer the
kernel's own state, with 1.x argument shapes and output. Queue verbs and
steer go through the existing inbox routes, so the controller still owns
admission and ordering. /export is a controller capability: the file is
created exclusively and never through a symlink. /takeover resumes the
target without taking a session from another process.
2026-10-02 01:38:49 -07:00
esengine 86eeae3d6d fix(cli): keep settings verbs workspace-scoped and user-level, route currency through a typed notice 2026-10-02 01:38:10 -07:00
esengine 932736c19e feat(tui): add /cls /todo /verbose /diff-fold /theme and an effect test for the settings verbs 2026-10-02 01:33:51 -07:00
esengine 7c2890aeff feat(control): answer /rename /forget /sandbox /output-style /language /currency /reasoning-language /effort and /reload as slash commands 2026-10-02 01:33:45 -07:00
esengine a5984a25bb fix(desktop): keep a fleet worker's tool calls inside the group card
foldTool looked for a call's parent only among top-level cards, so a
worker's own calls, whose parent is the per-worker task card nested in the
fleet group, fell through to the main list.
2026-10-02 01:32:32 -07:00
YHH b66b2c34ff Merge pull request #11687 from esengine/feat/cli-modes
feat(tui): YOLO on the mode cycle with one Ctrl+Y, git footer row, paste folding
2026-10-02 01:32:27 -07:00
YHH 51163562f1 Merge pull request #11681 from KHG420/codex/studio-plugin-guide-current-controls
docs(studio): align plugin guide with current controls
2026-10-02 01:28:37 -07:00
esengine 6749731ea3 feat(tui): fold multi-line pastes the way 1.x does
Pastes of five lines or a thousand characters become [Pasted text #N · M lines],
counted whatever row ending the terminal uses, and expand on send into the
labelled Begin/End block 1.x sends. Numbering continues past labels a resumed
session already holds, and panels receive pastes unfolded.
2026-10-02 01:10:30 -07:00
esengine 1ace88e63d feat(tui): show workspace@branch with dirt counts in the footer and name compaction as 1.x does
The footer's data row now leads with workspace@branch and the +added -removed ?untracked
counts from a new GET /workspace/git, read at start and after each turn. The compaction
meter label is back to the bare COMPACT word.
2026-10-02 01:10:30 -07:00
esengine db0c478900 feat(tui): put YOLO on the Shift+Tab cycle and enable it with one Ctrl+Y
The one-time confirmation gate is gone: YOLO is always a stop on the cycle and a
single Ctrl+Y enters it, as in the 1.x terminal UI. --yolo on the command line
keeps its one-time notice, and the sandbox still applies in every mode.
2026-10-02 01:10:30 -07:00
xxoingrandClaude Opus 5.5 18043ab882 fix(provider): hold DeepSeek request bodies for the HTTP/2 server preface
On a fresh HTTP/2 connection, api.deepseek.com's edge acknowledges an eagerly
sent request body (about 8 KB or more) with WINDOW_UPDATE before its own
SETTINGS frame. RFC 9113 §3.4 requires SETTINGS first, so Go's client aborts
with "received WindowUpdateFrame before a SETTINGS frame" and the turn fails
with connection error PROTOCOL_ERROR. Studio sends a ~30 KB body on every
turn, so every request fails on affected networks.

Send Expect: 100-continue on chat requests to the official DeepSeek host.
The transport then holds the body until the server answers, by which point
its preface has arrived. HTTP/2 stays negotiated and request bytes are
unchanged; other providers are unaffected.

Moving the header setup into chat_headers.go keeps openai.go within its
file-size budget.

Refs #11679

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:07:58 +08:00
xiexian 9c435af496 fix(studio): identify directory theme contributions by pack name 2026-10-02 16:05:58 +08:00
YHH 7c03770b1a Merge pull request #11674 from KHG420/codex/studio-plugin-theme-diagnostics
fix(studio): report plugin theme validation warnings
2026-10-02 00:41:28 -07:00
YHH 78c884b1f2 Merge pull request #11677 from KHG420/codex/studio-theme-glob-file-siblings
fix(plugins): skip file siblings in nested theme globs
2026-10-02 00:40:30 -07:00
xiexian 0f48be263d docs(studio): align plugin guide with current controls 2026-10-02 15:37:32 +08:00
YHH 0bced53fe4 Merge pull request #11670 from KHG420/codex/studio-plugin-replace-recovery-hint
fix(plugins): offer replacement in duplicate recovery hint
2026-10-02 00:29:37 -07:00
YHH b52bb7d4c3 Merge pull request #11676 from esengine/docs/readme-tagline
docs(readme): align the tagline with the repository description
2026-10-02 00:26:31 -07:00
xiexian b69954c912 fix(plugins): skip file siblings in nested theme globs 2026-10-02 15:19:17 +08:00
YHH 754dee6725 Merge pull request #11671 from KHG420/codex/studio-lease-test-distinct-pid
test(control): keep lease holder PID distinct from test process
2026-10-02 00:18:12 -07:00
esengine d03684b080 docs(readme): align the tagline with the repository description 2026-10-02 00:08:48 -07:00
xiexian b0578fca34 fix(studio): report plugin theme validation warnings 2026-10-02 15:03:19 +08:00
YHH 8f9c7806c6 Merge pull request #11664 from KHG420/codex/studio-local-link-path-alias
fix: recognize directory aliases in local link installs
2026-10-01 23:41:23 -07:00
xiexian 23f44c5171 test(control): keep lease holder PID distinct from test process 2026-10-02 14:38:59 +08:00
YHH 28383e9f92 Merge pull request #11663 from KHG420/codex/studio-subagent-author-example
docs: add a packaged read-only subagent exercise
2026-10-01 23:33:54 -07:00
YHH 0a4d0493ee Merge pull request #11658 from KHG420/codex/studio-plugin-agent-inventory
fix(plugin): show packaged subagents in capability inventory
2026-10-01 23:32:57 -07:00
YHH 765bdfc872 Merge pull request #11668 from KHG420/codex/studio-linked-plugin-remove
fix: clean up managed links when removing plugin packages
2026-10-01 23:32:05 -07:00
xiexian 13247c20e3 fix(plugins): offer replacement in duplicate recovery hint 2026-10-02 14:22:11 +08:00
xiexian b30ff93cac fix: recognize directory aliases in local link installs 2026-10-02 13:49:52 +08:00
xiexian e309ee2200 docs: add a packaged read-only subagent exercise 2026-10-02 13:39:32 +08:00
xiexian 782070a0df fix: clean up managed links when removing plugin packages 2026-10-02 13:32:25 +08:00
YHH 9a4d3dfaa7 Merge pull request #11657 from esengine/docs/community-links
docs: point the community links at the official QQ group and Discord
2026-10-01 22:08:19 -07:00
YHH 852b5e65aa Merge pull request #11655 from KHG420/codex/studio-own-install-result-retry
fix(studio): let authors re-preview failed package installs
2026-10-01 21:56:19 -07:00
xiexian 9393e594f2 fix(plugin): show packaged subagents in capability inventory 2026-10-02 12:44:36 +08:00
esengine 871687fee7 docs: point the community links at the official QQ group and Discord 2026-10-01 21:34:32 -07:00
xiexian b12683f48b fix(studio): let authors re-preview failed package installs 2026-10-02 12:27:00 +08:00
YHH 88ca5ae82b Merge pull request #11648 from KHG420/codex/studio-plugin-remove-help
fix(cli): show confirmation flag in plugin removal help
2026-10-01 21:13:27 -07:00
YHH a6f744d414 Merge pull request #11649 from KHG420/codex/studio-skill-invocation-catalog
fix(studio): honor skill invocation flags in capability catalog
2026-10-01 21:11:33 -07:00
YHH 580339980f Merge pull request #11646 from KHG420/codex/studio-scenario-skill-frontmatter
fix(studio): keep scenario skill frontmatter valid YAML
2026-10-01 20:45:18 -07:00
xiexian daec47c72a fix(studio): honor skill invocation flags in capability catalog 2026-10-02 11:36:06 +08:00
xiexian 1eb7516562 fix(cli): show confirmation flag in plugin removal help 2026-10-02 11:25:19 +08:00
xiexian f392dd2fa7 fix(studio): keep scenario skill frontmatter valid YAML 2026-10-02 11:13:03 +08:00
YHH c0a7ebe10d Merge pull request #11640 from esengine/fix/ask-stepper-back
fix(studio): keep the ask dialog on the next question after going back
2026-10-01 19:22:52 -07:00
esengine 63da41c794 fix(studio): keep the ask dialog on the next question after going back
Next used the first still-open question, so after going back it skipped answered questions, and with two questions it vanished in favour of Confirm. Stepping is now positional; only the last question looks for an open one.
2026-10-01 19:14:20 -07:00
YHH 1651a909f0 Merge pull request #11637 from esengine/fix/touch-enter-lint
fix(studio): keep Enter newline on touch-first devices (continues #11620)
2026-10-01 17:46:45 -07:00
YHH 8ea3bec3e2 Merge pull request #11635 from esengine/ci/windows-boot-timeout
ci(windows): give the Windows package budget 12m now that boot runs past 8m
2026-10-01 16:39:54 -07:00
esengine eba925da41 ci(windows): give the Windows package budget 12m now that boot runs past 8m 2026-10-01 16:29:29 -07:00
YHH d85fe68a17 Merge pull request #11634 from esengine/docs/studio-2.25.0-notes-2
docs(release): add the fixes merged since the first 2.25.0 notes
2026-10-01 15:05:19 -07:00
esengine 73aca5b612 docs(release): add the fixes merged since the first 2.25.0 notes 2026-10-01 14:57:20 -07:00
YHH 195ee6dcf3 Merge pull request #11590 from KHG420/codex/studio-context-hook-example
docs(studio): add a session-start context author example
2026-10-01 14:37:23 -07:00
esengine a7e6771ae5 fix(studio): keep the composer under the file-size ceiling and cover the touch-device send paths 2026-10-01 14:28:17 -07:00
YHH 0c88b9d0b4 Merge pull request #11632 from esengine/fix/footer-text-clip
fix(studio): stop single-line labels from clipping glyphs of tall fonts
2026-10-01 13:25:26 -07:00
esengine e8ac15d52a fix(studio): stop single-line labels from clipping glyphs of tall fonts
The workspace label in the sidebar footer, the composer's model name and the
workbench file name paired overflow clipping with line-height 1, so a UI font
whose ascent and descent exceed 1em lost the tops and bottoms of its glyphs.
They now use line-height 1.5; the rows centre their children, so nothing moves.
A style contract fails any new rule that clips a line tighter than 1.2.
2026-10-01 13:16:53 -07:00
YHH e4dd0ad1b4 Merge pull request #11629 from esengine/fix/meterrail-tabular-nums
fix(studio): keep the status rail from jumping as its numbers change
2026-10-01 12:20:14 -07:00
esengine 404af94b9e fix(studio): keep the status rail from jumping as its numbers change
The rail items re-declare the font shorthand, which resets
font-variant-numeric, so the digits were proportional and each item changed
width with its value. Restate tabular-nums in those rules and reserve the
width of a three-digit speed.
2026-10-01 12:10:06 -07:00
YHH 8de4c91010 Merge pull request #11624 from esengine/feat/remote-bootstrap-nvm
fix(remote): run bootstrap commands with the remote login-shell PATH
2026-10-01 11:25:20 -07:00
esengine b94c4aa944 fix(remote): run bootstrap commands with the remote login-shell PATH 2026-10-01 11:12:01 -07:00
YHH ef1a339ca9 Merge pull request #11613 from Harbor404/fix/studio-11574-browser-workspace
fix(studio): let browser deployments add workspaces by path
2026-10-01 11:08:02 -07:00
YHH 6d679edf67 Merge pull request #11614 from Harbor404/fix/studio-11586-provider-name-ime
fix(studio): hold Add while a source name is composing
2026-10-01 11:03:23 -07:00
xiexian 29c033cfd5 docs(studio): add session-start context author example 2026-10-02 01:28:24 +08:00
YHH 3eda0b98e4 Merge pull request #11618 from esengine/feat/feedback-unavailable-retry
fix(feedback): retry a transient submit response once and say what to do next
2026-10-01 10:02:38 -07:00
Harbor404 b5485b1649 fix(studio): keep Enter newline on touch-first devices 2026-10-02 01:00:05 +08:00
esengine 5e1ea240fb fix(feedback): retry transient submit responses once
A submit that gets an answer without a recognised service code (an uncoded
5xx or an undecodable success body) is retried once with the existing
backoff, keeping the idempotency key. Coded refusals, refused redirects and
replies are never retried. The failure message now says the typed text is
kept, to try again shortly, and points at the GitHub alternative.
2026-10-01 09:41:30 -07:00
Harbor404 c046c5281f fix(studio): keep add disabled during source-name IME
The source-name field intentionally hides validity errors while an IME candidate is still composing. The Add button did not share that state, so a completed form could submit a partial non-ASCII candidate before composition ended. Hold the button while composing and cover the name boundaries at the UI and serve boundary.
2026-10-01 22:45:16 +08:00
Harbor404 7ad19b82a5 fix(studio): add workspaces from a browser
Probe host capabilities before opening a native picker. Headless and remote browser clients now enter a server-side path and use the existing /tree/workspaces API, while the desktop shell keeps its native dialog.
2026-10-01 22:44:28 +08:00
YHH 7b34dd738d Merge pull request #11610 from Harbor404/fix/studio-11581-legacy-recovery-archive
fix(serve): archive a legacy recovery lineage atomically
2026-10-01 07:42:24 -07:00
YHH d77138966a Merge pull request #11611 from KHG420/codex/studio-mcp-result-retry
fix(studio): retain retry for returned MCP install failures
2026-10-01 07:40:24 -07:00
xiexian 4642a4cbee fix(studio): retain retry for returned MCP install failures 2026-10-01 22:18:28 +08:00
YHH 2df63d9f9a Merge pull request #11609 from KHG420/codex/studio-install-failure-reload
fix(studio): preserve install failures alongside reload errors
2026-10-01 07:17:26 -07:00
Harbor404 99e466f2cc fix(sessionstore): archive recovery lineage atomically
Archive a folded legacy recovery row together with its siblings, keep siblings open in a pane active, and restore sidecars if a multi-file update fails. Refs #11581.
2026-10-01 22:09:57 +08:00
YHH c145598d47 Merge pull request #11599 from KHG420/codex/studio-export-executable
fix(studio): keep exported plugin hooks executable after reinstall
2026-10-01 07:08:00 -07:00
xiexian 5ff8944e9f fix(studio): preserve install failures alongside reload errors 2026-10-01 22:01:39 +08:00
YHH e080b2b972 Merge pull request #11604 from KHG420/codex/studio-install-reload-reason
fix(studio): preserve installation reload failure reasons
2026-10-01 06:55:37 -07:00
YHH 614f9d3831 Merge pull request #11595 from KHG420/codex/studio-my-packages-account
fix(studio): scope My packages to the current account
2026-10-01 06:42:18 -07:00
YHH 9d53a3aad7 Merge pull request #11602 from KHG420/codex/studio-remove-reload-result
fix(studio): retain reload failures after package removal
2026-10-01 06:41:23 -07:00
YHH 8d306bd0e6 Merge pull request #11596 from no-name-touse/fix/onboarding-zoom
fix(studio): make onboarding layout follow zoom-adjusted width
2026-10-01 06:33:27 -07:00
YHH a9cae776f5 Merge pull request #11601 from esengine/fix/context-budget-notice-continues-task
fix(agent): tell the model compaction is automatic and the task continues
2026-10-01 06:32:36 -07:00
xiexian 13c1820de5 fix(studio): preserve installation reload failure reasons 2026-10-01 21:16:34 +08:00
xiexian 5d33e047f4 fix(studio): retain reload failures after package removal 2026-10-01 21:06:03 +08:00
esengine ce860db141 fix(agent): tell the model compaction is automatic and the task continues
The context-budget notice reported "room remaining" and, at the second
rung, told the model to land what it knows now. It never said that the
fold is automatic and the same task goes on, so a model near the trigger
read the figure as the end of its window, announced it would be
compacted, and handed the work back to the user (in-app feedback
FB-J2QR-HQ89).

Both rungs now state that the figure counts to the automatic compaction,
not to the end of the window, and carry one shared continuation clause:
keep working, do not stop, hand the work back or suggest a new session.
The notice still rides the turn tail and the typed ContextBudgetFigures
event is unchanged.

Two fixtures sat within a few tokens of the notice size and are widened.
2026-10-01 06:03:44 -07:00
YHH 688658585b Merge pull request #11600 from esengine/docs/studio-2.25.0-notes
docs(release): prepare Studio 2.25.0 notes
2026-10-01 06:00:43 -07:00
esengine eb6ba928be docs(release): prepare Studio 2.25.0 notes 2026-10-01 05:51:27 -07:00
YHH ac7836f048 Merge pull request #11592 from KHG420/codex/studio-review-refresh-order
fix(studio): keep review results after concurrent install refresh
2026-10-01 05:45:51 -07:00
YHH ac7d7be97d Merge pull request #11588 from Guan0923/codex/session-title-context-menu
fix(studio): 右键打开会话菜单并调整版本角标间距
2026-10-01 05:45:01 -07:00
xiexian a2e20b8a2f fix(studio): preserve executable bits in exported plugins 2026-10-01 20:34:59 +08:00
YHH 0be8aaf783 Merge pull request #11488 from esengine/fix/host-session-sweep-off-boot-path
fix(serve): sweep session history in the background, not inside the build
2026-10-01 05:25:29 -07:00
YHH 1c12f7c174 Merge pull request #11563 from esengine/fix/inbox-skip-requeues
fix(inbox): a skipped question requeues the guidance queued into its turn
2026-10-01 05:15:47 -07:00
esengine 07b2453403 fix(serve): sweep session history in the background instead of inside the build
Every runtime build ran ReconcileCleanupPending inline: a handful of stat and
read calls per transcript in the session directory. On a Windows box with 8000
sessions the sweep took 11 s of an 11.8 s build and 29 s at 20000 sessions
(about 1.4 ms per session, warm), and it sits before the host handshake, so a
cold start with a large history waits on it. The shell gave up at 20 s.

The Studio host, hub.Open, workspace switch and rebuild paths now hand boot a
reconciler that runs the sweep in a goroutine, once per directory at a time.
8000 sessions: build 11.8 s -> 1.0 s.

Refs #11434
2026-10-01 05:14:42 -07:00
xiexian b619b3dbfe fix(studio): scope author package views to the current account 2026-10-01 20:14:15 +08:00
YHH 4cdd4bde93 Merge pull request #11541 from esengine/fix/remote-open-absolute-workspace
fix(remote): a Windows absolute workspace is not joined after the remote home
2026-10-01 05:09:56 -07:00
挽风 8ac91c3399 fix(studio): make onboarding layout follow zoom-adjusted width 2026-10-01 20:05:59 +08:00
esengine 9fc7f5022c fix(inbox): a skipped question requeues the guidance queued into its turn
Skipping an ask ends the turn through the same cancel as the stop button, so
guidance the user had queued into it was held uncertain and the queue paused.
The cancel now carries a typed cause: only the user declining a question
returns the undelivered item to the queue as an ordinary follow-up and lets
dispatch continue. Stop, errors and crashes keep the pause, and a stop that
lands after a skip still wins. The requeue only moves an accepted steer, so a
repeat delivers nothing twice.

Refs #11524
2026-10-01 05:03:36 -07:00
xiexian 7d6687d03b fix(studio): refresh inventory after review submission 2026-10-01 19:53:38 +08:00
YHH af710d8784 Merge pull request #11585 from esengine/fix/add-source-name-inline
fix(studio): show the source name rule beside the field, not as a connection failure
2026-10-01 04:46:51 -07:00
Guan09 723b2b8ef2 fix(studio): 右键打开会话菜单并调整版本角标间距 2026-10-01 19:35:36 +08:00
esengine 3afb4dea49 fix(studio): explain an unusable source name beside the field, not as a connection failure
The add-source form only learned that a name such as a Chinese one is not
allowed after the kernel refused the save, and then showed the refusal under
the title "cannot connect". The name rule now shows inline as the name is
typed (with the allowed characters stated), the add button waits for a usable
name, coded name refusals from the kernel land on the field, and any other
failed save is titled as a save failure. The identifier rule is unchanged.

Reported through in-app feedback FB-WJ8B-C1XH.
2026-10-01 04:34:52 -07:00
YHH 79b0547997 Merge pull request #11533 from esengine/feat/project-reveal-menu
feat(desktop): show a project's folder in the file manager from the sidebar menu
2026-10-01 04:25:14 -07:00
YHH e6d0d4aebe Merge pull request #11545 from esengine/fix/tray-icon-blurry
fix(studio): sharpen the Windows tray icon at scaled displays
2026-10-01 04:13:59 -07:00
esengine 7b7e09fdcd fix(serve): touch the disk only with the window's own listed root
Refs #11524
2026-10-01 04:13:47 -07:00
YHH 631bd6b111 Merge pull request #11419 from BuGlessRB/open/diff-fence-stream-perf
fix(cli): colour a streaming diff fence hunk by hunk / 修复(cli):流式 diff 围栏逐 hunk 着色
2026-10-01 02:57:08 -07:00
YHH 7ac760e44d Merge pull request #11558 from KHG420/codex/studio-market-vote-retry
fix(studio): retry failed market vote lookups
2026-10-01 02:56:13 -07:00
YHH 9dcfdf2e7c Merge pull request #11565 from esengine/fix/onboarding-scroll
fix(studio): make the first-run connect card scroll in short windows
2026-10-01 02:35:06 -07:00
YHH f86eb89b74 Merge pull request #11559 from esengine/fix/cancel-flag-follow-up
fix(control): flag a turn cancelled only when it ended on the cancel sentinel
2026-10-01 02:30:14 -07:00
esengine 6292d66e90 fix(studio): make the first-run connect card scroll in short windows
The onboarding stage only had a min-height, so it grew with its content and
never overflowed itself while the body clipped it. Once the connection result
was showing, the Start button sat below the window with nothing to scroll.
The stage is now the window's height and scrolls; the shell no longer shrinks
or acts as a scroll container. Adds a browser guard (perf/onboarding.mjs).
2026-10-01 02:24:17 -07:00
YHH 3bb9dce90a Merge pull request #11562 from esengine/docs/hooks-guide
docs: add a Hooks section to the guide and reasonix-guide skill
2026-10-01 02:23:54 -07:00
YHH 5d3a70950c Merge pull request #11552 from esengine/fix/11524-notices-localized
fix(notices): word the context-budget and unapplied-guidance notices from typed codes
2026-10-01 02:19:11 -07:00
esengine a27298c462 docs: document hooks (events, settings.json schema, exit codes, scope)
A model told a user that Reasonix has no hooks. The guide's hooks section only
covered SessionStart, and the bundled reasonix-guide skill had no settings
schema, exit-code convention or worked example.
2026-10-01 02:14:37 -07:00
YHH f84126a76f Merge pull request #11555 from esengine/fix/inbox-block-code
fix(inbox): carry why a queue item stopped as a typed code the desktop words itself
2026-10-01 02:09:05 -07:00
Stephen R. van den Berg e8c7feeeef fix(cli): colour a streaming diff fence hunk by hunk
A ```diff fence rendered through the colourised built-in path on every
frame, and a colourised row costs ~1.4 ms to lay out (chroma via
regexp2) against ~1.3 µs for a plain one. While a fence streams every
delta re-renders the whole growing body, so a multifile diff pegged a
core and the display crawled. Changes:

- Each "@@ " hunk header settles the rows before it, so render that
  prefix and leave only the in-progress hunk on the plain rail: the diff
  colours in as it streams instead of only at the closing fence. A
  closed fence settles the whole body. The formatter runs on each
  settled prefix too, so its output appears hunk by hunk.

- Keep the already-formatted rows on screen while a settled body's run
  is pending: growing the body by one hunk changed its key and redrew
  the whole section on the plain rail until the new run landed, so the
  delta output flashed back to plain at every hunk. The renderer now
  draws the largest settled prefix whose result has landed and leaves
  only the newly settled remainder (and the in-progress hunk) on the
  rail.

- Memoize the colourised rows per hunk (hunk, path, width, rail, theme).
  A completed hunk never changes, so it is highlighted once however many
  frames redraw it; caching the whole body instead would re-highlight
  every earlier hunk at each "@@ " and is quadratic in the hunk count
  (measured: 60 hunks, 79 KB took 40 s with a body memo, 2 s per-hunk).
  The theme is in the key so a runtime theme switch cannot serve the old
  palette's colours.

- A fence still with fewer than two hunks keeps the plain rail, and a
  closed fence whose configured [cli].diff_formatter result is still
  pending also draws the rail, since that output supersedes the built-in
  rows anyway; a landed-but-failed run falls back to the built-in.

- Hold the transcript viewport still when the content shrinks: a settled
  diff collapsing its raw preamble to one formatted line frees rows at
  the bottom, and following them back up made the screen jump. The
  position now holds, the freed rows stay blank, and later rows fill
  them, so scrolling resumes only when the output actually grows past
  what is shown. The hold has only a lower bound, so a shrink larger
  than the viewport would hold the position past the content and blank
  the whole transcript — a collapsing multifile fence, or /clear
  restarting over a shorter one — so the view re-anchors to its tail
  instead. A manual scroll or resize releases the hold.

- Coalesce the stream frames the TUI hands the model: waitUpdate drains
  everything already queued into one updateMsg, so a burst of deltas
  costs one render instead of one per delta. Order is kept and a Gap
  still triggers a history reload.

- Fix the file-header misdetection: a removed SQL "-- x" line renders
  "--- x", which trimDiffPreamble/diffSectionStart mistook for a
  "--- "/"+++ " file header, dropping the "@@" hunk header (line numbers
  reset to 0) or the rows themselves when a removed "--- x" sat before
  an added "+++ y". A pair is a file header only when a "@@ " hunk
  follows it, and countDiff no longer skips "--- "/"+++ " rows.

- Keep a streaming multi-file diff's next file header on the plain rail:
  splitAtLastHunk settled everything before the last "@@ " header, which
  included the next file's "--- "/"+++ " pair (and its git preamble) once
  that file's first hunk header had arrived but no hunk of it had
  completed. isFileHeaderPair then saw no "@@ " after the pair, so the
  settled renderer read it as a removed/added row of the previous file's
  last hunk and counted it in that file's stat. A trailing dangling file
  header now moves from the settled part into the tail until its "@@ "
  arrives.

Tests: the multi-file cases TestSplitAtLastHunk was missing and an
end-to-end streamed test; a theme-switch test pinning the per-hunk memo's
theme key; and a benchmark replaying a 4-file x 15-hunk stream, redrawn
per streamed line.

fix(cli): highlight a streaming diff fence's hunk once

splitDiffSections appends a newline to every section, so splitHunks
returns a section's last hunk with a trailing newline and every earlier
hunk without. hunkRowsCached keyed the memo on the raw hunk, so one hunk
carried two keys over a stream: it was highlighted once while it was its
section's last hunk and again once a following hunk arrived. Every hunk
but a file's last was therefore highlighted twice, at ~1.4 ms a row.

Trim the trailing newline before keying. diffBody already trims, so the
rendered rows are unchanged; the streaming benchmark over a 4-file,
30-hunk, 1816-line diff drops from 7.8 s to 5.6 s.

TestStreamingDiffHunkMemoisedOnce pins one memo entry per hunk: it sees
three entries for two hunks before this change, two after.

Fold the writer-tool preview before highlighting it too. diffBody laid
out every row and then dropped all but maxLines, so a write_file card's
~24-row preview colourised the whole diff: ~772 ms per repaint for a
199-line file, paid on every message while the call runs. Counting the
fold from the source and laying out only the kept rows prices a
2000-line diff like a 24-line one.

BenchmarkDiffBlockPreview prices both shapes through a diffPreviewFold
seam, which TestDiffBodyFoldSeamSameRows proves draws the same rows: a
2000-line preview is 6.3 s and 86 MB with the whole body laid out, 71 ms
and 1.0 MB folded, and flat from 24 lines up.

docs(cli): correct diff-fence comment coherence

Four comments described behaviour the code does not have:

- diffRowCount's doc counted one row per non-blank line, but diffBody draws no
  row for a "@@ " header, so it over-counted by the hunk count.
- renderCodeRail's doc claimed the in-progress hunk and the pending-formatter
  placeholder, both drawn by renderRailText, not here.
- splitDiffSections' doc named only the fence path, omitting DiffText, and
  described the plain-rail fallback where DiffText drops a headerless section.
- the file header claimed md.go still sits at the 800-line ceiling.

Reword each to the constraint that holds now.
2026-10-01 11:01:45 +02:00
esengine f836dc6a3a fix(inbox): carry why a queue item stopped as a typed code the desktop words itself
A held queue item showed the kernel's English diagnostic (for example
"steer accepted but unapplied before turn exit") in a Chinese UI. Items now
also carry a stable BlockCode and the desktop words it in the UI language;
unknown codes fall back to the diagnostic.
2026-10-01 01:56:28 -07:00
YHH 69cb060066 Merge pull request #11548 from esengine/fix/storage-move-keeps-project-list
fix(storage): carry the remembered project list when the state root moves
2026-10-01 01:48:11 -07:00
esengine 6328dfa0b5 fix(control): flag a turn cancelled only when it ended on the cancel sentinel
A real failure that raced the stop button was reported as a cancellation and
hidden. TurnDone.Cancelled now requires Err to be nil or context.Canceled. The
desktop also reads "Cancelled" instead of "Interrupted" after the user's own
cancel.

Refs #11524
2026-10-01 01:43:02 -07:00
xiexian 9bb0aa28fd fix(studio): retry failed market vote lookups 2026-10-01 16:34:22 +08:00
YHH 3a754cebdb Merge pull request #11547 from KHG420/codex/studio-skill-author-invocation-comment
docs(skill): clarify authoring invocation policy
2026-10-01 01:34:04 -07:00
esengine 6694a1b81a fix(storage): carry the state root's loose entries when it moves
Settings > Storage moved sessions and projects but left other top-level state
behind while the code reads only the new root: the remembered project list
(serve-workspaces.json), user-level REASONIX/AGENTS/CLAUDE instructions,
memory/global, schedules, trusted seals, machine-id.key and the MCP migration
marker. All are now owned entries, so a move carries them.

Installs already moved get them back at boot: each entry is merged once without
overwriting and the old copy is renamed *.adopted. The project list is merged
under its lock and written atomically by a new leaf package shared with serve,
and file copies publish whole files. A guard test scans the tree for paths
joined under the state root so a future entry cannot be forgotten.
2026-10-01 01:33:31 -07:00
YHH 003000be21 Merge pull request #11532 from KHG420/codex/studio-publish-draft-lock
fix(studio): keep the publishing draft stable while submitting
2026-10-01 01:29:07 -07:00
YHH 92a1677fda Merge pull request #11551 from esengine/fix/11524-cancel-not-error
fix(desktop): do not draw a user-cancelled turn as an error card
2026-10-01 01:22:05 -07:00
esengine e4f1f80a76 fix(notices): word the context-budget and unapplied-guidance notices from typed codes
Both host notices reached every frontend as kernel English, so a Chinese UI
showed an English card. The context-budget notice now carries a code and a JSON
figures payload, the unapplied-guidance notice carries the user's text as
Detail, and the desktop card and the TUI word them from those in the UI
language. Unknown codes and undecodable payloads still fall back to the
kernel's text.

Refs #11524
2026-10-01 01:20:40 -07:00
YHH 9ced3d1577 Merge pull request #11542 from KHG420/codex/studio-fullsidecar-host-proof
docs(sdk): add fullsidecar installation and host checks
2026-10-01 01:04:54 -07:00
esengine 5caa03eca1 fix(desktop): do not draw a user-cancelled turn as an error card
Skipping an ask (or stopping a turn) ends it with the kernel's Cancelled flag
set and context.Canceled as Err. The transcript appended Err as an error card
regardless of the flag, so the user's own choice read as "context canceled".
The flag is the identity; Err is not drawn when it is set. An effect test pins
the kernel side through boot.Build.

Refs #11524
2026-10-01 00:45:03 -07:00
xiexian 309ea9aa0f docs(skill): clarify authoring invocation policy 2026-10-01 15:42:19 +08:00
xiexian 15155c3a67 fix(studio): keep the publishing draft stable while submitting 2026-10-01 15:36:14 +08:00
esengine 7f7a063139 fix(studio): give the Windows tray icon a file at exactly the size Windows asks for 2026-10-01 00:36:08 -07:00
YHH 8c608c8bba Merge pull request #11523 from KHG420/codex/studio-skill-dependency-guide
docs(skills): explain and verify MCP dependency readiness
2026-10-01 00:33:50 -07:00
YHH e9a863c36d Merge pull request #11304 from Guan0923/codex/user-message-copy
feat(studio): 重设计用户消息操作栏并新增复制功能
2026-10-01 00:30:47 -07:00
YHH 0d30c68641 Merge pull request #11528 from KHG420/codex/studio-source-disabled-style
fix(studio): distinguish the busy plugin source without fading text
2026-10-01 00:27:32 -07:00
YHH dc8f746f5e Merge pull request #11537 from KHG420/codex/studio-skill-invocation-comment
docs(skills): distinguish manual listing from model invocation
2026-10-01 00:26:38 -07:00
YHH f6d948c60b Merge pull request #11522 from KHG420/codex/studio-export-result-lifecycle
fix(studio): keep package export feedback current and visible
2026-10-01 00:25:07 -07:00
esengine b2f36909eb fix(remote): a Windows absolute workspace is not joined after the remote home
resolveWorkspace only knew a leading slash as rooted, so a drive-letter path such as C:\Users\u\x (what the remote tree reports as a project root) was appended to home and the serve never started. Rootedness is now asked of the target machine.
2026-10-01 00:24:00 -07:00
xiexian b5f2f0c025 test(extensions): verify installed fullsidecar host effects 2026-10-01 15:14:53 +08:00
YHH c9daeddf3e Merge pull request #11530 from esengine/fix/editmenu-locale
fix(electron): label the edit menus in the interface language
2026-09-30 23:52:52 -07:00
xiexian 8dd9294495 docs(skills): distinguish manual listing from model invocation 2026-10-01 14:48:59 +08:00
esengine f2dc940a29 test(desktop): cover the host-only route, remote projects and old shells
Refs #11524
2026-09-30 23:47:02 -07:00
xiexian 9d930f67f4 docs(skills): explain and verify MCP dependency readiness 2026-10-01 14:45:56 +08:00
esengine c0766fbe59 fix(electron): keep every platform Edit menu item, test the rebuild rule
The hand-written Edit menu dropped Paste and Match Style, Delete,
Substitutions and Speech; restore them with the same roles (structure checked
against the role-based menu in a real Electron). The menu factory is now
Electron-free and unit-tested, the language follows the first preferred system
language like the page, and the redundant second menu install is gone.

Refs #11524
2026-09-30 23:45:39 -07:00
Guan09 73e2fb91af fix(studio): restore action hit areas and unify hover feedback 2026-10-01 14:43:36 +08:00
xiexian 4df81041b1 fix(studio): dim the busy plugin source input 2026-10-01 14:41:29 +08:00
YHH 4bcda5691d Merge pull request #11511 from KHG420/codex/studio-skill-catalog-guide
docs(skills): explain the catalog projection and cache boundary
2026-09-30 23:32:07 -07:00
esengine 383e6369ac refactor(desktop): read the shell's ability to reveal from the host, not the hub 2026-09-30 23:30:59 -07:00
esengine 6bb625fcf7 fix(desktop): extract the reveal menu item, add refusal wording 2026-09-30 23:22:30 -07:00
YHH 8d963086ea Merge pull request #11529 from esengine/feat/subagent-bg-rows
feat(studio): sub-agent transcript entry in the deck, show-all for clipped nested output
2026-09-30 23:21:57 -07:00
esengine 85df91d4ef feat(desktop): show a project's folder in the file manager from the sidebar menu
The project menu offered only sort and remove-from-list. Add a reveal item that
asks the hub where a listed project lives (GET /host/workspaces/locate, window
only, refuses any folder the window does not list) and has the shell select it,
so it needs no open pane. Remote hosts draw in their own rail and never reach
this menu; a project whose folder is gone shows the item disabled with the
reason.

Refs #11524
2026-09-30 23:21:09 -07:00
xiexian af2dbb252d fix(studio): keep package export feedback current and visible 2026-10-01 14:15:30 +08:00
esengine 8de4b4851c fix(electron): label the edit menus in the interface language
Role-only menu items take their text from Electron's built-in English
catalogue even when the app locale is zh-CN (verified on a real Electron:
getLocale() == zh-CN, labels Undo/Cut/Copy/Paste/Select All). Keep the roles
for the clipboard work and give each item an explicit zh/en label chosen by the
page's saved language (rx-lang), falling back to the machine locale exactly as
the page does. The macOS Edit menu is rebuilt when the language preference
changes.

Refs #11524
2026-09-30 23:15:27 -07:00
esengine fb3d9d7433 fix(studio): register the new actions, trap focus in the transcript overlay, cap its output
Registers tool.show-all, agent.open, agent.close and agent.focus-trap, moves
focus into the overlay and back to the deck chip, caps the overlay's rendered
output at 200000 characters with a note, keeps the sub-agent count stable, wraps
nested output, and drops a stray .agents rule that changed the sidebar spacing.
2026-09-30 23:13:00 -07:00
YHH 66029c7dec Merge pull request #11521 from KHG420/codex/studio-plugin-export-refusal
fix(studio): preserve plugin export failure reasons
2026-09-30 23:07:39 -07:00
esengine 43b210573b feat(studio): sub-agent rows open a full transcript, and clipped nested output offers show-all
The sub-agent list stays after delegates finish and each row opens that
sub-agent's whole transcript. A nested call whose output exceeds the 400
character preview now says so with a show-all control instead of cutting.
2026-09-30 23:02:34 -07:00
xiexian 422b47b3a0 docs(skills): describe the turn-projected catalog 2026-10-01 13:39:24 +08:00
YHH d80c290d89 Merge pull request #11519 from KHG420/codex/studio-plugin-source-confirmation
fix(studio): keep the confirmed plugin source stable during preview
2026-09-30 22:29:28 -07:00
YHH 594cbf664a Merge pull request #11520 from KHG420/codex/studio-package-action-errors
fix(studio): expose failed package operations alongside diagnostics
2026-09-30 22:28:38 -07:00
YHH 7bb8031e85 Merge pull request #11514 from KHG420/codex/studio-shared-project-skill
docs(skills): verify sharing a project playbook across collaborators
2026-09-30 22:10:17 -07:00
xiexian 188df0245b fix(studio): preserve plugin export refusal details 2026-10-01 13:04:18 +08:00
xiexian c2df8e3ed6 fix(studio): reveal package action failures with diagnostics 2026-10-01 12:54:56 +08:00
xiexian 104d01ba07 fix(studio): keep plugin source stable during preview 2026-10-01 12:43:45 +08:00
YHH e977923203 Merge pull request #11509 from KHG420/codex/studio-mcp-partial-retry
fix(studio): preserve partial MCP installation results on retry
2026-09-30 21:34:17 -07:00
YHH 5450ed38e9 Merge pull request #11510 from KHG420/codex/studio-backup-plugin-owner
fix(studio): isolate plugin selections during backup restore
2026-09-30 21:33:27 -07:00
xiexian 3fcce85ca7 docs(skills): cover repository sharing and personal activation 2026-10-01 12:26:06 +08:00
xiexian d6cc51fe76 fix(studio): keep restored plugin installers with their selection 2026-10-01 11:50:07 +08:00
xiexian abca1a62c5 fix(studio): retain partial MCP outcomes and retry remaining servers 2026-10-01 11:45:26 +08:00
YHH b70b86e7a7 Merge pull request #11494 from esengine/fix/headless-trust-silence
fix(run): say why a headless run in an untrusted folder did nothing
2026-09-30 20:38:05 -07:00
YHH 754166fc32 Merge pull request #11502 from KHG420/codex/studio-plugin-install-confirmation
fix(studio): preserve new plugin confirmation during install
2026-09-30 20:34:00 -07:00
YHH 0aeb204660 Merge pull request #11500 from KHG420/codex/studio-mcp-install-controls
fix(studio): keep MCP install confirmation stable while pending
2026-09-30 20:33:10 -07:00
YHH 1fa7030ced Merge pull request #11499 from KHG420/codex/studio-mcp-partial-install-notification
fix(studio): refresh inventory after partial MCP installation
2026-09-30 20:32:20 -07:00
YHH 43661cdec1 Merge pull request #11498 from KHG420/codex/studio-mcp-install-connection
fix(studio): reset MCP installation state on connection changes
2026-09-30 20:31:30 -07:00
YHH e07bc5faa8 Merge pull request #11496 from KHG420/codex/studio-mcp-operation-owner
fix(studio): scope MCP row confirmations and responses
2026-09-30 20:30:40 -07:00
YHH a968fd8881 Merge pull request #11497 from KHG420/codex/studio-inspection-shortcut-guard
fix(studio): guard installation inspection shortcuts
2026-09-30 20:29:50 -07:00
YHH 420a9231c5 Merge pull request #11458 from KHG420/codex/studio-project-order-11420
feat(studio): persist manual project order without changing launch project
2026-09-30 20:29:00 -07:00
YHH 11b84e5891 Merge pull request #11481 from KHG420/codex/studio-frontend-page-example
docs(studio): add a local frontend delivery example
2026-09-30 20:28:10 -07:00
YHH 0495d1f294 Merge pull request #11485 from KHG420/codex/studio-author-settled-notification
fix(studio): preserve author install notifications after sign-out
2026-09-30 20:27:20 -07:00
YHH 0040dec2a0 Merge pull request #11486 from KHG420/codex/studio-runtime-reload-connection
fix(studio): scope runtime reload state to its connection
2026-09-30 20:26:31 -07:00
YHH 4b94a02c27 Merge pull request #11489 from KHG420/codex/studio-package-operation-connection
fix(studio): reset package operation state on connection change
2026-09-30 20:25:41 -07:00
YHH 28a9010d0e Merge pull request #11475 from KHG420/codex/studio-url-elicitation-11348
feat(mcp): support consent-based URL elicitation
2026-09-30 20:24:40 -07:00
xiexian 71fb27901b fix(studio): preserve new plugin confirmation during install 2026-10-01 10:42:52 +08:00
xiexian 223c55b95f fix(studio): keep MCP install confirmation stable while pending 2026-10-01 10:34:49 +08:00
xiexian d84543d259 fix(studio): refresh inventory after partial MCP installation 2026-10-01 10:17:49 +08:00
xiexian 0247623274 fix(studio): reset MCP installation state on connection changes 2026-10-01 10:05:31 +08:00
esengine 8fa67e544e fix(run): say why a headless run in an untrusted folder did nothing
A run in a folder with no trust decision refused every edit and command
under the asking default and exited 0 with an empty stderr (plain text
run) and only the generic 'no interactive approver' reason for the model.

The refusal now carries permission.untrusted_folder. The model reads the
cause and the remedy, the text run prints refused tools, code and remedy on
stderr, the structured result's denial carries a remedy, and
--fail-on-unverified exits 3 for it. Exit 0 and the trust gate itself are
unchanged.

Refs #11493
2026-09-30 18:55:48 -07:00
xiexian fe850bc160 fix(studio): guard installation inspection shortcuts 2026-10-01 09:50:50 +08:00
xiexian f527fa023d fix(studio): scope MCP row confirmations and responses 2026-10-01 09:41:52 +08:00
xiexian 33199377da fix(studio): reset package operation state on connection change 2026-10-01 08:57:14 +08:00
xiexian 1f1c469b16 fix(studio): scope runtime reload state to its connection 2026-10-01 08:51:01 +08:00
xiexian 7fe57fc25a fix(studio): preserve author install notifications after sign-out 2026-10-01 08:43:39 +08:00
xiexian e5a801c603 feat(studio): persist manual project order without changing launch project 2026-10-01 08:35:14 +08:00
xiexian 824f95613a feat(mcp): support consent-based URL elicitation 2026-10-01 08:19:31 +08:00
xiexian 03d5346869 docs(studio): add a local frontend delivery example 2026-10-01 08:15:01 +08:00
Guan09 a2a429e3aa fix(studio): tighten user message action spacing 2026-09-30 23:02:44 +08:00
Guan09 e1f30114d2 fix(studio): show copy feedback and remove empty user header 2026-09-30 20:22:49 +08:00
Guan09 23ade961ba feat(studio): 调整用户消息操作栏并增加复制按钮 2026-09-30 18:20:53 +08:00
484 changed files with 25066 additions and 1758 deletions

No files matched your search

+6
View File
@@ -6,3 +6,9 @@ contact_links:
- name: Questions & discussions
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
- name: Discord
url: https://discord.gg/XF78rEME2D
about: Bilingual community chat for setup help, workflow showcases, and feature ideas.
- name: QQ group (DeepSeek-Reasonix官方群, 1093562660)
url: https://qm.qq.com/q/i59b0z2R8s
about: Chinese-language community chat for setup help and everyday questions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+9 -9
View File
@@ -80,7 +80,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# 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
@@ -175,12 +175,12 @@ jobs:
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
#
# That timeout is 8m, not 3m: across four consecutive runs of the same
# That timeout is 12m, not 3m: across four consecutive runs of the same
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
# step failed on slow runners without a code change; a hang still trips
# the 15 minute job budget above.
# variance alone, with four heavy packages sharing four vCPUs. The boot package
# has since grown to 422s and then ran past 480s on two consecutive runs of the
# same code, so 8m no longer leaves room. A hang still trips the job budget.
#
# Every root-module `go test` that runs on Windows passes -count=1. With
# the result cache on, go test records each file and environment access
@@ -191,11 +191,11 @@ jobs:
# internal/base/testenv holds every such step to it.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 15
timeout-minutes: 20
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -214,7 +214,7 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite still uses the same 8m package budget as Windows smoke:
# Full push suite still uses the same 12m package budget as Windows smoke:
# agent/boot/control already hit 180s package timeouts under runner load,
# so 3m fails on slow machines without a code regression. It runs with
# -count=1 for the reason given at the smoke step. A hang still dies here
@@ -230,7 +230,7 @@ jobs:
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=8m ./...
run: go test -count=1 -p 4 -timeout=12m ./...
race:
needs: changes
+10 -2
View File
@@ -18,6 +18,8 @@
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -31,6 +33,7 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -41,12 +44,16 @@
<br/>
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A coding agent you can leave running.</h3>
<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">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
>
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
<br/>
@@ -265,8 +272,9 @@ coffee, not a contract — donations don't buy feature priority or change how
issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付(扫码)
- **国内** — 微信支付 / 支付宝(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
</p>
+10 -2
View File
@@ -18,6 +18,8 @@
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -31,6 +33,7 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -41,12 +44,16 @@
<br/>
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
>
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
## 版本
@@ -245,9 +252,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付(扫下方二维码)
- **国内** — 微信支付 / 支付宝(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
</p>
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// the app's own folder in the sidebar again after every removal.
func launchWorkspace(appExe string) string {
home, _ := os.UserHomeDir()
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.Workspaces(), home)
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
}
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -61,3 +62,31 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
}
}
}
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
base := t.TempDir()
home := filepath.Join(base, "state")
app := filepath.Join(base, "app")
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
for _, dir := range []string{home, app, a, b} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("REASONIX_HOME", home)
t.Chdir(app)
data, _ := json.Marshal(map[string]any{"paths": []string{b, a}, "launch": a})
if err := os.WriteFile(filepath.Join(home, "serve-workspaces.json"), data, 0o644); err != nil {
t.Fatal(err)
}
exe := filepath.Join(app, "Reasonix Studio")
if got := launchWorkspace(exe); got != a {
t.Fatalf("launchWorkspace = %q, want the pre-reorder launch project %q", got, a)
}
if err := os.Remove(a); err != nil {
t.Fatal(err)
}
if got := launchWorkspace(exe); got != b {
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
}
}
+4
View File
@@ -321,6 +321,10 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
Stderr: logs,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
})
if err != nil {
return nil, err
+37
View File
@@ -71,3 +71,40 @@ func TestTheHostPointsTheKernelsCatalogueAtTheConfiguredLanguage(t *testing.T) {
}
}
}
// Sweeping a session directory costs a handful of file operations per
// transcript, and the window cannot open until the handshake. A build that
// sweeps inline makes the first launch after the cache went cold wait on
// history it never displays.
func TestTheHostsFirstBuildSweepsSessionsInTheBackground(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
found := false
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
sel, ok := lit.Type.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Options" {
return true
}
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "CleanupPendingReconciler" {
if v, ok := kv.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "BackgroundCleanupReconciler" {
found = true
}
}
}
return true
})
if !found {
t.Fatal("the host's boot.Options does not set CleanupPendingReconciler to serve.BackgroundCleanupReconciler")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+4 -3
View File
@@ -7,9 +7,10 @@ const { chromium } = require("playwright");
const ASSETS = path.join(__dirname, "..", "assets");
const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
// The tray is drawn at 16 logical pixels; nativeImage picks the @Nx file that
// matches the display, so a scaled screen never enlarges the 16px one.
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [2, "@2x"], [3, "@3x"]];
// The tray is drawn at 16 logical pixels. Windows asks for exactly 16*scale
// pixels and resizes whatever it is handed, so every Windows scale has its own
// file (src/trayimage.js picks it).
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
+123 -13
View File
@@ -1,21 +1,131 @@
"use strict";
// The context menu's shape, kept apart from the platform so it can be checked
// without one. editFlags is the page's own account of what is possible at the
// click: roles do the clipboard work, this only decides what to offer.
function contextTemplate(params) {
if (!params.isEditable && !params.selectionText) return [];
const can = params.editFlags ?? {};
// Role labels come from the platform's own catalogue, which follows the
// system locale rather than the language the interface was set to, so they are
// stated here for the two languages the interface draws.
const WORDS = {
zh: {
edit: "编辑",
undo: "撤销",
redo: "重做",
cut: "剪切",
copy: "复制",
paste: "粘贴",
selectAll: "全选",
pasteAndMatchStyle: "粘贴并匹配样式",
delete: "删除",
substitutions: "替换",
showSubstitutions: "显示替换",
smartQuotes: "智能引号",
smartDashes: "智能破折号",
textReplacement: "文本替换",
speech: "语音",
startSpeaking: "开始朗读",
stopSpeaking: "停止朗读",
},
en: {
edit: "Edit",
undo: "Undo",
redo: "Redo",
cut: "Cut",
copy: "Copy",
paste: "Paste",
selectAll: "Select All",
pasteAndMatchStyle: "Paste and Match Style",
delete: "Delete",
substitutions: "Substitutions",
showSubstitutions: "Show Substitutions",
smartQuotes: "Smart Quotes",
smartDashes: "Smart Dashes",
textReplacement: "Text Replacement",
speech: "Speech",
startSpeaking: "Start Speaking",
stopSpeaking: "Stop Speaking",
},
};
function wordsFor(lang) {
return WORDS[lang] ?? WORDS.en;
}
// The edit commands as roles carrying explicit labels: roles do the clipboard
// work, the labels follow the interface language.
function editItems(lang, can) {
const w = wordsFor(lang);
const on = (flag) => (can ? { enabled: !!can[flag] } : {});
return [
{ role: "undo", enabled: !!can.canUndo },
{ role: "redo", enabled: !!can.canRedo },
{ role: "undo", label: w.undo, ...on("canUndo") },
{ role: "redo", label: w.redo, ...on("canRedo") },
{ type: "separator" },
{ role: "cut", enabled: !!can.canCut },
{ role: "copy", enabled: !!can.canCopy },
{ role: "paste", enabled: !!can.canPaste },
{ role: "cut", label: w.cut, ...on("canCut") },
{ role: "copy", label: w.copy, ...on("canCopy") },
{ role: "paste", label: w.paste, ...on("canPaste") },
{ type: "separator" },
{ role: "selectAll", enabled: !!can.canSelectAll },
{ role: "selectAll", label: w.selectAll, ...on("canSelectAll") },
];
}
module.exports = { contextTemplate };
// The context menu's shape, kept apart from the platform so it can be checked
// without one. editFlags is the page's own account of what is possible at the
// click: this only decides what to offer.
function contextTemplate(params, lang) {
if (!params.isEditable && !params.selectionText) return [];
return editItems(lang, params.editFlags ?? {});
}
// Everything the platform's own Edit menu carries, with the same roles, so
// only the words change.
function editMenuTemplate(lang) {
const w = wordsFor(lang);
const items = editItems(lang);
const at = items.findIndex((i) => i.role === "selectAll");
items.splice(at - 1, 1);
items.splice(at - 1, 0,
{ role: "pasteAndMatchStyle", label: w.pasteAndMatchStyle },
{ role: "delete", label: w.delete });
items.push(
{ type: "separator" },
{
label: w.substitutions,
submenu: [
{ role: "showSubstitutions", label: w.showSubstitutions },
{ type: "separator" },
{ role: "toggleSmartQuotes", label: w.smartQuotes },
{ role: "toggleSmartDashes", label: w.smartDashes },
{ role: "toggleTextReplacement", label: w.textReplacement },
],
},
{
label: w.speech,
submenu: [
{ role: "startSpeaking", label: w.startSpeaking },
{ role: "stopSpeaking", label: w.stopSpeaking },
],
},
);
return { label: w.edit, submenu: items };
}
function applicationMenuTemplate(lang) {
return [{ role: "appMenu" }, editMenuTemplate(lang), { role: "windowMenu" }];
}
// Only the Edit menu follows the interface language; the application and window
// menus are roles and follow the OS. Menu is injected so this runs without Electron.
function menuInstaller(Menu, platform = process.platform) {
let installedLang = null;
return function installApplicationMenu(languageOf = () => "en") {
if (platform !== "darwin") {
Menu.setApplicationMenu(null);
return null;
}
const lang = languageOf();
if (lang === installedLang) return Menu.getApplicationMenu();
installedLang = lang;
const menu = Menu.buildFromTemplate(applicationMenuTemplate(lang));
Menu.setApplicationMenu(menu);
return menu;
};
}
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
+13 -6
View File
@@ -13,10 +13,11 @@ const { StudioHost } = require("./hostclient");
const { installTray } = require("./tray");
const { instanceID, profileFor } = require("./instance");
const { installApplicationMenu, installContextMenu } = require("./menu");
const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
const { externalTarget } = require("./links");
const { reveal } = require("./reveal");
const { reveal, revealWorkspace } = require("./reveal");
const { appIcon } = require("./appicon");
const layout = require("./layout");
const { offerCleanup } = require("./legacy");
@@ -24,7 +25,7 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { prefsFile, registerPrefs } = require("./prefs");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
// A page in a minimized or fully covered window counts as hidden, and a hidden
@@ -166,7 +167,7 @@ async function boot() {
const cause = unpaintedWindowCause(grants, app.getLocale());
if (cause) dialog.showErrorBox(cause.title, cause.detail);
});
installContextMenu(win.webContents, win);
installContextMenu(win.webContents, win, uiLang);
installFullScreenKey(win.webContents, win);
reload = installReload(win.webContents, win);
win.once("ready-to-show", () => win.show());
@@ -314,7 +315,10 @@ function fromWindow(event) {
return win && !win.isDestroyed() && event.sender === win.webContents ? win : null;
}
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow);
// The page reads navigator.languages[0], which is the first preferred system
// language; app.getLocale() is the locale Chromium resolved, which can differ.
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
@@ -351,8 +355,11 @@ ipcMain.handle("browser:login-answer", (event, id, username, password) => {
ipcMain.handle("shell:open-external", (event, raw) => {
if (!fromWindow(event)) return;
const target = externalTarget(raw);
if (target) void shell.openExternal(target);
if (target) return shell.openExternal(target);
});
ipcMain.handle("shell:reveal-workspace", (event, root) =>
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:reveal", (event, base, rel) =>
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
@@ -428,7 +435,7 @@ if (!primary) {
app.whenReady().then(() => {
if (!primary) return;
installApplicationMenu();
installApplicationMenu(uiLang);
boot().catch((err) => {
console.error("reasonix-studio:", err.message);
if (quitting) {
+4 -16
View File
@@ -1,31 +1,19 @@
"use strict";
const { Menu } = require("electron");
const { contextTemplate } = require("./editmenu");
const { contextTemplate, menuInstaller } = require("./editmenu");
// Without an application menu a WebContents has no copy, paste or undo at all:
// macOS routes those shortcuts through it, and a window with none reads as a
// broken text editor. Elsewhere the bar would render inside the window, and
// those platforms bind the shortcuts themselves.
function installApplicationMenu() {
if (process.platform !== "darwin") {
Menu.setApplicationMenu(null);
return null;
}
const menu = Menu.buildFromTemplate([
{ role: "appMenu" },
{ role: "editMenu" },
{ role: "windowMenu" },
]);
Menu.setApplicationMenu(menu);
return menu;
}
const installApplicationMenu = menuInstaller(Menu);
// A production build ships without one, so a right-click in a text field offered
// nothing at all. Roles rather than handlers: the clipboard work belongs to the
// platform, and editFlags is the page's own account of what is possible here.
function installContextMenu(contents, window) {
function installContextMenu(contents, window, languageOf = () => "en") {
contents.on("context-menu", (_event, params) => {
const template = contextTemplate(params);
const template = contextTemplate(params, languageOf());
if (!template.length) return;
Menu.buildFromTemplate(template).popup({ window });
});
+2 -1
View File
@@ -44,7 +44,7 @@ function savePrefs(file, prefs) {
// in the same process and must not reach this file.
// fileOf is asked on each use: the profile directory is settled during boot,
// after these handlers exist.
function registerPrefs(ipcMain, fileOf, fromWindow) {
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
ipcMain.on("prefs:load", (event) => {
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
});
@@ -52,6 +52,7 @@ function registerPrefs(ipcMain, fileOf, fromWindow) {
if (fromWindow(event)) {
try {
savePrefs(fileOf(), prefs);
onSave();
} catch {
// A preference that did not save is one relaunch of lost state, not a
// reason to fail the window.
+2
View File
@@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
// A project the sidebar lists, which may have no pane at all.
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
// Where a dropped file lives. Resolved here rather than in the page: the
// renderer is handed a File and never a path, and a turn that has to work on
// the file itself cannot do it on a copy of the bytes.
+15 -2
View File
@@ -23,9 +23,13 @@ function local(target, platform) {
async function locate(client, base, rel, platform) {
if (!PANE_BASE.test(base)) return { refusal: refused("not a pane this window publishes") };
const query = rel ? `?path=${encodeURIComponent(rel)}` : "";
return ask(client, `${base}/workspace/locate${query}`, platform);
}
async function ask(client, url, platform) {
let res;
try {
res = await client.request("GET", `${base}/workspace/locate${query}`);
res = await client.request("GET", url);
} catch (err) {
return { refusal: refused(err.message) };
}
@@ -54,4 +58,13 @@ async function reveal(client, shell, base, rel, platform = process.platform) {
return null;
}
module.exports = { reveal };
// A project the sidebar lists needs no pane: the hub answers for the folders
// it lists and refuses any other, so the page still names no location itself.
async function revealWorkspace(client, shell, root, platform = process.platform) {
const found = await ask(client, `/host/workspaces/locate?root=${encodeURIComponent(String(root))}`, platform);
if (found.refusal) return found.refusal;
shell.showItemInFolder(found.target);
return null;
}
module.exports = { reveal, revealWorkspace };
+7 -4
View File
@@ -1,18 +1,17 @@
"use strict";
const path = require("node:path");
const { Menu, Tray, nativeImage } = require("electron");
const { Menu, Tray, nativeImage, screen } = require("electron");
const { trayImage } = require("./trayimage.js");
// How often the fold is re-read. It is a projection the kernel recomputes on
// demand, so a missed read costs nothing the next one does not restore — which
// is why it is pulled on a timer rather than carried on the stream.
const FOLD_INTERVAL_MS = 5000;
const ICON = path.join(__dirname, "..", "assets", "tray.png");
// installTray brings the icon up and reports whether there is one. A shell with
// no icon must never background its window: hiding it would leave a running
// session with nothing left to bring it back.
function installTray(host, { onOpen, onQuit }) {
const image = nativeImage.createFromPath(ICON);
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
if (image.isEmpty()) return null;
const icon = new Tray(image);
const state = { fold: null, prefs: null };
@@ -47,6 +46,9 @@ function installTray(host, { onOpen, onQuit }) {
);
};
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
if (process.platform === "win32") screen.on("display-metrics-changed", refit);
icon.on("click", onOpen);
const timer = setInterval(() => void refresh(), FOLD_INTERVAL_MS);
@@ -68,6 +70,7 @@ function installTray(host, { onOpen, onQuit }) {
refresh,
close() {
clearInterval(timer);
screen.removeListener("display-metrics-changed", refit);
icon.destroy();
},
};
+30
View File
@@ -0,0 +1,30 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const ASSETS = path.join(__dirname, "..", "assets");
const SCALES = [
[1, ""],
[1.25, "@1.25x"],
[1.5, "@1.5x"],
[1.75, "@1.75x"],
[2, "@2x"],
[2.5, "@2.5x"],
[3, "@3x"],
];
function trayAsset(scaleFactor) {
const want = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1;
const hit = SCALES.find(([scale]) => scale >= want - 1e-6) ?? SCALES[SCALES.length - 1];
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
}
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
// platforms choose among the @Nx files themselves.
function trayImage(nativeImage, scaleFactor) {
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
}
module.exports = { trayAsset, trayImage, SCALES };
+17
View File
@@ -0,0 +1,17 @@
"use strict";
// The interface language the page will draw in, decided from the same two
// facts it uses: its saved choice (rx-lang) and, for anything but an explicit
// zh or en, the machine's first locale.
function isChinese(tag) {
const s = String(tag ?? "").toLowerCase();
return s.startsWith("zh") || s.startsWith("yue") || s.startsWith("cmn") || s.includes("hans") || s.includes("hant");
}
function uiLanguage(prefs, machineLocale) {
const want = String(prefs?.["rx-lang"] ?? "").trim().toLowerCase();
if (want === "zh" || want === "en") return want;
return isChinese(machineLocale) ? "zh" : "en";
}
module.exports = { uiLanguage };
+3 -3
View File
@@ -303,11 +303,11 @@ async function menuChecks(win, js) {
const menu = Menu.getApplicationMenu();
const roles = (menu?.items ?? []).map((i) => String(i.role).toLowerCase());
check("the application menu is installed", !!menu);
check("it carries the three menus macOS routes through", ["appmenu", "editmenu", "windowmenu"].every((r) => roles.includes(r)), roles);
check("it carries the three menus macOS routes through", ["appmenu", "windowmenu"].every((r) => roles.includes(r)) && menu.items.length === 3, roles);
const edit = (menu?.items ?? []).find((i) => String(i.role).toLowerCase() === "editmenu");
const edit = (menu?.items ?? [])[1];
const editRoles = (edit?.submenu?.items ?? []).map((i) => String(i.role).toLowerCase());
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "selectall"].every((r) => editRoles.includes(r)), editRoles);
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "pasteandmatchstyle", "delete", "selectall"].every((r) => editRoles.includes(r)), editRoles);
app.focus({ steal: true });
win.focus();
+100 -3
View File
@@ -7,7 +7,8 @@ import fs from "node:fs";
const require = createRequire(import.meta.url);
const { parse, readActs } = require("../src/host.js");
const { contextTemplate } = require("../src/editmenu.js");
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
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");
@@ -63,6 +64,68 @@ test("the context menu mirrors what the page says is possible", () => {
});
});
test("the edit menu's labels follow the interface language, not the system's", () => {
const params = { isEditable: true, selectionText: "", editFlags: {} };
const labels = (lang) => contextTemplate(params, lang).filter((i) => i.role).map((i) => i.label);
assert.deepEqual(labels("zh"), ["撤销", "重做", "剪切", "复制", "粘贴", "全选"]);
assert.deepEqual(labels("en"), ["Undo", "Redo", "Cut", "Copy", "Paste", "Select All"]);
assert.equal(editMenuTemplate("zh").label, "编辑");
});
test("the edit menu keeps everything the platform's own one carries", () => {
const roles = (items) => items.flatMap((i) => [i.role, ...(i.submenu ? roles(i.submenu) : [])]).filter(Boolean);
const want = ["undo", "redo", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll",
"showSubstitutions", "toggleSmartQuotes", "toggleSmartDashes", "toggleTextReplacement", "startSpeaking", "stopSpeaking"];
for (const lang of ["zh", "en"]) {
assert.deepEqual(roles(editMenuTemplate(lang).submenu), want);
const labels = (items) => items.flatMap((i) => [i.label, ...(i.submenu ? labels(i.submenu) : [])]).filter((l) => l !== undefined);
assert.ok(labels(editMenuTemplate(lang).submenu).every((l) => l.length > 0));
}
assert.deepEqual(editMenuTemplate("en").submenu.map((i) => i.role ?? i.type ?? i.label),
["undo", "redo", "separator", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll", "separator", "Substitutions", "Speech"]);
assert.deepEqual(applicationMenuTemplate("zh").map((i) => i.role ?? "edit"), ["appMenu", "edit", "windowMenu"]);
});
test("the application menu is rebuilt when the language changes and not otherwise", () => {
const built = [];
const Menu = {
buildFromTemplate: (t) => (built.push(t), { template: t }),
setApplicationMenu: () => {},
getApplicationMenu: () => null,
};
const install = menuInstaller(Menu, "darwin");
let lang = "en";
install(() => lang);
install(() => lang);
assert.equal(built.length, 1);
lang = "zh";
install(() => lang);
assert.equal(built.length, 2);
assert.equal(built[1][1].label, "编辑");
let cleared = 0;
menuInstaller({ ...Menu, setApplicationMenu: () => cleared++ }, "linux")(() => "zh");
assert.equal(cleared, 1);
assert.equal(built.length, 2);
});
test("the interface language is the page's own choice, else the machine's", () => {
assert.equal(uiLanguage({ "rx-lang": "zh" }, "en-US"), "zh");
assert.equal(uiLanguage({ "rx-lang": "en" }, "zh-CN"), "en");
assert.equal(uiLanguage({ "rx-lang": "" }, "zh-Hans-CN"), "zh");
assert.equal(uiLanguage({}, "fr-FR"), "en");
assert.equal(uiLanguage(undefined, undefined), "en");
});
test("a saved preference tells the shell so it can follow it", () => {
const handlers = {};
const ipc = { on: (name, fn) => { handlers[name] = fn; } };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-prefs-"));
let saved = 0;
registerPrefs(ipc, () => path.join(dir, "p.json"), () => true, () => { saved++; });
handlers["prefs:save"]({ returnValue: null }, { "rx-lang": "zh" });
assert.equal(saved, 1);
});
test("only http and https ever reach the platform opener", () => {
assert.equal(externalTarget("https://example.test/a"), "https://example.test/a");
assert.equal(externalTarget("http://example.test/"), "http://example.test/");
@@ -124,7 +187,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
assert.equal(await dead.trayState(), null);
});
const { reveal } = require("../src/reveal.js");
const { reveal, revealWorkspace } = require("../src/reveal.js");
// A kernel that answers /workspace/locate as the test says, and a shell that
// only records what it was asked to open.
@@ -143,7 +206,7 @@ async function revealRig(answer, platform = process.platform) {
openPath: async (p) => (opened.push(["open", p]), ""),
showItemInFolder: (p) => opened.push(["select", p]),
};
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), close: () => server.close() };
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), workspace: (root) => revealWorkspace(client, shell, root, platform), close: () => server.close() };
}
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
@@ -194,6 +257,22 @@ test("the page cannot steer reveal to a location the kernel did not name", async
}
});
test("a listed project is shown through the hub and only on its answer", async () => {
const rig = await revealRig((url) =>
url.pathname === "/host/workspaces/locate" && url.searchParams.get("root") === "/work/a b"
? [200, { path: path.join(ROOT, "a b"), dir: true }]
: [404, { code: "workspace.not_listed", error: "not listed" }],
);
try {
assert.equal(await rig.workspace("/work/a b"), null);
assert.equal((await rig.workspace("/etc")).code, "workspace.not_listed");
assert.deepEqual(rig.opened, [["select", path.join(ROOT, "a b")]]);
assert.equal(rig.asked[0], "/host/workspaces/locate?root=%2Fwork%2Fa%20b");
} finally {
rig.close();
}
});
// A root that is an .app bundle, or a link to one, is a directory to stat and
// an application to `open`: handing it to openPath would launch it.
test("the workspace root is selected, never opened", async () => {
@@ -1177,6 +1256,7 @@ function loadShell({ lock, host }) {
if (key === "getPath") return () => userData;
if (key === "getVersion") return () => "9.9.9";
if (key === "getLocale") return () => "en-US";
if (key === "getPreferredSystemLanguages") return () => ["en-US"];
if (key === "isPackaged") return false;
if (key === "quit") return () => { calls.push(["quit"]); quit(); };
return inert;
@@ -1245,3 +1325,20 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
shell.cleanup();
}
});
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
const { trayAsset, SCALES } = require("../src/trayimage.js");
const dim = (f) => {
const b = fs.readFileSync(f);
return [b.readUInt32BE(16), b.readUInt32BE(20)];
};
for (const [scale] of SCALES) {
const { file, pixels } = trayAsset(scale);
assert.deepEqual(dim(file), [pixels, pixels], file);
assert.equal(pixels, Math.round(16 * scale));
}
assert.equal(trayAsset(1.75).pixels, 28);
assert.equal(trayAsset(1.8).pixels, 32);
assert.equal(trayAsset(5).pixels, 48);
assert.equal(trayAsset(NaN).pixels, 16);
});
+1
View File
@@ -46,6 +46,7 @@ node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
node perf/locale.mjs # 跟随系统语言:中文各写法都归中文,其余归英文
+4 -2
View File
@@ -45,11 +45,12 @@ class BenchPort extends MockPort {
}
async providerSetup() {
return null;
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
}
async appearance() {
const look = await super.appearance();
let look = await super.appearance();
if (query.has("zoom")) look = { ...look, zoom: Number(query.get("zoom")) };
return PREF === null ? look : { ...look, language: PREF };
}
@@ -149,6 +150,7 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
// ?pref= 是「内核记着的语言」。真机上它来自 config;这里由地址给,好让
// 一次验证能把两侧摆成同一个值——否则 adopt 会认为本地缓存过期。
const PREF = query.get("pref");
const ONBOARDING = query.has("onboarding");
const SESSIONS = Number(query.get("sess") ?? 0);
// ?turns= is how long the conversation being opened already is.
const TURNS = Number(query.get("turns") ?? 0);
+108
View File
@@ -0,0 +1,108 @@
// The first-run "start" button must be reachable at every window height.
// The body clips, so a card taller than the window is only reachable if the
// stage itself scrolls. Focus is no evidence: the browser scrolls the clipped
// body to a focused element, so the wheel is what gets asserted.
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?onboarding=1";
const SHOTS = process.env.ONBOARDING_SHOTS;
const SIZES = [
[1920, 700], [1600, 600], [2560, 560], [1366, 600], [1280, 540], [1440, 900], [1600, 760, 1.25],
[760, 480], [761, 480], [760, 480, 0.8], [761, 480, 1.8], [800, 480, 1.8], [840, 560, 1.8],
[840, 560, 1.5], [1280, 720, 1.8], [1600, 900, 1.8, [840, 560]], [840, 560, 1.8, [1600, 900]],
];
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
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();
for (const scheme of ["light", "dark"]) {
for (const [width, height, zoom = 1, resize] of SIZES) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
const url = new URL(PAGE);
url.searchParams.set("zoom", String(zoom));
await page.goto(url.href, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".onb-card");
await page.waitForFunction((z) => Number(getComputedStyle(document.documentElement).zoom) === z, zoom);
const tag = `${scheme} ${width}x${height}${zoom !== 1 ? ` zoom${zoom}` : ""}${resize ? ` to ${resize.join("x")}` : ""}`;
const fitsCard = async (label) => {
const layout = await page.evaluate(() => {
const stage = document.querySelector(".onb-stage");
const shell = document.querySelector(".onb-shell");
const r = shell.getBoundingClientRect();
const fields = ["#onb-url", "#onb-key", ".onb-go", ".onb-note", ".onb-protocols", ".onb-pick"];
const clipped = fields.flatMap((selector) => {
const el = document.querySelector(selector);
if (!el) return [];
const box = el.getBoundingClientRect();
return box.left < Math.max(0, r.left) - 0.5 || box.right > Math.min(innerWidth, r.right) + 0.5
? [{ selector, left: box.left, right: box.right, shellRight: r.right }] : [];
});
return { clipped, columns: getComputedStyle(shell).gridTemplateColumns.split(" ").length,
expectedColumns: stage.clientWidth <= 760 ? 1 : 2, width: stage.scrollWidth, client: stage.clientWidth };
});
check(`${tag} ${label}: form stays within the card and viewport`, layout.clipped.length === 0, JSON.stringify(layout.clipped));
check(`${tag} ${label}: columns follow zoom-adjusted room`, layout.columns === layout.expectedColumns, JSON.stringify(layout));
check(`${tag} ${label}: stage does not scroll sideways`, layout.width <= layout.client + 1);
};
const reachable = async (label, enabled) => {
const viewport = page.viewportSize();
const go = page.locator(".onb-go");
await page.evaluate(() => document.activeElement?.blur());
await page.mouse.move(viewport.width / 2, viewport.height / 2);
await page.mouse.wheel(0, 4000);
await page.waitForTimeout(250);
const box = await go.boundingBox();
check(`${tag} ${label}: after wheeling to the bottom the button is in the viewport`, !!box && box.y >= 0 && box.y + box.height <= viewport.height + 0.5, JSON.stringify(box));
const bar = await page.locator(".onb-brandbar").boundingBox();
check(`${tag} ${label}: the title bar stays in the viewport`, !!bar && bar.y >= -0.5 && bar.y + bar.height <= viewport.height + 0.5, JSON.stringify(bar));
if (!enabled) return;
const hit = await page.evaluate(() => {
const r = document.querySelector(".onb-go").getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return !!el?.closest(".onb-go");
});
check(`${tag} ${label}: the point under its centre hits it`, hit);
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${tag.replaceAll(" ", "-")}-${label.replaceAll(" ", "-")}.png`) });
const first = page.locator(".onb-chip").first();
await page.evaluate(() => document.activeElement?.blur());
await first.focus();
await page.waitForTimeout(250);
const [fb, bb] = [await first.boundingBox(), await page.locator(".onb-brandbar").boundingBox()];
check(`${tag} ${label}: a focused top-of-card element sits below the title bar`, !!fb && !!bb && fb.y >= bb.y + bb.height - 0.5, JSON.stringify({ fb, bb }));
};
await fitsCard("before connect");
await reachable("before connect", false);
await page.fill('input[placeholder="sk-…"]', "sk-test");
await page.locator(".onb-go").click();
await page.waitForSelector(".onb-found");
await fitsCard("after connect");
await reachable("after connect", true);
if (resize) {
await page.setViewportSize({ width: resize[0], height: resize[1] });
await page.waitForFunction(() => {
const stage = document.querySelector(".onb-stage");
return Math.abs(stage.getBoundingClientRect().width - innerWidth) < 1;
});
await fitsCard("after resize");
await reachable("after resize", true);
}
await page.locator(".onb-go").click();
await page.waitForSelector(".app .chrome");
check(`${tag}: Start finishes setup`, await page.locator(".onb-stage").count() === 0);
await ctx.close();
}
}
await browser.close();
console.log(fails.length ? `\n${fails.length} failed` : "\nall passed");
process.exit(fails.length ? 1 : 0);
+16
View File
@@ -95,6 +95,10 @@ export const ACTIONS: UIAction[] = [
{ id: "tool.image-open", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.image-close", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "agent.open", kind: "view", target: "entity", proof: "interaction" },
{ id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
{ id: "agent.close", kind: "view", target: "none", proof: "interaction" },
// ── The turn ─────────────────────────────────────────────────────────────
// Send and stop remain two actions even when they sit together: during a
@@ -151,6 +155,14 @@ export const ACTIONS: UIAction[] = [
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "community.join", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.discord", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.issues", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.contributors", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.profile", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" },
{ id: "community.author", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
@@ -244,6 +256,7 @@ export const ACTIONS: UIAction[] = [
{ id: "market.mine-retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.submit", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.vote", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.vote-retry", kind: "view", target: "none", proof: "interaction" },
// Taking back the innermost open thing: a popover, an inline form. One
// intent, reached by pressing away and by Escape.
{ id: "layer.dismiss", kind: "navigation", target: "none", proof: "interaction" },
@@ -281,6 +294,7 @@ export const ACTIONS: UIAction[] = [
// state — which is why it is one id and why it is not a mutation.
{ id: "external.open", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -295,6 +309,8 @@ export const ACTIONS: UIAction[] = [
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
// ── Taking a change back ─────────────────────────────────────────────────
// Planning a revert and performing one are two contracts: the first asks the
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="296" class="segno"><g transform="scale(8)"><path fill="#fff" d="M0 0h37v37h-37z"/><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m1 0h1m2 0h1m2 0h1m1 0h2m1 0h7m-29 1h1m5 0h1m5 0h1m3 0h2m2 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m6 0h1m2 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m11 0h1m1 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h5m2 0h3m1 0h1m2 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-20 1h1m1 0h3m1 0h3m-18 1h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m1 0h1m1 0h2m1 0h1m1 0h1m-21 1h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h4m1 0h1m1 0h1m2 0h1m2 0h1m-29 1h4m1 0h4m1 0h2m1 0h3m2 0h2m3 0h5m-28 1h4m4 0h1m3 0h2m2 0h1m9 0h2m-28 1h2m2 0h1m1 0h1m1 0h5m1 0h2m1 0h1m1 0h2m1 0h2m1 0h1m1 0h2m-28 1h1m1 0h2m3 0h1m2 0h1m1 0h4m1 0h2m1 0h1m-22 1h2m1 0h2m1 0h1m1 0h1m1 0h1m1 0h3m3 0h1m1 0h2m1 0h1m1 0h4m-28 1h2m1 0h2m2 0h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m-28 1h4m2 0h2m1 0h3m1 0h1m3 0h3m1 0h1m5 0h1m-27 1h2m2 0h1m1 0h1m2 0h1m1 0h5m1 0h1m2 0h2m2 0h1m2 0h1m-29 1h1m1 0h2m1 0h6m3 0h5m3 0h3m2 0h2m-27 1h2m4 0h1m1 0h2m2 0h1m2 0h1m1 0h2m3 0h1m2 0h2m-29 1h1m2 0h1m2 0h1m2 0h1m5 0h3m1 0h6m1 0h1m-19 1h2m1 0h2m1 0h2m1 0h4m3 0h1m1 0h3m-29 1h7m2 0h1m2 0h1m4 0h2m1 0h1m1 0h1m1 0h1m2 0h1m-28 1h1m5 0h1m1 0h2m1 0h1m2 0h2m3 0h2m3 0h5m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m3 0h3m1 0h5m2 0h1m-28 1h1m1 0h3m1 0h1m1 0h2m2 0h2m1 0h2m1 0h2m2 0h5m1 0h1m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m1 0h1m2 0h4m3 0h3m1 0h1m-29 1h1m5 0h1m2 0h1m2 0h1m1 0h1m2 0h1m1 0h2m1 0h1m4 0h1m-28 1h7m1 0h1m3 0h2m2 0h2m2 0h3m1 0h1m2 0h1"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,141 @@
[
"SivanCola",
"KHG420",
"ttmouse",
"lifu963",
"HUQIANTAO",
"GTC2080",
"mchenziyi",
"Li-Charles-One",
"light-front-theory",
"eghrhegpe",
"Bernardxu123",
"HaoyueQin",
"BuGlessRB",
"chuiwenwei",
"CVEngineer66",
"JesonChou",
"lanshi17",
"SuMuxi66",
"paradoxSCH",
"CnsMaple",
"cyq1017",
"Guan0923",
"ipanta",
"XTLine",
"YoungDan-hero",
"erphenheimer",
"SauronSkywalker",
"ashishexee",
"lizhengwu",
"par73e",
"yizeyi18",
"Nath-Vikky",
"PMKang",
"qiaone",
"ferstar",
"Harbor404",
"liaoyl830",
"Lxiny-zy",
"zhangsanouo",
"AtmoOmen",
"codex",
"Diarica",
"ffff2004",
"ikelvingo",
"kekexunxun",
"lightfront",
"maoxin1234",
"nostalgia296",
"onionviolet",
"PaulDing98",
"scrappylabsai",
"skyzhao1223",
"Tatlatat",
"yekern",
"assoyuan",
"billycat",
"boscocp",
"closss",
"drafish",
"HBLADEH",
"heshuimu",
"iysun",
"jackie-cqz",
"LVGG",
"nanami7777777",
"qiukeren",
"sudoviber",
"warvyvr",
"whale-fall-ouo",
"wqshan-cn",
"xianyu-sheng",
"Xio-Shark",
"xxoingr",
"yhemeo613",
"zdjmrq",
"Abysstsuki",
"ahmedalbusaidy",
"Akashmdshetty",
"alxbnct",
"c020627",
"chengoak",
"CooperSheroy",
"dcs02280653-collab",
"dengbushi",
"EMEEEEMMMM",
"ERroooooR",
"gcoder1991",
"h4rk8s",
"HHHEEEWWW",
"hobostay",
"HorusJiang",
"i1j",
"innocarpe",
"j2bap",
"jackijianxa",
"jbts6",
"ji-tz",
"JimmyDaddy",
"kabaka9527",
"kate1128",
"LegGasai",
"leslie3747",
"LouF666",
"MagnumGoYB",
"MaitreyeeDeshmukh",
"myipanta",
"nanami-0713",
"Ninot1Quyi",
"NkAntony777",
"no-name-touse",
"Nontee22",
"onenewcode",
"Oxygen56",
"PorunC",
"Q1hangL",
"QingLong-C",
"Ray-lyy",
"rixzkiye",
"Robertoarce",
"ryukeilee",
"seankhliao",
"ShiroKSH",
"siwenyu666-beep",
"SprayHank",
"Starry0214",
"Surfire",
"taibai233",
"themarchrain",
"tic-top",
"weikengchen",
"wudiandy",
"xiaoshuai1024",
"xiaotaoya2022-cell",
"xinrenH",
"ying-hua",
"yuanchenglu",
"zhuguang-ZFG",
"Zhuocheng-Lang",
"zlwu"
]
+16
View File
@@ -17,11 +17,13 @@ import { EN_USAGE } from "./en_usage";
import { EN_COMPOSER } from "./en_composer";
import { EN_BACKUP } from "./en_backup";
import { EN_FEEDBACK } from "./en_feedback";
import { EN_COMMUNITY } from "./en_community";
export const EN: Record<string, string> = {
"工作台": "Workbench",
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
...EN_SETTINGS,
...EN_COMMUNITY,
...EN_METRICS,
...EN_STORAGE,
...EN_USAGE,
@@ -69,6 +71,7 @@ export const EN: Record<string, string> = {
"复制": "Copy",
"已复制": "Copied",
"复制不了": "Copy blocked",
"复制失败,请重试": "Copy failed. Try again.",
"复制这段回答": "Copy this answer",
"复制这段代码": "Copy this code",
"复制路径": "Copy path",
@@ -278,6 +281,10 @@ export const EN: Record<string, string> = {
"尚无文件夹": "No folders yet",
"正在读取文件夹…": "Reading folders…",
"打开项目…": "Open a project…",
"添加工作区": "Add a workspace",
"无法打开文件夹选择器。请输入内核所在机器上的路径。": "The folder picker cannot open. Enter a path on the machine running the kernel.",
"工作区路径": "Workspace path",
"此内核不支持添加工作区。": "This kernel does not support adding workspaces.",
"先打开一个项目": "Open a project first",
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
"使用当前位置": "Stay where I am",
@@ -333,6 +340,11 @@ export const EN: Record<string, string> = {
"正在运行,暂停后才能关闭": "Running; pause it before closing",
"受阻": "blocked",
"状态不明": "uncertain",
"这条插话在本轮结束前没来得及送达,没有发给模型": "This guidance was not delivered before the turn ended, so the model never saw it",
"本轮已结束,但对话记录没能保存": "The turn ended, but the transcript could not be saved",
"本轮已结束,但队列没能确认这一条": "The turn ended, but the queue could not acknowledge this item",
"运行这一条的会话已不在了": "The session that was running this item is gone",
"队列记录损坏,这一条是从残留文件里找回的": "The queue record was corrupt; this item was salvaged from leftover files",
"冻结 {n} 文件": "{n} frozen",
"无法读取该条的正文,未打开编辑:{why}":
"Could not read this entry back, so it was not opened for editing: {why}",
@@ -1092,6 +1104,8 @@ export const EN: Record<string, string> = {
"项目操作:{name}": "Project actions: {name}",
"项目操作": "Project actions",
"不删除文件": "Keeps files",
"在文件管理器中显示": "Show in file manager",
"文件夹已不在磁盘上": "Folder is gone",
"删除会话:{title}": "Delete session: {title}",
"会话在文件夹里打开,先添加一个": "A session opens in a folder; add one first",
"添加文件夹": "Add folder",
@@ -1215,5 +1229,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",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
};
@@ -0,0 +1,20 @@
export const EN_COMMUNITY: Record<string, string> = {
"社区与贡献者": "Community and contributors",
"遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。": "Join the community for help, tips and ideas. Below are the people who have contributed code to Reasonix.",
"QQ 群二维码": "QQ group QR code",
"群号": "Group number",
"复制群号": "Copy group number",
"用 QQ 扫描二维码,或点击下方按钮加入。": "Scan the QR code with QQ, or use the button below to join.",
"加入 QQ 群": "Join the QQ group",
"贡献者": "Contributors",
"按提交数排序,名单随版本更新": "Sorted by commits; the list is refreshed with each release",
"查看全部贡献者": "View all contributors",
"显示全部 {n} 位": "Show all {n}",
"抖音": "Douyin",
"抖音号": "Douyin ID",
"复制抖音号": "Copy Douyin ID",
"显示二维码": "Show QR code",
"收起二维码": "Hide QR code",
"抖音二维码": "Douyin QR code",
"作者与维护者": "Author and maintainer",
};
@@ -21,6 +21,7 @@ export const EN_COMPOSER: Record<string, string> = {
"正在加载命令与技能…": "Loading commands and skills…",
"引用文件": "Reference files",
"Enter 插话 · Shift+Enter 换行": "Enter to steer · Shift+Enter for a new line",
"点按插话 · 回车换行": "Tap Steer · Enter for a new line",
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
"知道了": "Got it",
"是否信任此文件夹": "Trust this folder",
@@ -66,6 +67,7 @@ export const EN_COMPOSER: Record<string, string> = {
"Nobody has said whether this model reads images · treated as no for now; tick it under Connection to send them straight through",
"自动": "Auto",
"Enter 发送 · Shift+Enter 换行": "Enter to send · Shift+Enter for a new line",
"点按发送 · 回车换行": "Tap Send · Enter for a new line",
"不询问": "Do not ask",
"全部放行": "Allow all",
"均衡": "Balanced",
@@ -97,7 +97,8 @@ export const EN_FEEDBACK: Record<string, string> = {
"有一张截图没能清除其中的隐藏信息。请换一张,或重新导出后再添加。": "A screenshot could not be cleaned of its hidden metadata. Pick another one, or re-export it and add it again.",
"反馈通道暂时关闭。你可以直接到 GitHub 提交问题。": "The feedback channel is switched off for now. You can file an issue on GitHub directly.",
"反馈服务没有认出这台电脑,请稍后重试。": "The feedback service did not recognise this computer. Please try again later.",
"反馈服务暂时出了问题,不是你的操作有误。稍后再试即可。": "The feedback service is having trouble; this is not something you did. Try again later.",
"反馈服务暂时出了问题。已填的内容都还在,请稍等片刻再试,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. What you typed is kept. Try again in a moment, or file an issue on GitHub directly.",
"反馈服务暂时出了问题。你写的回复还在,请稍等片刻;重发前先刷新列表确认是否送达,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. Your reply is kept. Wait a moment, then refresh the list to check whether it arrived before resending, or file an issue on GitHub directly.",
"本机保存反馈记录时出错,请重试。": "Saving the feedback record on this machine failed. Please try again.",
"反馈没有发出去,请稍后重试。": "The feedback was not sent. Please try again later.",
"需要补充信息": "Needs more info",
+14 -1
View File
@@ -133,7 +133,7 @@ export const EN_KERNEL: Record<string, string> = {
"任务正在运行,请先停止再重载扩展": "A task is running — stop it before reloading extensions",
"该来源正在使用中,请先切换模型再删除": "This source is in use — switch models before deleting it",
"请为该来源填写名称": "Give this source a name",
"名称只能包含字母、数字、点、连字符和下划线": "A name may use letters, digits, dot, dash and underscore only",
"名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符": "Use only letters, digits, dots, hyphens and underscores, starting with a letter or digit, up to 64 characters",
"已经有名为「{name}」的连接了,换一个名称": "A connection named “{name}” already exists; pick another name",
"读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试": "The configuration could not be read, so there is no safe place to store the key; check the config and try again",
"请填写接口地址": "Enter an endpoint address",
@@ -163,6 +163,8 @@ export const EN_KERNEL: Record<string, string> = {
"无法在 {ip} 上开启监听:{error}": "Could not listen on {ip}: {error}",
"没有这台已配对的设备,可能已被移除。": "No paired device has that id; it may already have been removed.",
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
"这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.",
"项目文件夹已不在磁盘上。": "The project folder is no longer on disk.",
"这个内核不在本机,没法在系统文件管理器中显示它的文件。": "This kernel is not on this machine, so its files cannot be shown in the system file manager.",
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
@@ -172,6 +174,16 @@ export const EN_KERNEL: Record<string, string> = {
"该 key 长度异常,可能粘贴了错误内容": "That key is too long — something else was probably pasted",
"已连接,无需重复配置": "Already connected — no need to set it up again",
"远端配置未完成,请稍后重试": "The remote setup did not complete; try again shortly",
"没有这个模型连接": "There is no such model connection",
"该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env": "This connection has no variable to store a key in — set its api_key_env in the configuration first",
"该 key 超长或含换行,请重新复制": "That key is too long or spans several lines — copy it again",
"已保存的密钥在此期间被改动,请重新打开配置再试": "The stored keys changed in the meantime — reopen the setup and try again",
"密钥已保存,但连接尚未生效,请重试": "The key was saved but the connection is not active yet — try again",
"服务商拒绝了该 key,请检查是否复制完整": "The provider rejected that key — check it was copied in full",
"服务商没有及时应答,请稍后重试": "The provider did not answer in time — try again shortly",
"连不上服务商,请检查网络或服务地址": "Cannot reach the provider — check the network or the service address",
"服务商返回了错误,请稍后重试": "The provider returned an error — try again shortly",
"连接测试未通过": "The connection test did not pass",
"这台服务器已关闭会话切换": "This server has session switching turned off",
"该会话正在清理,请稍后再打开": "This session is being cleaned up; open it again in a moment",
"该会话正被其他位置占用,请先关闭该处": "This session is held elsewhere — close it there first",
@@ -242,6 +254,7 @@ export const EN_KERNEL: Record<string, string> = {
"这份备份已不存在": "That backup no longer exists",
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
"不存在该预设": "There is no such preset",
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
+15 -2
View File
@@ -346,6 +346,7 @@ export const EN_SETTINGS: Record<string, string> = {
"Use this after editing extension code, or installing, removing, enabling or disabling a package. The current turn is unaffected; the next one uses the new configuration.",
"正在重启常驻进程,重新扫描技能、命令和钩子…": "Restarting resident processes and rescanning skills, commands and hooks…",
"已生效,下一轮开始用新的扩展": "Live — the next turn runs the new extensions",
"更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。": "Changes saved, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
"插件包": "Plugin packages",
"一个包可以同时提供技能、命令、自动化钩子和外部服务。安装与导入是同一个操作:提供一个仓库地址,或本机的一个文件夹。":
"One package can bring skills, commands, automation hooks and external services at once. Installing and importing are the same action: give it a repository address or a folder on this machine.",
@@ -431,6 +432,14 @@ export const EN_SETTINGS: Record<string, string> = {
"上次填写未通过:{note}": "The last answer was not accepted: {note}",
"填写这一项": "Fill this in",
"已提交给 {name}": "Sent to {name}",
"MCP 服务器 {name} 请求外部操作": "MCP server {name} is asking for an external interaction",
"请在自己的浏览器中完成操作,完成后再继续。打开页面不会自动确认,也不会把浏览器内容交给 agent。": "Complete the interaction in your own browser, then continue. Opening the page does not confirm completion or share its contents with the agent.",
"此链接指向本机或私有网络。打开前请确认你信任该服务器和目标地址。": "This link targets this device or a private network. Open it only if you trust the server and the destination.",
"请核对完整地址:此链接使用 HTTP 或国际化域名。": "Check the full address: this link uses HTTP or an internationalized domain.",
"无法打开浏览器。请重试,或手动打开上面的地址。": "Could not open the browser. Retry, or open the address above manually.",
"已确认完成": "Completion confirmed",
"打开浏览器": "Open browser",
"已完成,继续": "Completed, continue",
"已拒绝提供": "Declined",
"拒绝提供": "Decline",
"(补写过一次)": " (repaired once)",
@@ -473,6 +482,7 @@ export const EN_SETTINGS: Record<string, string> = {
" · 缺 key": " · no key",
"安装这个版本": "Install this version",
"保存中…": "Saving…",
"无法保存": "Could not save",
"不发送": "Do not send",
"当前版本": "Current version",
"读取中…": "Reading…",
@@ -617,8 +627,11 @@ export const EN_SETTINGS: Record<string, string> = {
"在浏览器里打开的页面输入这串代码。没自动打开就手动访问 {uri}":
"Type this code into the page your browser opened. If none opened, go to {uri}",
"装好了,下一轮就能用": "Installed — the next turn can use it",
"装好了,但这一轮还在跑:等它结束或新建会话后生效":
"Installed, but this turn is still running: it takes effect when the turn ends, or in a new session",
"装好了,但运行时未重载:{reason}。请用「重载运行时」重试。":
"Installed, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
"运行时未重载:{reason}。请用「重载运行时」重试。":
"The runtime did not reload: {reason}. Use Reload runtime to retry.",
"有项目未安装成功,原因见下方。": "Not all items were installed. See the failures below.",
"没装上": "Not installed",
"这个地址上已经有「{name}」了。留空 key": "This address already holds “{name}”. Leave the key blank",
"已就位 · {n} 个工具,下一轮就能用": "In place · {n} tools, ready next turn",
+2 -2
View File
@@ -21,8 +21,8 @@ export const EN_STORAGE: Record<string, string> = {
"目录过大,已统计到时间上限,实际占用不小于此数": "This folder is too large to finish measuring in time; the real size is at least this much.",
占用: "Space used",
原位置仍有残留数据: "Data remains in the previous location",
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此本机的壁纸、主题包或更新回滚备份可能显示为缺失。手动将这些目录复制到当前位置即可恢复。":
"These are still in {dir}: {names}. A move left them behind, so a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. Copying those folders into the current location restores them.",
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此项目列表、全局指令与记忆、计划任务、壁纸、主题包或更新回滚备份可能显示为缺失。下次启动会自动并入当前位置;若仍显示,请退出后把这些文件或目录复制到当前位置,已有的文件不要覆盖。":
"These are still in {dir}: {names}. A move left them behind, so the project list, global instructions and memory, scheduled runs, a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. The next launch merges them into the current location; if they are still listed, quit and copy them there without overwriting files that already exist.",
位置: "Locations",
迁移: "Migrate",
"迁移未能启动。": "The move could not be started.",
@@ -14,6 +14,12 @@ export const EN_WINDOW: Record<string, string> = {
"运行详情": "Runtime details",
"查看全部指标": "View all metrics",
"子代理": "Subagents",
"查看完整记录": "Open full transcript",
"查看完整记录:{name}": "Open full transcript: {name}",
"子代理完整记录:{name}": "Full transcript of subagent: {name}",
"还没有记录到步骤": "No steps recorded yet",
"显示全部": "Show all",
"输出过长,仅显示前 200000 个字符": "Output too long, showing the first 200000 characters",
"成本构成": "Cost breakdown",
"计划待确认": "The plan is yours to decide",
"计划已生成,如何执行由你决定。": "The plan is written. What happens next is your call.",
@@ -31,6 +31,14 @@ describe("what a reader is told a refusal was", () => {
// The gate the codes were for. The kernel rewords its own English whenever a
// sentence reads better in a log; if that moves what a reader sees, wording
// authority never actually left the kernel.
it("explains the project limit in Chinese and English", () => {
const error = coded("storage limit", "workspace.limit_reached");
expect(reason(error)).toBe("项目列表已满(32 个),请先移除一个项目再添加");
localStorage.setItem(STORAGE, "en");
boot();
expect(reason(error)).toBe("The project list is full (32 projects); remove one before adding another");
});
it("does not move when the kernel rewords the same code", () => {
const before = reason(coded("inbox item not found", "inbox.not_found"));
const after = reason(coded("no such entry in this session's inbox", "inbox.not_found"));
+14 -1
View File
@@ -42,7 +42,7 @@ const SAID: Record<string, string> = {
// ── 来源:填错了什么 ─────────────────────────────────────────────
"provider.name_required": "请为该来源填写名称",
"provider.name_invalid": "名称只能包含字母、数字、点、连字符和下划线",
"provider.name_invalid": "名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符",
"provider.name_taken": "已经有名为「{name}」的连接了,换一个名称",
"provider.config_unreadable": "读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试",
"provider.endpoint_required": "请填写接口地址",
@@ -104,6 +104,7 @@ const SAID: Record<string, string> = {
"backup.not_found": "这份备份已不存在",
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"settings.unknown_preset": "不存在该预设",
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
@@ -136,6 +137,8 @@ const SAID: Record<string, string> = {
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
"editor.launch_failed": "编辑器没能启动:{error}",
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
"workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。",
"workspace.folder_missing": "项目文件夹已不在磁盘上。",
"workspace.locate_no_window": "这个内核不在本机,没法在系统文件管理器中显示它的文件。",
"device.host_only": "这项操作只能在电脑上的窗口里做,已配对的手机做不了。",
"device.host_rejected": "这个地址不是本机共享的地址,请重新扫码。",
@@ -157,6 +160,16 @@ const SAID: Record<string, string> = {
"provider.key_too_large": "该 key 长度异常,可能粘贴了错误内容",
"provider.setup_done": "已连接,无需重复配置",
"provider.setup_failed": "远端配置未完成,请稍后重试",
"provider.unknown": "没有这个模型连接",
"provider.no_key_slot": "该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env",
"provider.key_invalid": "该 key 超长或含换行,请重新复制",
"provider.credentials_changed": "已保存的密钥在此期间被改动,请重新打开配置再试",
"provider.activation_failed": "密钥已保存,但连接尚未生效,请重试",
"provider.test_auth": "服务商拒绝了该 key,请检查是否复制完整",
"provider.test_timeout": "服务商没有及时应答,请稍后重试",
"provider.test_unreachable": "连不上服务商,请检查网络或服务地址",
"provider.test_upstream": "服务商返回了错误,请稍后重试",
"provider.test_failed": "连接测试未通过",
"memory.unavailable": "该会话未启用记忆",
@@ -16,6 +16,9 @@ export const NOTICE_TEXT: Record<string, string> = {
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
@@ -0,0 +1,9 @@
// Why a held queue entry stopped, keyed by the kernel's block code. A code with
// no entry shows the kernel's own diagnostic.
export const BLOCK_WHY: Record<string, string> = {
steer_unapplied: "这条插话在本轮结束前没来得及送达,没有发给模型",
turn_snapshot_failed: "本轮已结束,但对话记录没能保存",
turn_ack_failed: "本轮已结束,但队列没能确认这一条",
owner_inactive: "运行这一条的会话已不在了",
manifest_salvaged: "队列记录损坏,这一条是从残留文件里找回的",
};
+1
View File
@@ -7,6 +7,7 @@ import "./styles/tokens.css";
import "./styles/app.css";
import "./styles/studio.css";
import "./styles/feedback.css";
import "./styles/community.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
+13 -3
View File
@@ -19,7 +19,7 @@ export interface HostPort {
toggleMaximiseWindow(): void;
isWindowMaximised(): Promise<boolean>;
closeWindow(): void;
openExternal(url: string): void;
openExternal(url: string): void | Promise<void>;
/** Where dropped files live. Empty where the shell cannot say — a browser
* tab never learns a path. */
pathsForFiles(files: File[]): string[];
@@ -38,6 +38,9 @@ export interface HostPort {
* root. The shell asks the kernel where that is and selects only the answer;
* null once shown, otherwise why it was not. */
revealPath(base: string, path: string): Promise<Refusal | null>;
/** Show a project the sidebar lists, which needs no pane. The hub answers
* where it is and refuses any folder it does not list. */
revealWorkspace(root: string): Promise<Refusal | null>;
/** Whether this shell draws the agent's browser pages inside the window. */
drawsBrowserViews(): boolean;
/** Draw one of the agent's pages over rect, in on-screen coordinates, and
@@ -127,6 +130,7 @@ interface ElectronBridge {
saveBytes(name: string, bytes: Uint8Array): Promise<string>;
pickFolder(startIn: string): Promise<string>;
revealPath?(base: string, path: string): Promise<Refusal | null>;
revealWorkspace?(root: string): Promise<Refusal | null>;
showBrowserView?(target: string, rect: ViewRect): Promise<void>;
hideBrowserView?(): Promise<void>;
freezeBrowserView?(): Promise<string>;
@@ -169,7 +173,7 @@ class ElectronHost implements HostPort {
void this.api.closeWindow();
}
openExternal(url: string) {
void this.api.openExternal(url);
return this.api.openExternal(url);
}
pathsForFiles(files: File[]) {
// Resolved one at a time because that is the shape the platform offers;
@@ -186,11 +190,14 @@ class ElectronHost implements HostPort {
return this.api.pickFolder(startIn);
}
revealsFiles() {
return typeof this.api.revealPath === "function";
return typeof this.api.revealPath === "function" && typeof this.api.revealWorkspace === "function";
}
revealPath(base: string, path: string) {
return this.api.revealPath?.(base, path) ?? Promise.resolve({ error: "this shell cannot show files" });
}
revealWorkspace(root: string) {
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
}
// A shell older than the verbs has no views to draw, and says so by lacking them.
drawsBrowserViews() {
return typeof this.api.showBrowserView === "function";
@@ -257,6 +264,9 @@ class BrowserHost implements HostPort {
revealPath() {
return Promise.resolve({ error: "a browser tab cannot show files" });
}
revealWorkspace() {
return Promise.resolve({ error: "a browser tab cannot show files" });
}
drawsBrowserViews() {
return false;
}
@@ -0,0 +1,14 @@
import { afterEach, expect, it, vi } from "vitest";
async function hostOver(bridge: Record<string, unknown>) {
vi.resetModules();
vi.stubGlobal("window", { reasonixHost: { shell: "electron", platform: "darwin", titleBar: false, ...bridge } });
return (await import("./host")).host();
}
afterEach(() => vi.unstubAllGlobals());
it("offers reveal only when the shell can show both a pane's file and a project", async () => {
expect((await hostOver({ revealPath: async () => null, revealWorkspace: async () => null })).revealsFiles()).toBe(true);
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
});
@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { SseHub } from "./hub";
import { HttpError } from "./port";
@@ -47,3 +48,29 @@ describe("a failed hub call", () => {
expect(err.detailed).toBe(false);
});
});
function jsonAnswer(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
json: async () => body,
headers: new Map([["content-type", "application/json"]]),
};
}
describe("host capabilities", () => {
it("does not ask a kernel picker the capability probe says is absent", async () => {
const calls: Array<[string, string]> = [];
vi.stubGlobal("fetch", async (path: string, init?: RequestInit) => {
calls.push([init?.method ?? "GET", path]);
if (path === "/host/capabilities") {
return jsonAnswer(200, { pickFolder: false, addWorkspace: true });
}
return answer(501, JSON.stringify({ code: "picker.unsupported", error: "native folder picker is unavailable" }));
});
expect(await new SseHub().hostCapabilities()).toEqual({ pickFolder: false, addWorkspace: true });
expect(calls).toEqual([["GET", "/host/capabilities"]]);
});
});
+50
View File
@@ -2,6 +2,7 @@ import { HttpError, type AgentPort } from "./port";
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe } from "./remote";
import type { CloudShareOffer, DeviceSelf, ShareOffer, SharePort, ShareStatus } from "./share";
import { SsePort } from "./sse";
import { host } from "./host";
// How often a client waiting on a dial looks for the question it might be
// stopped by. A person answers this one, so half a second is not the cost —
@@ -48,6 +49,15 @@ export interface TreeSession {
versions?: TreeSession[];
}
export interface HostCapabilities {
// The kernel can open a picker on its own machine. A browser on a headless
// server gets false and must offer the path API instead.
pickFolder: boolean;
// The kernel exposes POST /tree/workspaces. This is what makes the fallback
// meaningful rather than sending the reader back to the desktop shell.
addWorkspace: boolean;
}
export interface TreeWorkspace {
root: string;
name: string;
@@ -71,6 +81,9 @@ export interface HubPort extends SharePort {
tree(): Promise<TreeWorkspace[]>;
addWorkspace(path: string): Promise<TreeWorkspace>;
removeWorkspace(path: string): Promise<void>;
/** Show a listed project's folder in the system file manager. */
revealWorkspace(path: string): Promise<void>;
moveWorkspace(path: string, direction: -1 | 1): Promise<void>;
removeSession(path: string): Promise<void>;
archiveSession(path: string, archived: boolean): Promise<void>;
renameSession(path: string, title: string): Promise<void>;
@@ -111,6 +124,10 @@ export interface HubPort extends SharePort {
// kernel that would ask is the one this build cannot reach.
onRemoteAsk(cb: (ask: RemoteAsk) => void): () => void;
answerRemote(id: string, ok: boolean, text: string): void;
// What the kernel can do independently of the desktop window. It is a
// separate read so the page can choose a usable fallback before invoking a
// host-only action.
hostCapabilities(): Promise<HostCapabilities>;
pickFolder(): Promise<string | null>;
// Absolute paths of every file dropped anywhere on the window. It belongs
// here rather than on a pane because the window has one of it: the shell
@@ -200,10 +217,19 @@ export class SseHub implements HubPort {
return this.post<TreeWorkspace>("/tree/workspaces", { path });
}
async revealWorkspace(path: string) {
const why = await host().revealWorkspace(path);
if (why) throw new HttpError(0, why.error || "not shown", why, !!(why.code || why.error));
}
async removeWorkspace(path: string) {
await this.post<void>("/tree/workspaces/remove", { path });
}
async moveWorkspace(path: string, direction: -1 | 1) {
await this.post<void>("/tree/workspaces/move", { path, direction });
}
async removeSession(path: string) {
await this.post<void>("/tree/sessions/remove", { path });
}
@@ -346,6 +372,30 @@ export class SseHub implements HubPort {
}
}
async hostCapabilities() {
const shellCanPick = await host()
.describe()
.then((info) => info.shell === "electron")
.catch(() => false);
const res = await fetch("/host/capabilities", { credentials: "same-origin" });
// A kernel predating this probe has no route and keeps the old picker
// attempt; the caller already treats null as "no picker" safely.
if (res.status === 404 || res.status === 501 || res.status === 503) {
return { pickFolder: true, addWorkspace: true };
}
if (!res.ok) await SseHub.fail("/host/capabilities", res);
const body = (await res.json().catch(() => null)) as Partial<HostCapabilities> | null;
if (typeof body?.pickFolder !== "boolean" || typeof body.addWorkspace !== "boolean") {
return { pickFolder: true, addWorkspace: true };
}
return {
pickFolder: shellCanPick || body.pickFolder,
addWorkspace: body.addWorkspace,
};
}
async pickFolder() {
const shellPath = await this.shell.pickFolder();
if (shellPath !== null) return shellPath;
+1 -1
View File
@@ -670,7 +670,6 @@ export class MockPort extends MockFeedback implements AgentPort {
async retryQueued(itemId: string) {
void itemId;
}
async refreshQueued(itemId: string) {
void itemId;
}
@@ -691,6 +690,7 @@ export class MockPort extends MockFeedback implements AgentPort {
if (at === undefined) throw new HttpError(409, "already applied", { code: "steer.already_applied" });
window.clearTimeout(at);
this.queued.delete(itemId);
this.dropQueued(itemId);
}
async submit(text: string) {
@@ -182,12 +182,23 @@ export class MockHub implements HubPort {
return Promise.resolve<TreeWorkspace>({ root: path, name: path.split("/").pop() ?? path, remembered: true, sessions: [] });
}
async revealWorkspace() {}
removeWorkspace(path: string) {
const at = this.roots.indexOf(path);
if (at >= 0) this.roots.splice(at, 1);
return Promise.resolve();
}
moveWorkspace(path: string, direction: -1 | 1) {
const at = this.roots.indexOf(path);
const to = at + direction;
if (at >= 0 && to >= 0 && to < this.roots.length) {
[this.roots[at], this.roots[to]] = [this.roots[to], this.roots[at]];
}
return Promise.resolve();
}
removeSession(_path: string) {
return Promise.resolve();
}
@@ -213,6 +224,10 @@ export class MockHub implements HubPort {
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0 });
}
hostCapabilities() {
return Promise.resolve({ pickFolder: true, addWorkspace: true });
}
pickFolder() {
return Promise.resolve<string | null>("~/projects/mock-workspace");
}
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { SsePort } from "./sse";
import { SseExtensions } from "./sse_ext";
import { HttpError } from "./http_error";
import { host } from "./host";
import { boot, STORAGE } from "../i18n";
import { reason } from "../i18n/kernel";
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); });
const root = "/rt/project";
const path = "/plugins/notes-kit/export";
const callers = [
["Studio", () => new SsePort(root)],
["extension port", () => new SseExtensions(root)],
] as const;
const replies = [
{ name: "export refusal", status: 422, body: { code: "plugin.export_failed", error: "archive cannot be written", params: { detail: "installed folder missing" } } },
{ name: "unknown package", status: 404, body: { code: "plugin.not_installed", error: "that plugin is not installed" } },
{ name: "plain text", status: 503, body: "export service unavailable" },
{ name: "empty response", status: 502, body: "" },
] as const;
it.each(callers.flatMap(([caller, make]) => replies.map((reply) => ({ caller, make, ...reply }))))(
"keeps a $name from the $caller export endpoint", async ({ make, status, body }) => {
const raw = typeof body === "string" ? body : JSON.stringify(body);
const response = new Response(raw, { status });
const fetch = vi.fn().mockResolvedValue(response);
vi.stubGlobal("fetch", fetch);
const save = vi.spyOn(host(), "saveBytes");
const readArchive = vi.spyOn(response, "blob");
const error = await make().exportPlugin("notes-kit").catch((e: unknown) => e);
expect(error).toBeInstanceOf(HttpError);
const typed = error as HttpError;
expect(typed.status).toBe(status);
expect(typed.message).toBe(typeof body === "string" ? body || path + ": " + status : body.error);
expect(typed.reason).toEqual(typeof body === "string" ? undefined : body);
expect(typed.detailed).toBe(raw !== "");
expect(fetch).toHaveBeenCalledExactlyOnceWith(root + path, { credentials: "same-origin" });
expect(readArchive).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
expect(response.bodyUsed).toBe(true);
},
);
it.each(callers)("renders the %s export refusal with the kernel's detail", async (_caller, make) => {
localStorage.setItem(STORAGE, "zh");
boot();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(replies[0].body), { status: 422 })));
const error = await make().exportPlugin("notes-kit").catch((e: unknown) => e);
expect(reason(error)).toBe("未能导出该插件:installed folder missing");
});
const archive = new Uint8Array([80, 75, 3, 4]);
function exportResponse() {
return new Response(archive, { headers: { "content-type": "application/zip", "X-Reasonix-Required-Env": "NOTES_TOKEN,DOCS_KEY" } });
}
it.each(["/exports/notes-kit.zip", ""])("keeps the native archive save answer %j", async (savedTo) => {
const response = exportResponse();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
const save = vi.spyOn(host(), "saveBytes").mockResolvedValue(savedTo);
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
expect(await new SsePort(root).exportPlugin("notes-kit")).toEqual({
required: ["NOTES_TOKEN", "DOCS_KEY"], savedTo: savedTo || undefined,
});
expect(save).toHaveBeenCalledExactlyOnceWith("notes-kit.zip", archive);
expect(click).not.toHaveBeenCalled();
expect(response.bodyUsed).toBe(true);
});
it.each(callers)("keeps the %s browser archive download", async (_caller, make) => {
const response = exportResponse();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
vi.spyOn(host(), "saveBytes").mockResolvedValue(null);
const create = vi.fn().mockReturnValue("blob:notes-archive");
const revoke = vi.fn();
vi.stubGlobal("URL", class extends URL {
static createObjectURL = create;
static revokeObjectURL = revoke;
});
let clicked!: HTMLAnchorElement;
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked = this; });
expect(await make().exportPlugin("notes-kit")).toEqual({ required: ["NOTES_TOKEN", "DOCS_KEY"] });
expect(create).toHaveBeenCalledTimes(1);
expect(new Uint8Array(await create.mock.calls[0][0].arrayBuffer())).toEqual(archive);
expect(click).toHaveBeenCalledTimes(1);
expect(clicked.href).toBe("blob:notes-archive");
expect(clicked.download).toBe("notes-kit.zip");
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:notes-archive");
});
+2
View File
@@ -115,6 +115,8 @@ export interface QueueItem {
createdAt: string;
// Why a blocked entry stopped, from the kernel that stopped it.
blockReason?: string;
// The stable identity of that stop; the sentence is worded from it here.
blockCode?: string;
// Files this entry froze at the moment it was queued. Their presence is what
// makes re-freezing meaningful; the entry quotes them as they were.
refs?: { path?: string }[];
+3 -4
View File
@@ -69,7 +69,7 @@ export class SsePort extends SseFeedback implements AgentPort {
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
credentials: "same-origin",
});
if (!res.ok) throw new Error(`/plugins/${name}/export: ${res.status}`);
if (!res.ok) await SsePort.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
const blob = await res.blob();
// A shell with a save dialog puts the archive where it is asked to; the
@@ -371,9 +371,8 @@ export class SsePort extends SseFeedback implements AgentPort {
if (why) throw new HttpError(0, why.error || "not shown", why, !!(why.code || why.error));
}
openExternal(url: string): Promise<void> {
host().openExternal(url);
return Promise.resolve();
async openExternal(url: string): Promise<void> {
await host().openExternal(url);
}
async pickFolder(): Promise<string | null> {
+1 -1
View File
@@ -88,7 +88,7 @@ export class SseExtensions extends SseLook {
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
credentials: "same-origin",
});
if (!res.ok) throw new Error(`/plugins/${name}/export: ${res.status}`);
if (!res.ok) await SseExtensions.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
const url = URL.createObjectURL(await res.blob());
const a = document.createElement("a");
+4
View File
@@ -281,6 +281,9 @@ export interface AskOrigin {
source: string;
message?: string;
note?: string;
url?: string;
urlHost?: string;
urlLocal?: boolean;
}
export interface Ask {
@@ -645,6 +648,7 @@ export type ExecutionGraphRead = ExecutionGraphView & ExecutionGraphSnapshot;
export interface WireEvent {
kind: Kind;
nonPersistable?: boolean;
text?: string;
detail?: string;
code?: string;
+18 -2
View File
@@ -12,11 +12,27 @@ import { nextId } from "./ids";
// sealTurn tell the two apart.
const merge = (prev: Tool, next: Tool): Tool => ({ ...prev, ...next, partial: next.partial ?? false });
// The kernel's reserved ToolProgress channels for a child's live preview. They
// describe the child, never the call that spawned it, so they are kept apart
// from the call's card: folding one in renames it and reopens it.
export const SUBAGENT_PROGRESS_PREFIX = "reasonix.subagent.";
export const isSubagentProgress = (name: string | undefined): boolean => !!name?.startsWith(SUBAGENT_PROGRESS_PREFIX);
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
export function notePhase(phases: Record<string, string>, tool: Tool): Record<string, string> {
if (tool.name !== SUBAGENT_PROGRESS_PREFIX + "status" || !tool.id) return phases;
return { ...phases, [tool.id]: tool.output ?? "" };
}
/** A child is working until its tracker says otherwise. */
export const subagentLive = (phase: string | undefined): boolean => !!phase && !TERMINAL.has(phase);
export function foldTool(items: Item[], tool: Tool, running: boolean): Item[] {
// A subagent's calls carry parentId; they belong inside the task that spawned
// them, not as siblings in the main flow.
// them, not as siblings in the main flow. A fleet worker's parent is itself a
// child card, so the owning card is the one that holds the parent as a child.
if (tool.parentId) {
const at = items.findIndex((i) => i.t === "tool" && i.tool.id === tool.parentId);
const at = items.findIndex(
(i) => i.t === "tool" && (i.tool.id === tool.parentId || i.children.some((c) => c.id === tool.parentId)),
);
if (at >= 0) {
const parent = items[at] as Extract<Item, { t: "tool" }>;
const kids = parent.children.slice();
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import type { Tool } from "../port/wire";
import { foldTool } from "./fold";
import type { Item } from "./session_types";
const tool = (id: string, name: string, parentId?: string): Tool => ({ id, name, parentId, readOnly: true });
const run = (events: Tool[]): Item[] => events.reduce<Item[]>((items, t) => foldTool(items, t, true), []);
describe("a delegate's calls stay inside the card that spawned them", () => {
it("nests a task's own call under the task", () => {
const items = run([tool("task", "use_capability"), tool("task/ls", "ls", "task")]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ t: "tool", children: [{ id: "task/ls" }] });
});
it("keeps a fleet worker's call out of the main list", () => {
const items = run([
tool("fl", "use_capability"),
tool("fl/fleet-1", "task", "fl"),
tool("fl/fleet-1/read", "read_file", "fl/fleet-1"),
]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ t: "tool" });
const kids = (items[0] as Extract<Item, { t: "tool" }>).children;
expect(kids.map((c) => c.id)).toEqual(["fl/fleet-1", "fl/fleet-1/read"]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { SessionState } from "./session_types";
// The kernel answers a queued line with the id it queued it under. The row is
// already on screen by then: this gives it a name to be taken back by.
export function nameQueued(s: SessionState, id: string, itemId: string, queued: "steer" | "followup"): SessionState {
// Guidance read at a tool boundary can be reported before its receipt lands
// here. That report drew the delivered line, so this row is its duplicate and
// naming it would re-open a line that already arrived.
if (s.items.some((i) => i.t === "user" && i.itemId === itemId && i.id !== id)) {
return { ...s, items: s.items.filter((i) => !(i.t === "user" && i.id === id)) };
}
return {
...s,
items: s.items.map((i) => (i.t === "user" && i.id === id ? { ...i, itemId, queued, pending: true } : i)),
};
}
@@ -93,6 +93,22 @@ describe("sealing a turn", () => {
});
});
// The kernel flags a turn the user's own action ended. Its err is the sentinel's
// wording, which says nothing the flag does not, so it is not a failure card.
describe("a turn the user cancelled", () => {
it("does not draw the cancellation as an error card", () => {
const s = run([partial("c1"), { kind: "turn_done", cancelled: true, err: "context canceled" } as SessionEvent]);
expect(s.items.filter((i) => i.t === "notice" && i.level === "error")).toHaveLength(0);
expect(s.terminal).toEqual({ kind: "cancelled" });
expect(s.doing).toBe("已取消");
});
it("still draws a genuine failure", () => {
const s = run([{ kind: "turn_done", err: "provider down" } as SessionEvent]);
expect(notices(s)).toEqual(["provider down"]);
});
});
describe("rebuilding a reopened transcript", () => {
const users = (msgs: HistoryMessage[]) =>
fromHistory(msgs).items.filter((i): i is Extract<Item, { t: "user" }> => i.t === "user");
@@ -568,3 +584,21 @@ describe("which model wrote a reply", () => {
expect(says(st)[0].model).toBe("deepseek/deepseek-pro");
});
});
describe("a receipt that arrives after the delivery it is for", () => {
const sent = { kind: "__user", text: "换个思路", pending: true, id: "row-1" } as SessionEvent;
const delivered = { kind: "steer", text: "换个思路", itemId: "it1" } as SessionEvent;
const receipt = { kind: "__queued", id: "row-1", itemId: "it1", queued: "steer" } as SessionEvent;
const users = (st: SessionState) => st.items.filter((i): i is Extract<Item, { t: "user" }> => i.t === "user");
it("leaves one delivered line and nothing waiting", () => {
const rows = users(run([sent, delivered, receipt]));
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ steer: true, itemId: "it1" });
expect(rows.some((r) => r.pending)).toBe(false);
});
it("still names a line the kernel has not delivered yet", () => {
expect(users(run([sent, receipt]))[0]).toMatchObject({ pending: true, itemId: "it1" });
});
});
+9 -16
View File
@@ -19,7 +19,8 @@ import { nameTurnStart, othersSteer } from "./turn_start";
import { appendText, foldMessage, sealSay } from "./say";
import { nextId } from "./ids";
import { foldStall } from "./stall";
import { dropTool, foldLastRead, foldTool, mergeReads } from "./fold";
import { nameQueued } from "./queued";
import { dropTool, foldLastRead, foldTool, isSubagentProgress, mergeReads, notePhase } from "./fold";
export { quoteAmount };
export { setShowsReceipt, showsReceipt };
@@ -51,7 +52,7 @@ export const initialState: SessionState = {
waiting: {},
running: false,
doing: "空闲",
steerQueue: [],
steerQueue: [], subagentPhase: {},
awaitingTurnStart: [],
queueMoved: 0,
browserTabsMoved: 0,
@@ -107,7 +108,8 @@ function sealTurn(items: Item[], err?: string): Item[] {
}
if (!err) return sealed;
// The kernel's own words: classifying them here would be this file guessing
// at failures it cannot see.
// at failures it cannot see. A turn it flagged as cancelled never gets here:
// the flag is the identity, and its err only repeats the sentinel.
return [...sealed, { t: "notice" as const, id: nextId(), level: "error", text: err }];
}
@@ -232,17 +234,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
items: [...s.items, { t: "user", id, text: ev.text, pending: ev.pending }],
};
}
// The kernel answers a queued line with the id it queued it under. The row
// is already on screen by then — this is what gives it a name to be taken
// back by, and losing that name is the same as losing the button.
if (ev.kind === "__queued") {
return {
...s,
items: s.items.map((i) =>
i.t === "user" && i.id === ev.id ? { ...i, itemId: ev.itemId, queued: ev.queued, pending: true } : i,
),
};
}
if (ev.kind === "__queued") return nameQueued(s, ev.id, ev.itemId, ev.queued);
// A line the kernel never took is not part of what happened, so it leaves the
// transcript rather than sitting there looking sent. Either name identifies
// it: the row the composer minted, or the entry the kernel queued it as —
@@ -375,6 +367,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
: s;
case "tool_progress":
if (ev.tool && isSubagentProgress(ev.tool.name)) return { ...s, subagentPhase: notePhase(s.subagentPhase, ev.tool) };
return ev.tool
? { ...s, executions: noteTool(s.executions, ev.tool, false), items: foldTool(s.items, ev.tool, true) }
: s;
@@ -607,10 +600,10 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
...s,
running: false,
terminal: turnTerminal(ev),
doing: ev.outcome === "no_progress" ? "已暂停" : ev.err ? "已中断" : "已完成",
doing: ev.outcome === "no_progress" ? "已暂停" : ev.cancelled ? "已取消" : ev.err ? "已中断" : "已完成",
waiting: {},
plan: livePlan(s.plan),
items: withReceipt(sealTurn(sealSay(s.items, true), ev.outcome === "no_progress" ? undefined : ev.err), ev.receipt),
items: withReceipt(sealTurn(sealSay(s.items, true), ev.outcome === "no_progress" || ev.cancelled ? undefined : ev.err), ev.receipt),
};
default:
@@ -243,4 +243,7 @@ export interface SessionState {
// are kept apart from `views` because they have no place of their own: they
// appear only where the thing they stand in for appears.
takeovers: Record<string, ExtensionSurface>;
// The last phase each sub-agent reported, by call id. Kept beside the cards
// because a background child outlives the call that started it.
subagentPhase: Record<string, string>;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { initialState, reduce, type Item, type SessionEvent } from "./session";
import go from "../../../../internal/contract/event/subagent_progress.go?raw";
import { SUBAGENT_PROGRESS_PREFIX } from "./fold";
import { railOf } from "../ui/panels/derive";
const prof = { name: "flash-fill" };
const dispatch = (id: string): SessionEvent =>
({ kind: "tool_dispatch", tool: { id, name: "use_capability", args: "{}", profile: prof } }) as SessionEvent;
const started = (id: string): SessionEvent =>
({ kind: "tool_result", tool: { id, name: "use_capability", output: 'Started background task "task-1"', profile: prof } }) as SessionEvent;
const status = (id: string, phase: string, parentId?: string): SessionEvent =>
({ kind: "tool_progress", tool: { id, parentId, name: "reasonix.subagent.status", output: phase, durationMs: phase === "completed" ? 41000 : undefined } }) as SessionEvent;
const after = (evs: SessionEvent[]) => evs.reduce(reduce, initialState);
const card = (s: ReturnType<typeof after>) => s.items.find((i): i is Extract<Item, { t: "tool" }> => i.t === "tool")!;
const liveTasks = (s: ReturnType<typeof after>) => railOf(s.items, s.executions, s.subagentPhase).tasks.filter((x) => x.running).length;
describe("a background sub-agent after its dispatching call has answered", () => {
it("is still running while its last phase is not terminal", () => {
const s = after([dispatch("c1"), started("c1"), status("c1", "running"), status("c1", "tool")]);
expect(card(s).running).toBe(false);
expect(liveTasks(s)).toBe(1);
});
it("is done once a terminal phase arrives, and the card is not reopened", () => {
const s = after([dispatch("c1"), started("c1"), status("c1", "running"), status("c1", "completed")]);
expect(card(s).running).toBe(false);
expect(liveTasks(s)).toBe(0);
});
it("is done on failure and on cancellation too", () => {
for (const phase of ["failed", "cancelled"]) {
expect(liveTasks(after([dispatch("c1"), started("c1"), status("c1", "running"), status(("c1"), phase)]))).toBe(0);
}
});
it("never renames the call or its recorded execution", () => {
const s = after([dispatch("c1"), started("c1"), status("c1", "completed")]);
expect(card(s).tool.name).toBe("use_capability");
expect(s.executions["c1"]).toMatchObject({ name: "use_capability" });
});
it("keeps a call that is still open running", () => {
expect(liveTasks(after([dispatch("c1"), status("c1", "completed")]))).toBe(1);
});
it("draws no card for a preview", () => {
expect(after([status("c1", "queued")]).items).toEqual([]);
});
it("follows a fleet worker through the card that holds it", () => {
const fleet = { kind: "tool_dispatch", tool: { id: "fl", name: "use_capability", profile: { name: "fleet", count: 2 } } } as SessionEvent;
const worker = { kind: "tool_dispatch", tool: { id: "fl/w1", parentId: "fl", name: "task" } } as SessionEvent;
const closed = { kind: "tool_result", tool: { id: "fl", name: "use_capability", profile: { name: "fleet", count: 2 } } } as SessionEvent;
const mid = after([fleet, worker, closed, status("fl/w1", "running", "fl")]);
expect(liveTasks(mid)).toBe(1);
expect(liveTasks(after([fleet, worker, closed, status("fl/w1", "running", "fl"), status("fl/w1", "completed", "fl")]))).toBe(0);
});
});
describe("the reserved prefix", () => {
it("is the kernel's", () => {
expect(go.match(/SubagentProgressPrefix\s*=\s*"([^"]+)"/)?.[1]).toBe(SUBAGENT_PROGRESS_PREFIX);
});
});
+53 -8
View File
@@ -269,6 +269,19 @@ rules are written in.
was read, stored, written inline and then overridden every time.
- `.gutter-l` outranks the rail because the rail is `position: fixed` at 12: a
seam painted under the panel it divides cannot be grabbed where it matters.
- The rail's floor is 232px (`RAIL.min`); a narrower saved width is raised on
read.
- Under 232px the session filter row cannot hold four labels in either
language.
- The rail head is a size container, and its threshold is a content-box width,
not a rail width.
- The threshold is 220px: the content box of the default 264px rail, which has
22px side padding. The default and anything wider show counts and shortcut
hints.
- At 219px or less counts and hints are dropped, the filter cells may shrink
and labels ellipsize.
- Inside a window of 860px or less the padding is 16px, so the cutoff falls at
a 252px rail.
- The rail lists every mounted workspace, each folding over its own sessions.
The studio layer had hidden all but the focused one and relabelled its
children 「最近」, so a count of six sat above a list of one. The switcher above
@@ -404,14 +417,11 @@ rules are written in.
- `[data-k="me"]` opens a turn. Scrolling back, it is the only anchor, and it
used to be as light as a tool call's header. The turn number is deliberately
absent: the rewind entry is on the card, so there is no number to match by eye.
- The bubble sizes the card; the controls row above it does not.
`contain: inline-size` keeps the row out of the card's width, and
`margin-inline-start: auto` holds the bubble's right edge on the column's.
- When the labels need more room than the bubble, the controls drop to icons
and keep their names, hints and focus. Fit is measured (`labelfit.ts`).
- No container-query breakpoint decides it: the labels' width moves with the
language and the marks beside them, so any fixed width is wrong for a row.
- Icons wider than the bubble overflow to the left, where the free space is.
- User message controls sit below the bubble, ordered copy, edit, rewind.
They stay icon-only with names, hints and keyboard focus. Inline-size
containment keeps the row from widening the bubble; wider rows extend left.
- The row appears on message hover, keyboard focus, or an open rewind menu.
Devices without hover keep the controls visible.
- `[data-k="host"]` is what the host did itself. A transcript has three authors —
you, the model, the host — and `data-k` only knows tool names, so the host's
cards landed on the default grey beside an uncategorised tool. A dashed line is
@@ -670,9 +680,36 @@ what exists.
## Onboarding
- The stage is the named inline-size container `onboarding`. The card folds at
760px of that container, so interface zoom changes its columns and spacing.
- The fold follows available CSS width, not the unscaled viewport measured by
a media query. Form controls must fit inside the shell's clipping boundary.
- `perf/onboarding.mjs` checks card bounds, the 760/761px boundary, saved zoom
and resizing, alongside scrolling, focus clearance and completing setup.
- The connect card grows inside the opening scene rather than starting a second
screen, so the introduction above it stays present and the palette follows the
scene (a dark ground) rather than the app theme.
- The stage is exactly the window's height and is the scroll container, because
the body clips.
- That height is `100dvh / var(--zoom)`, as on the body: `vh` does not scale
with the zoom setting, so a bare `100dvh` stage would be taller than the
zoomed window.
- The title bar is `position: sticky; top: 0` inside the stage: it holds the
drag region and the window buttons, which must stay on screen however far
the card is scrolled.
- A stage with only a `min-height` grows with its content and never overflows
itself, so the connected state pushed the start button below a short window
with nothing to scroll.
- The ambient glow is `position: fixed`: absolutely positioned inside a scroll
container it counts as scrollable overflow, and at a wide window (38vw tall)
it let the stage scroll far past the card.
- The shell is `flex: none`: a shrinkable shell is cut off instead of making
the stage scroll.
- The stage's `scroll-padding-top` equals the title bar's 64px, so focus moving
up the form scrolls the field below the sticky bar rather than under it.
- The title bar and footer are `flex: none` too; as shrinkable flex items they
were squeezed to half height once the card overflowed.
- The shell clips with `overflow: clip`, so it is not a scroll container.
- Its labels are not uppercased: tracking pulls apart the Chinese particles in
mixed text. The "get a key" entry sits at the label's right end with tracking
zeroed, because the label's .06em is for Latin small labels.
@@ -779,6 +816,14 @@ what exists.
- Remove is the one irreversible action in a row, so it does not look like the
other buttons and appears only on hover.
## Community
- The QR code sits on a white plate in both themes: a scanner needs dark modules
on a light field with a quiet zone, and an inverted code in the dark theme
does not scan. The plate is the image's own quiet zone, not a card behind it.
- The image is drawn at 5 CSS pixels per module (185px for 37 modules) so the
squares land on whole pixels; `image-rendering: pixelated` keeps them hard.
## Version list
- The list is one grid and each row a subgrid of it, so version, description,
+10
View File
@@ -2766,6 +2766,7 @@
font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.srv .acts { flex: none }
.srv .why { margin: 4px 0 0 15px; font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--err) }
.srv .why[role="status"] { color: var(--muted) }
.srv .peek { margin: 7px 0 1px 15px }
.srv .srv-ab { display: flex; align-items: baseline; gap: 8px; margin: 5px 0 0 15px }
@@ -2861,6 +2862,7 @@
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.7 var(--mono); resize: vertical;
}
.addsrv .paste::placeholder, .addpkg .paste::placeholder { color: var(--ghost); white-space: pre-line }
.addpkg .paste:disabled { background: var(--overlay); cursor: default }
.addsrv .paste:focus, .addpkg .paste:focus { outline: 2px solid var(--focus); outline-offset: 1px }
.addsrv .acts, .addpkg .acts { display: flex; align-items: center; gap: 8px; margin-top: 10px }
.addsrv .acts .note, .addpkg .acts .note { flex: 1; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
@@ -3099,6 +3101,12 @@
.addp .fields label { display: flex; flex-direction: column; gap: 4px }
.addp .fields label.grow { flex: 1; min-width: 150px }
.addp .fields label.full { flex-basis: 100% }
.addp .fields input[aria-invalid="true"] { border-color: var(--err) }
.addp .fields .name-cell { flex: 1; min-width: 150px; display: flex; flex-direction: column; gap: 4px }
.addp .fields .name-cell label { display: flex; flex-direction: column; gap: 4px }
.addp .fields input[aria-invalid="true"]:focus { outline-color: var(--err) }
.addp .fields .name-rule { margin: 0; font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); color: var(--ghost) }
.addp .fields .name-rule[data-bad] { color: var(--err) }
.addp .fields .tip { font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); font-style: normal; color: var(--ghost) }
.addp-section { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--hair) }
.addp-section.compact { margin-top: 12px }
@@ -3273,6 +3281,8 @@
}
.mkt-fields .mono { font: var(--w-reg) var(--fs-meta)/1.5 var(--mono) }
.mkt-fields textarea { resize: vertical }
.mkt-pub .mkt-fields :is(input, textarea):disabled,
.mkt-pub .mkt-seen:has(:disabled) { opacity: .5; cursor: default }
.mkt-fields input:focus, .mkt-fields textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px }
.mkt-fields input::placeholder { color: var(--ghost) }
.mkt-vote { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 0 }
@@ -0,0 +1,35 @@
.comm { display: flex; flex-direction: column; gap: 22px; }
.comm-join { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 20px; }
.comm-qr {
flex: none; width: 185px; height: 185px; max-width: 100%; box-sizing: border-box;
background: #fff; border: 1px solid var(--border); border-radius: var(--r-md);
image-rendering: pixelated;
}
.comm-qq { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
.comm-name { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); color: var(--text); overflow-wrap: anywhere; }
.comm-num { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.comm-num code { font: var(--w-med) var(--fs-lead) var(--mono); color: var(--text); letter-spacing: .02em; user-select: all; }
.comm-k { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); }
.comm-how { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); }
.comm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.comm-fail { margin: 0; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--err-ink); overflow-wrap: anywhere; }
.comm-who { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding-top: 16px; border-top: 1px solid var(--hair); }
.comm-who-hd { align-self: stretch; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.comm-who-hd h4 { margin: 0; font: var(--w-semi) var(--fs-ui) var(--ui); color: var(--text); }
.comm-who-hd .btn { margin-left: auto; }
.comm-list { align-self: stretch; margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
.comm-list li { min-width: 0; }
.comm-list a {
display: block; max-width: 100%; padding: 2px 9px; border: 1px solid var(--hair); border-radius: var(--r-pill);
font: var(--w-reg) var(--fs-meta)/1.7 var(--mono); color: var(--muted); text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.comm-list a:hover { color: var(--text); border-color: var(--faint); }
.comm-list a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.comm-dy { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.comm-dy code { font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); user-select: all; }
.comm-dy-qr { align-self: flex-start; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-md); }
.comm-author { display: flex; align-items: baseline; gap: 10px; padding: 0 0 14px; }
.comm-author a { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); text-decoration: none; }
.comm-author a:hover { text-decoration: underline; }
.comm-author a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
const CSS = import.meta.glob("./*.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const MIN_LINE_HEIGHT = 1.2;
interface Rule {
file: string;
selector: string;
body: string;
}
function rules(): Rule[] {
const out: Rule[] = [];
for (const [file, css] of Object.entries(CSS)) {
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) out.push({ file, selector: m[1].trim(), body: m[2] });
}
return out;
}
function lineHeight(body: string): number | undefined {
const longhand = body.match(/(?:^|[;\s])line-height\s*:\s*([\d.]+)\s*(?:;|$)/);
if (longhand) return Number(longhand[1]);
const shorthand = body.match(/(?:^|[;\s])font\s*:[^;]*?\/\s*([\d.]+)(?=\s|;|$)/);
return shorthand ? Number(shorthand[1]) : undefined;
}
const clips = (body: string) => /overflow(?:-x)?\s*:\s*hidden|text-overflow\s*:/.test(body);
describe("a clipped single line keeps room for the glyphs of any UI font", () => {
it("never pairs overflow clipping with a unitless line-height below 1.2", () => {
const tight = rules()
.filter((r) => clips(r.body))
.filter((r) => (lineHeight(r.body) ?? Infinity) < MIN_LINE_HEIGHT)
.map((r) => `${r.file} ${r.selector}`);
expect(tight).toEqual([]);
});
it("gives the composer's model name its own line-height, since it inherits a tight one from the picker", () => {
const names = rules().filter((r) => /\.model-picker \.nm$/.test(r.selector) && clips(r.body));
expect(names.length).toBeGreaterThan(0);
const bare = names.filter((r) => (lineHeight(r.body) ?? 0) < MIN_LINE_HEIGHT).map((r) => `${r.file} ${r.selector}`);
expect(bare).toEqual([]);
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
const SHEET = Object.values(
import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>,
)[0];
interface Rule {
selector: string;
decls: Map<string, string>;
}
function rules(css: string): Rule[] {
const out: Rule[] = [];
for (const m of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const decls = new Map<string, string>();
for (const part of m[2].split(";")) {
const at = part.indexOf(":");
if (at > 0) decls.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
}
out.push({ selector: m[1].trim(), decls });
}
return out;
}
describe("status rail figures hold their width", () => {
const rail = rules(SHEET).filter((r) => r.selector.split(",").some((s) => s.includes(".studio-meterrail")));
it("finds the rail rules", () => {
expect(rail.length).toBeGreaterThan(0);
});
it("keeps tabular numerals on every rule that sets the font shorthand", () => {
const reset = rail
.filter((r) => r.decls.has("font") && r.decls.get("font-variant-numeric") !== "tabular-nums")
.map((r) => r.selector.replace(/\s+/g, " "));
expect(reset, "the font shorthand resets font-variant-numeric; restate tabular-nums in the same rule").toEqual([]);
});
it("reserves the width of a three-digit speed so 9.9 to 10.0 and 99.9 to 100.0 do not move its neighbours", () => {
const speed = rail.find((r) => r.selector === ".studio-meterrail .studio-meter-speed b");
expect(speed?.decls.get("min-width")).toBe("5ch");
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const css = Object.values(CSS)[0];
const stage = [...css.matchAll(/\.onb-stage\s*\{([^}]*)\}/g)].map((match) => match[1]).join("\n");
describe("onboarding folds in the room left after interface zoom", () => {
it("measures its own inline size", () => {
expect(stage).toMatch(/container:\s*onboarding\s*\/\s*inline-size/);
});
it("moves the complete card to one column through a container query", () => {
const fold = css.match(/@container onboarding \(max-width: 760px\)\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
expect(fold).toMatch(/\.onb-shell\s*\{[^}]*grid-template-columns:\s*1fr/);
expect(fold).toContain(".onb-progress");
expect(fold).toContain(".onb { padding: 26px 22px 24px; }");
const media = css.match(/@media \(max-width: 760px\)\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
expect(media).not.toContain(".onb");
});
it("keeps viewport scrolling and focus clearance independent of the fold", () => {
expect(stage).toContain("height: calc(100dvh / var(--zoom, 1))");
expect(stage).toContain("scroll-padding-top: 64px");
expect(stage).toContain("overflow: auto");
expect(css).toMatch(/\.onb-brandbar\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0/);
});
});
+104 -23
View File
@@ -326,7 +326,9 @@ body { background: var(--page); }
/* First-run connection */
.onb-stage {
min-height: 100vh;
height: calc(100dvh / var(--zoom, 1));
scroll-padding-top: 64px;
container: onboarding / inline-size;
display: flex;
flex-direction: column;
background:
@@ -336,6 +338,7 @@ body { background: var(--page); }
}
.onb-brandbar {
height: 64px;
flex: none;
display: flex;
align-items: center;
gap: 9px;
@@ -358,9 +361,10 @@ body { background: var(--page); }
.onb-shell {
width: min(920px, calc(100% - 48px));
margin: auto;
flex: none;
display: grid;
grid-template-columns: 230px minmax(0, 1fr);
overflow: hidden;
overflow: clip;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface);
@@ -586,7 +590,7 @@ body { background: var(--page) !important; }
stroke-linejoin: round;
}
.studio-icon[data-flip] { transform: rotate(180deg); }
.studio-rail-head { padding: 23px 22px 5px; }
.studio-rail-head { padding: 23px 22px 5px; container-type: inline-size; }
.studio-brand { padding: 0 0 19px; gap: 9px; }
.studio-brand-mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; background: var(--accent); color: var(--accent-fg); font-weight: 750; }
.studio-brand-mark .studio-icon { width: 17px; height: 17px; stroke-width: 1.9; }
@@ -638,25 +642,35 @@ body { background: var(--page) !important; }
}
.studio-quicknav button:hover { color: var(--text); background: var(--overlay); }
.studio-quicknav button > span { display: grid; place-items: center; color: var(--accent); text-align: center; }
.studio-quicknav b { font: 500 11px/1 var(--ui); }
.studio-quicknav b { min-width: 0; overflow: hidden; font: 500 11px/1.4 var(--ui); text-overflow: ellipsis; white-space: nowrap; }
.studio-new-task > .studio-label,
.studio-search > .studio-label { min-width: 0; display: block; overflow: hidden; color: inherit; font: inherit; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
.studio-quicknav small { color: var(--faint); font: 500 9px/1 var(--ui); }
.studio-session-filters {
min-height: 31px; display: grid; grid-template-columns: minmax(0, 1fr) 26px;
align-items: center; gap: 4px; margin: 0 0 8px; padding: 0; background: transparent;
}
.studio-session-segments {
height: 28px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
height: 28px; display: flex;
align-items: center; gap: 1px; padding: 2px; border-radius: 6px; background: var(--overlay);
}
.studio-session-segments button {
min-width: 0; height: 24px; display: flex; align-items: center; justify-content: center;
gap: 2px; padding: 0 3px; border: 0; border-radius: 4px; background: transparent;
min-width: 0; height: 24px; flex: 1 0 auto; display: flex; align-items: center; justify-content: center;
gap: 2px; padding: 0 1px; border: 0; border-radius: 4px; background: transparent;
color: var(--muted); font: 500 10px/1 var(--ui); white-space: nowrap; cursor: pointer;
}
.studio-session-segments button:hover { background: var(--raised); color: var(--text); }
.studio-session-segments button[aria-pressed="true"] { background: var(--raised); color: var(--text); font-weight: 650; }
.studio-session-segments b { min-width: 8px; color: var(--faint); font: 560 9px/1 var(--ui); font-variant-numeric: tabular-nums; }
.studio-session-segments button > span { min-width: 0; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; }
.studio-session-segments b { flex: none; min-width: 8px; color: var(--faint); font: 560 9px/1 var(--ui); font-variant-numeric: tabular-nums; }
.studio-session-segments button[aria-pressed="true"] b { color: var(--muted); }
@container (max-width: 219px) {
.studio-new-task kbd,
.studio-search kbd,
.studio-quicknav small,
.studio-session-segments b { display: none; }
.studio-session-segments button { flex-shrink: 1; }
}
.studio-session-filters .studio-filter-search {
width: 26px; height: 26px; display: grid; place-items: center; margin: 0; padding: 0;
border: 0; border-radius: 5px; background: transparent; color: var(--faint); cursor: pointer;
@@ -683,7 +697,7 @@ body { background: var(--page) !important; }
.rail .sessrow[aria-selected="true"] { background: var(--raised); color: var(--text); box-shadow: inset 2px 0 var(--text); }
.rail .sessrow .pip { opacity: .45; }
.rail .sesstitle { font-size: 11px; line-height: 1.35; }
.rail .sessmeta { color: var(--faint); font-size: 9px; }
.rail .sessmeta { flex: none; color: var(--faint); font-size: 9px; white-space: nowrap; }
.rail .sessrow > .wsdel { opacity: 0; transition: opacity 100ms ease; }
.rail .sessrow:hover > .wsdel,
.rail .sessrow:focus-within > .wsdel { opacity: 1; }
@@ -851,13 +865,35 @@ body { background: var(--page) !important; }
.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 28px;
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; }
.flow .call[data-k="me"] .user-hl { min-height: 0; margin: 0 3px 4px; justify-content: flex-end; flex-wrap: nowrap; contain: inline-size; }
.flow .user-acts { display: flex; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 4px; min-height: 26px; contain: inline-size; opacity: 0; transition: opacity var(--m-line); }
.flow .call > .c:hover .user-acts,
.flow .call > .c:focus-within .user-acts,
.flow .user-acts:has(.rewind[data-open]),
.flow .user-acts:has(.copy[data-feedback]) { opacity: 1; }
.flow .user-acts > .rewind { margin-left: 0; opacity: 1; }
.flow .user-acts .rewind > .picker { display: flex; }
.flow .user-acts > button,
.flow .user-acts .stepctl button {
display: inline-flex; align-items: center; justify-content: center; flex: none;
width: 26px; height: 26px; padding: 0; opacity: 1; line-height: 12px;
border: 0; border-radius: 6px; background: transparent; color: var(--muted);
}
.flow .user-acts .studio-icon { width: 12px; height: 12px; }
.flow .call[data-k="me"] .user-acts > button:hover,
.flow .call[data-k="me"] .user-acts .stepctl button:hover { background: var(--overlay); color: var(--text); }
.flow .user-acts > .copy[data-feedback] { width: auto; gap: 5px; padding: 0 6px; white-space: nowrap; }
.flow .user-acts > button.copy[data-state="done"],
.flow .user-acts > button.copy[data-state="done"]:hover { color: var(--ok); }
.flow .user-acts > button.copy[data-state="failed"],
.flow .user-acts > button.copy[data-state="failed"]:hover { color: var(--warn-ink); }
.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 .call[data-k="me"] .out .txt {
@@ -1126,7 +1162,7 @@ body { background: var(--page) !important; }
.turntools .studio-branch { max-width: 160px; }
.turntools .studio-branch .lb { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.turntools .model-picker { max-width: 180px; }
.turntools .model-picker .nm { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.turntools .model-picker .nm { max-width: 130px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.compose .mode {
min-height: 28px;
border-color: transparent;
@@ -1176,7 +1212,7 @@ body { background: var(--page) !important; }
.studio-meterrail > .studio-meter-cost {
height: 25px; display: inline-flex; align-items: center; gap: 5px; padding: 0 6px;
border: 0; border-radius: 5px; background: transparent; color: var(--faint);
white-space: nowrap; font: 500 10px/1 var(--ui);
white-space: nowrap; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums;
}
.studio-meterrail > button,
.studio-meterrail .studio-context-anchor > button,
@@ -1187,7 +1223,8 @@ body { background: var(--page) !important; }
background: color-mix(in srgb, var(--text) 7%, transparent);
color: var(--text);
}
.studio-meterrail b { color: inherit; font: 500 10px/1 var(--ui); }
.studio-meterrail b { color: inherit; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums; }
.studio-meterrail .studio-meter-speed b { min-width: 5ch; text-align: right; }
.studio-meterrail .studio-icon { width: 13px; height: 13px; }
/* The line reads before the figure: the shape answers "is it moving" at a
glance, and the number is what you look at once it has. It takes no room
@@ -1406,7 +1443,7 @@ body { background: var(--page) !important; }
.workbench-filelist em { color: var(--warn); font-style: normal; }
.workbench-no-files { margin: 18px 12px; color: var(--faint); font: 500 10px/1.4 var(--ui); text-align: center; }
.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); }
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1 var(--mono); }
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1.5 var(--mono); }
.workbench-filebar [role="group"] { display: flex; padding: 2px; background: var(--page); border: 1px solid var(--hair); border-radius: 5px; }
.workbench-filebar button { min-height: 25px; padding: 0 8px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font: 600 10px/1 var(--ui); }
.workbench-filebar button[aria-pressed="true"] { background: var(--raised); color: var(--text); }
@@ -1650,11 +1687,16 @@ body { background: var(--page) !important; }
.onb-model-menu .mi:hover,
.onb-model-menu .mi[data-lead] { background: var(--overlay); }
.onb-model-menu .mfind { background: var(--float); color: var(--text); border-bottom-color: var(--border); }
.onb-foot { padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 10px/1 var(--ui); }
.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; }
}
@container onboarding (max-width: 760px) {
.onb-shell { width: min(560px, calc(100% - 24px)); grid-template-columns: 1fr; }
.onb-progress { padding: 18px 20px; border-right: 0; border-bottom: 1px solid var(--hair); }
.onb-progress ol { margin-top: 14px; display: flex; gap: 10px; }
@@ -1662,8 +1704,6 @@ body { background: var(--page) !important; }
.onb-progress li span small,
.onb-progress p { display: none; }
.onb { padding: 26px 22px 24px; }
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
.compose { width: calc(100% - 20px); margin-inline: auto; }
}
@media (prefers-reduced-motion: reduce) {
@@ -1738,7 +1778,7 @@ body { background: var(--page) !important; }
.onb-stage::after {
content: "";
position: absolute;
position: fixed;
z-index: -1;
top: 7%;
right: 10%;
@@ -1755,7 +1795,8 @@ body { background: var(--page) !important; }
}
.onb-brandbar {
position: relative;
position: sticky;
top: 0;
z-index: 2;
background: color-mix(in srgb, var(--page) 76%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.12);
@@ -1864,6 +1905,8 @@ body { font-size: 14px; line-height: 1.75; }
display: flex; align-items: center; overflow: hidden;
font: 400 11px/1.45 var(--ui); letter-spacing: -.01em;
}
.rail .sessrow-context > .sesstitle { padding-inline-end: 9px; }
.rail .sessrow-context > .sesscopies { margin-inline-end: 8px; }
.rail .sesstitle > span {
min-width: 0; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden; white-space: normal; overflow-wrap: anywhere; word-break: break-word;
@@ -2145,7 +2188,7 @@ body { font-size: 14px; line-height: 1.75; }
}
.studio-user-foot .acctrow { width: 30px; min-width: 30px; height: 30px; margin: 0; padding: 0; overflow: hidden; }
.studio-user-foot .ini { width: 28px; height: 28px; }
.studio-workspace-kind { color: var(--faint); font: 500 11px/1 var(--ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.studio-workspace-kind { color: var(--faint); font: 500 11px/1.5 var(--ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.turntools > .attach { order: 1 !important; }
.turntools > .studio-mode-control { order: 2 !important; }
@@ -2184,7 +2227,7 @@ body { font-size: 14px; line-height: 1.75; }
.studio-mode-picker .studio-icon:last-child,
.studio-effort-picker .studio-icon:last-child,
.model-picker .studio-icon:last-child { width: 11px; height: 11px; color: var(--ghost); }
.studio-model-control .model-picker .nm { max-width: 125px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.studio-model-control .model-picker .nm { max-width: 125px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.studio-model-control .model-picker {
min-height: 28px;
justify-content: flex-start;
@@ -3485,7 +3528,7 @@ body { font-size: 14px; line-height: 1.75; }
.run-signal-line button i { position: absolute; z-index: 1; left: 50%; top: 50%; width: 9px; height: 9px; translate: -50% -50%; rotate: 45deg; border: 2px solid var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); }
.run-signal-line button:hover i,
.run-signal-line button:focus-visible i { scale: 1.22; }
.run-rounds ol { list-style: none; margin: 0; padding: 0; }
.run-rounds ol { list-style: none; margin: 0; padding: 0; overflow-anchor: none; }
.run-rounds li { border-radius: 0; border-bottom: 1px solid var(--hair); }
.run-rounds li:last-child { border-bottom: 0; }
.run-rounds li:hover { background: var(--overlay); }
@@ -3511,7 +3554,8 @@ body { font-size: 14px; line-height: 1.75; }
.run-round-copy b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) 11.5px var(--mono); }
.run-round-copy small { flex: none; color: var(--faint); font: var(--w-reg) 10px var(--mono); }
.run-round-kind { justify-self: end; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ra-color); font: var(--w-reg) 9.5px var(--mono); }
.run-inspect { margin-top: 14px; padding: 14px 15px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--raised) 84%, transparent); }
.run-inspect { margin: 14px 0 0; padding: 14px 15px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--raised) 84%, transparent); }
.run-rounds .run-inspect { margin: 2px 10px 10px 44px; }
.run-inspect > header { display: flex; justify-content: space-between; gap: 12px; color: var(--text); font: var(--w-reg) 11px var(--mono); }
.run-inspect-facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 11px; }
.run-inspect-facts span { display: grid; gap: 2px; }
@@ -3605,6 +3649,7 @@ body { font-size: 14px; line-height: 1.75; }
.grp .now,
.vway .lb,
.addp .fields > label > span,
.addp .fields .name-cell > label > span,
.addp-section-head span { font-size: var(--prefs-meta); }
.addp-head strong,
.addp-section-head strong,
@@ -3998,6 +4043,7 @@ body { font-size: 14px; line-height: 1.75; }
.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; }
:root[data-theme="light"] .refine-card { background: var(--page); }
@media (hover: none) {
.flow .user-acts,
.wsdel,
.rail .sessrow > .wsdel,
.rail .session-more,
@@ -4069,3 +4115,38 @@ body { font-size: 14px; line-height: 1.75; }
.studio-effort-menu .mi[aria-checked] .studio-item-badge { background: var(--overlay); color: var(--warn-ink); }
.studio-effort-menu .mi[aria-checked] { position: sticky; bottom: 0; z-index: 1; background: var(--raised); box-shadow: 0 -1px 0 var(--border); }
.studio-effort-menu .mi[aria-checked]:not(:disabled):hover { background: var(--overlay); }
.bound .out-more { margin: 0 0 0 8px; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
.bound .out-more:hover { background: var(--overlay); color: var(--text); }
.flow .bound .out-more { padding: 0 4px; }
.ag-open {
flex: none; width: 22px; height: 22px; display: grid; place-items: center; margin: -3px -4px -3px 0;
padding: 0; border: 0; border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer;
}
.ag-open .studio-icon { width: 11px; height: 11px; }
.ag-open:hover { background: var(--overlay); color: var(--text); }
.ag-open:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
@media (pointer: coarse) { .ag-open { width: 32px; height: 32px; margin: -8px -8px -8px 0; } }
.agent-tx-scrim {
position: fixed; inset: var(--chrome-h, 39px) 0 0; z-index: 60; display: grid; place-items: center; padding: 28px;
background: color-mix(in srgb, var(--page) 40%, transparent);
-webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92);
}
.agent-tx {
width: min(720px, 100%); max-height: 100%; display: flex; flex-direction: column; overflow: hidden;
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float);
}
.agent-tx-hd { display: flex; align-items: center; gap: 9px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--hair); }
.agent-tx-hd .who { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--deleg); }
.agent-tx-hd .rt { margin-left: auto; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint); }
.agent-tx-x {
flex: none; width: 26px; height: 26px; display: grid; place-items: center; padding: 0; border: 0;
border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer;
}
.agent-tx-x .studio-icon { width: 12px; height: 12px; }
.agent-tx-x:hover { background: var(--overlay); color: var(--text); }
.agent-tx-x:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
.agent-tx-bd { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 14px 10px; }
.agent-tx .nest-ret { flex: none; max-height: 30vh; overflow-y: auto; margin: 4px 14px 14px; white-space: pre-wrap; }
.nest-bd :is(.term, .term-l) { white-space: pre-wrap; overflow-wrap: anywhere; }
.agent-tx .call { content-visibility: auto; contain-intrinsic-size: auto 80px; }
.agent-tx:focus { outline: none; }
+26
View File
@@ -0,0 +1,26 @@
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import { AuthorLine } from "./AuthorLine";
import { Community } from "./Community";
import { Group } from "./Group";
import { Versions } from "./Versions";
export function About({ port }: { port: AgentPort }) {
return (
<>
<Group id="versions"
title={t("版本")}
hint={t("当前安装的版本、可用更新,以及出现问题时如何回退。更新下载好后由你决定何时重启;回退后会固定在所选版本,不再提示新版本。")}
>
<Versions port={port} />
</Group>
<AuthorLine port={port} />
<Group id="community"
title={t("社区与贡献者")}
hint={t("遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。")}
>
<Community port={port} />
</Group>
</>
);
}
+39 -13
View File
@@ -52,6 +52,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
});
const look = async () => {
if (busy || !text.trim()) return;
setBusy(true);
setError("");
try {
@@ -90,8 +91,17 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
};
const pick = async () => {
const dir = await port.pickFolder();
if (dir) setText(dir);
if (busy) return;
setBusy(true);
setError("");
try {
const dir = await port.pickFolder();
if (dir) setText(dir);
} catch (e) {
setError(reason(e));
} finally {
setBusy(false);
}
};
// An update has nothing to type: its source was recorded at install. Going
@@ -106,7 +116,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
// you agree to install it. A tab has no path to fill it with, so nothing is
// typed there and the box still takes an address.
const [over, setOver] = useState(false);
const drop = useFileDrop((d) => d.paths[0] && setText(d.paths[0]), setOver);
const drop = useFileDrop((d) => !busy && d.paths[0] && setText(d.paths[0]), setOver);
if (done) {
return (
@@ -160,7 +170,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
<button
className="act"
data-action={updating ? "extensions.cancel" : "extensions.back"}
disabled={!!updating && busy}
disabled={busy}
onClick={() => (updating ? onClose() : setPlan(null))}
>
{t(updating ? "取消" : "返回")}
@@ -200,22 +210,26 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
}
return (
<div className="addpkg" data-stage="paste" ref={drop} data-over={over ? "" : undefined}>
<div className="addpkg" data-stage="paste" ref={drop} data-over={over ? "" : undefined} aria-busy={busy}>
<textarea
className="paste"
data-action-keydown="extensions.inspect"
rows={3}
autoFocus
value={text}
disabled={busy}
placeholder={PLACEHOLDER}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void look();
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void look();
}
}}
/>
<div className="acts">
<span className="note">{t("仓库地址,或将文件夹拖入此处")}</span>
<button className="act" data-action="extensions.pick-folder" onClick={() => void pick()}>
<button className="act" data-action="extensions.pick-folder" disabled={busy} onClick={() => void pick()}>
{t("选文件夹")}
</button>
<button className="act" data-action="extensions.cancel" onClick={onClose}>
@@ -231,7 +245,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
{t(busy ? "读取中…" : "查看内容")}
</button>
</div>
{error && <div className="why">{error}</div>}
{error && <div className="why" role="alert">{error}</div>}
</div>
);
}
@@ -358,16 +372,28 @@ function contributes(a: PluginAction): string[] {
// leaves the package on disk and out of this session, and saying so is the
// difference between waiting and re-installing.
export function Outcome({ plan }: { plan: PluginPlan }) {
const state = plan.reloadError ? "action_required" : plan.ok ? "ready" : "issue";
return (
const state = !plan.ok ? "issue" : plan.reloadError ? "action_required" : "ready";
const failed = plan.actions?.filter((action) => action.status === "failed") ?? [];
return <>
<div className="outcome" data-state={state}>
<i className="pip" />
<span className="nm">{plan.actions?.[0]?.name || t("安装")}</span>
<span className="dt">
{state === "ready" && t("装好了,下一轮就能用")}
{state === "action_required" && t("装好了,但这一轮还在跑:等它结束或新建会话后生效")}
{state === "issue" && (plan.error || plan.next || t("没装上"))}
{state === "action_required" && t("装好了,但运行时未重载:{reason}。请用「重载运行时」重试。", { reason: plan.reloadError! })}
{state === "issue" && (plan.error || (failed.length ? t("有项目未安装成功,原因见下方。") : plan.next || t("没装上")))}
</span>
</div>
);
{failed.map((action, i) => (
<div className="why" role="alert" key={`${action.kind}:${action.name}:${i}`}>
{action.name || action.kind}: {action.error || action.next || t("没装上")}
{action.error && action.next && <div>{action.next}</div>}
</div>
))}
{state === "issue" && plan.reloadError && (
<div className="why" role="status">
{t("运行时未重载:{reason}。请用「重载运行时」重试。", { reason: plan.reloadError })}
</div>
)}
</>;
}
@@ -64,3 +64,130 @@ it("keeps relay-only controls optional and saves them when disclosed", async ()
headers: { "X-Title": "Reasonix" },
}));
});
const nameCatalog = async () => [{ kind: "openai", discovery: "openai", serverWebSearch: false, reasoningParams: true }];
it("explains an unusable source name next to the field while it is typed and holds the add button", async () => {
const saveProvider = vi.fn(async () => {});
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
const field = screen.getByLabelText("来源名称");
await userEvent.type(field, "公司中转站");
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
expect(field.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByText(/名称只能用字母、数字、点、连字符和下划线/)).toBeTruthy();
expect((screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByText("无法连接")).toBeNull();
await userEvent.clear(field);
await userEvent.type(field, "company-relay");
expect(field.getAttribute("aria-invalid")).toBeNull();
expect((screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement).disabled).toBe(false);
});
it("shows a coded name refusal from the kernel on the name field, not as a connection failure", async () => {
const { HttpError } = await import("../port/http_error");
const saveProvider = vi.fn(async () => {
throw new HttpError(409, "taken", { code: "provider.name_taken", params: { name: "relay" } });
});
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
await userEvent.type(screen.getByLabelText("来源名称"), "relay");
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
const field = screen.getByLabelText("来源名称");
await waitFor(() => expect(field.getAttribute("aria-invalid")).toBe("true"));
expect(screen.getByText("已经有名为「relay」的连接了,换一个名称")).toBeTruthy();
expect(screen.queryByText("无法连接")).toBeNull();
expect(screen.queryByText("无法保存")).toBeNull();
});
it("titles a failed save as a save failure, never as a connection failure", async () => {
const { HttpError } = await import("../port/http_error");
const saveProvider = vi.fn(async () => {
throw new HttpError(422, "pick at least one model", { code: "provider.endpoint_required" });
});
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
await userEvent.type(screen.getByLabelText("来源名称"), "relay");
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
expect(await screen.findByText("无法保存")).toBeTruthy();
expect(screen.queryByText("无法连接")).toBeNull();
});
it("ties the name rule to the field and the button, and waits out IME composition", async () => {
const { fireEvent } = await import("@testing-library/react");
const port = { protocols: vi.fn(nameCatalog), saveProvider: vi.fn() } as unknown as Port;
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
const field = screen.getByLabelText("来源名称");
expect(field.getAttribute("aria-describedby")).toBe("addp-name-rule");
fireEvent.compositionStart(field);
fireEvent.change(field, { target: { value: "gong'si" } });
expect(field.getAttribute("aria-invalid")).toBeNull();
fireEvent.compositionEnd(field);
expect(field.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByRole("button", { name: "添加来源" }).getAttribute("aria-describedby")).toBe("addp-name-rule");
});
it("keeps Add disabled while an IME name is still being composed", async () => {
const { fireEvent } = await import("@testing-library/react");
const saveProvider = vi.fn();
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
const name = screen.getByLabelText("来源名称");
fireEvent.change(name, { target: { value: "company-relay" } });
fireEvent.change(screen.getByLabelText("接口地址"), { target: { value: "https://relay.example/v1" } });
const model = screen.getByRole("searchbox", { name: "搜索或添加模型" });
fireEvent.change(model, { target: { value: "m1" } });
fireEvent.keyDown(model, { key: "Enter", code: "Enter", charCode: 13 });
const add = screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement;
await waitFor(() => expect(add.disabled).toBe(false));
fireEvent.compositionStart(name);
fireEvent.change(name, { target: { value: "公司中转站" } });
expect(add.disabled).toBe(true);
fireEvent.click(add);
expect(saveProvider).not.toHaveBeenCalled();
fireEvent.compositionEnd(name);
expect(add.disabled).toBe(true);
fireEvent.change(name, { target: { value: "company-relay" } });
expect(add.disabled).toBe(false);
});
it("normalizes spaces around an ASCII name and refuses a blank one", async () => {
const { fireEvent } = await import("@testing-library/react");
const saveProvider = vi.fn(async () => {});
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
const name = screen.getByLabelText("来源名称");
const add = screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement;
expect(add.disabled).toBe(true);
fireEvent.change(name, { target: { value: " " } });
expect(add.disabled).toBe(true);
fireEvent.change(name, { target: { value: " company-relay.1_x " } });
fireEvent.change(screen.getByLabelText("接口地址"), { target: { value: "https://relay.example/v1" } });
const model = screen.getByRole("searchbox", { name: "搜索或添加模型" });
fireEvent.change(model, { target: { value: "m1" } });
fireEvent.keyDown(model, { key: "Enter", code: "Enter", charCode: 13 });
await waitFor(() => expect(add.disabled).toBe(false));
fireEvent.click(add);
await waitFor(() => expect(saveProvider).toHaveBeenCalledWith(expect.objectContaining({
name: "company-relay.1_x",
})));
});
+26 -6
View File
@@ -2,14 +2,17 @@ import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { Protocol, ProviderEntry, ProviderProbe } from "../port/port";
import { clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
import { KIND_LABEL, hostOf, nameFrom, vendorLabel } from "./vendors";
import { KIND_LABEL, hostOf, nameFrom, sourceNameUsable, vendorLabel } from "./vendors";
import type { Port } from "./Providers";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/http_error";
import { THINKING, parseExtraBody, parseHeaders } from "./provider_compat";
// Detection is assistance, not authority. Compatible relays often expose one
// model-list shape while expecting another request protocol, so every durable
// value remains editable and a failed probe never blocks manual setup.
const NAME_CODES = new Set(["provider.name_required", "provider.name_invalid", "provider.name_taken"]);
export function AddProvider({
port, taken, known, onDone, onCancel,
}: {
@@ -20,6 +23,9 @@ export function AddProvider({
const [probe, setProbe] = useState<ProviderProbe | null>(null);
const [catalog, setCatalog] = useState<Protocol[]>([]);
const [err, setErr] = useState("");
const [errOnSave, setErrOnSave] = useState(false);
const [nameErr, setNameErr] = useState("");
const [composing, setComposing] = useState(false);
const [busy, setBusy] = useState(false);
// The endpoint says which wires are on the table and the kernel says what
@@ -52,6 +58,7 @@ export function AddProvider({
const [heads, setHeads] = useState("");
const [extra, setExtra] = useState("");
const [noProxy, setNoProxy] = useState(false);
const nameBad = !composing && name.trim() !== "" && !sourceNameUsable(name);
const extraBad = extra.trim() !== "" && parseExtraBody(extra) === null;
const protocolCanThink = catalog.find((p) => p.kind === kind)?.reasoningParams ?? false;
// A wire with no listing shape has nothing to read: the model id is declared
@@ -66,6 +73,7 @@ export function AddProvider({
const connect = async () => {
setBusy(true);
setErr("");
setErrOnSave(false);
try {
const got = await port.probeProvider(baseUrl.trim(), apiKey.trim());
setProbe(got);
@@ -87,6 +95,8 @@ export function AddProvider({
const save = async () => {
setBusy(true);
setErr("");
setNameErr("");
setErrOnSave(true);
try {
await port.saveProvider({
name: name.trim(),
@@ -107,7 +117,8 @@ export function AddProvider({
});
onDone();
} catch (e) {
setErr(reason(e));
if (e instanceof HttpError && e.reason?.code && NAME_CODES.has(e.reason.code)) setNameErr(reason(e));
else setErr(reason(e));
} finally {
setBusy(false);
}
@@ -167,18 +178,27 @@ export function AddProvider({
<button className="addp-close" onClick={onCancel} disabled={busy || checkingModel !== ""} aria-label={t("取消")}>×</button>
</div>
<div className="fields">
<label className="grow">
<div className="grow name-cell">
<label>
<span>{t("来源名称")}</span>
<input
aria-label={t("来源名称")}
data-action="provider.draft" data-value="name"
value={name}
placeholder={t("例如:公司中转站")}
onChange={(e) => setName(e.target.value)}
onChange={(e) => { setName(e.target.value); setNameErr(""); }}
disabled={busy || checkingModel !== ""}
spellCheck={false}
aria-invalid={nameBad || nameErr !== "" || undefined}
aria-describedby="addp-name-rule"
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
/>
</label>
<p id="addp-name-rule" className="name-rule" data-bad={nameBad || nameErr !== "" || undefined} aria-live="polite">
{nameErr !== "" ? nameErr : t("名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符")}
</p>
</div>
<label className="grow">
<span>{t("接口协议")}</span>
<select aria-label={t("接口协议")} data-action="provider.draft" data-value="protocol" value={kind} onChange={(e) => {
@@ -333,7 +353,7 @@ export function AddProvider({
</div>
<div className="acts addp-footer">
<button className="act" data-action="provider.add" data-primary onClick={save} disabled={busy || checkingModel !== "" || picked.length === 0 || name.trim() === "" || kind === "" || baseUrl.trim() === "" || extraBad}>
<button className="act" data-action="provider.add" data-primary onClick={save} aria-describedby={nameBad ? "addp-name-rule" : undefined} disabled={busy || composing || checkingModel !== "" || picked.length === 0 || name.trim() === "" || nameBad || kind === "" || baseUrl.trim() === "" || extraBad}>
{t(busy ? "保存中…" : "添加来源")}
</button>
<button className="act" onClick={onCancel} disabled={busy || checkingModel !== ""}>{t("取消")}</button>
@@ -341,7 +361,7 @@ export function AddProvider({
{err && (
<div className="find" data-lvl="warn">
<span className="t">{t("无法连接")}</span>
<span className="t">{errOnSave ? t("无法保存") : t("无法连接")}</span>
<span className="why">{err}</span>
</div>
)}
+26 -13
View File
@@ -26,7 +26,13 @@ interface Props {
onInstalled: () => void;
}
export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
export function AddServer(props: Props) {
const [connection, setConnection] = useState({ port: props.port, generation: 0 });
if (connection.port !== props.port) setConnection({ port: props.port, generation: connection.generation + 1 });
return <ServerInput key={connection.generation} {...props} />;
}
function ServerInput({ port, canProject, onClose, onInstalled }: Props) {
// Mounted only while open, so this component is the layer Escape closes.
useEscape(true, onClose);
const [text, setText] = useState("");
@@ -35,8 +41,11 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [results, setResults] = useState<McpInstallResult[]>([]);
const remaining = draft?.servers.filter((s) => !results.some((r) => r.name === s.name && r.state !== "issue")) ?? [];
const hasSaved = results.some((r) => r.state !== "issue");
const parse = async () => {
if (busy || !text.trim()) return;
setBusy(true);
setError("");
try {
@@ -53,19 +62,19 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
if (!draft) return;
setBusy(true);
setError("");
const out: McpInstallResult[] = [];
try {
const out: McpInstallResult[] = [];
for (const s of draft.servers) out.push(await port.installMcp(s, scope));
setResults(out);
if (out.some((r) => r.state !== "issue")) onInstalled();
for (const s of remaining) out.push(await port.installMcp(s, scope));
} catch (e) {
setError(reason(e));
} finally {
setResults((previous) => [...new Map([...previous, ...out].map((r) => [r.name, r])).values()]);
setBusy(false);
if (out.some((r) => r.state !== "issue")) onInstalled();
}
};
if (results.length > 0) {
if (results.length > 0 && remaining.length === 0 && !error && !busy) {
return (
<div className="addsrv" data-stage="done">
{results.map((r) => (
@@ -81,7 +90,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
}
return (
<div className="addsrv" data-stage={draft ? "confirm" : "paste"}>
<div className="addsrv" data-stage={draft ? "confirm" : "paste"} aria-busy={busy}>
{!draft && (
<>
<textarea
@@ -93,7 +102,10 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
placeholder={PLACEHOLDER}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void parse();
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void parse();
}
}}
/>
<div className="acts">
@@ -110,7 +122,8 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
{draft && (
<>
{draft.servers.map((s) => (
{results.map((r) => <Outcome key={r.name} r={r} />)}
{remaining.map((s) => (
<div className="cand" key={s.name}>
<div className="cand-hd">
<span className="nm">{s.name}</span>
@@ -131,13 +144,13 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
question. Only the third option edits a tracked file, so it is the
one that has to say so out loud — and it is never the default. */}
<div className="scope" role="radiogroup" aria-label={t("安装位置")}>
<button role="radio" aria-checked={scope === "user"} onClick={() => setScope("user")}>
<button role="radio" aria-checked={scope === "user"} disabled={busy || hasSaved} onClick={() => setScope("user")}>
{t("我的")}<i>{t("所有项目均可使用")}</i>
</button>
<button
role="radio"
aria-checked={scope === "local"}
disabled={!canProject}
disabled={busy || hasSaved || !canProject}
onClick={() => setScope("local")}
>
{t("仅当前项目")}<i>{t("不写入仓库,他人不会获得")}</i>
@@ -145,7 +158,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
<button
role="radio"
aria-checked={scope === "project"}
disabled={!canProject}
disabled={busy || hasSaved || !canProject}
onClick={() => setScope("project")}
>
{t("写进仓库")}<i>{t("clone 仓库的人也会获得")}</i>
@@ -155,7 +168,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
<div className="warn">{t("这会修改仓库中的配置文件,属于一处待提交的改动。")}</div>
)}
<div className="acts">
<button className="act" onClick={() => setDraft(null)}>
<button className="act" disabled={busy} onClick={() => { setDraft(null); setResults([]); setError(""); }}>
{t("返回")}
</button>
<button className="act" data-action="mcp.add" data-primary disabled={busy} onClick={() => void install()}>
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
interface Props {
busy: boolean;
onSubmit: (path: string) => void;
onClose: () => void;
}
/** The picker-free path into a workspace. It only appears when the kernel says
* it has no native picker, so a desktop shell keeps its familiar dialog. */
export function AddWorkspacePrompt({ busy, onSubmit, onClose }: Props) {
const [path, setPath] = useState("");
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
input.current?.focus();
}, []);
return createPortal(
<div className="pickveil" role="dialog" aria-modal="true" aria-labelledby="addws-t">
<form
className="pickcard"
data-action="workspace.add"
onSubmit={(ev) => {
ev.preventDefault();
onSubmit(path);
}}
>
<h2 id="addws-t">{t("添加工作区")}</h2>
<p className="picknote">{t("无法打开文件夹选择器。请输入内核所在机器上的路径。")}</p>
<div className="pickpath">
<input
ref={input}
value={path}
spellCheck={false}
dir="ltr"
placeholder="/srv/project"
aria-label={t("工作区路径")}
data-action="workspace.add"
onChange={(ev) => setPath(ev.target.value)}
/>
<button type="submit" className="pickgo" data-action="workspace.add" disabled={busy || !path.trim()}>
{t("添加")}
</button>
</div>
<div className="pickact">
<button type="button" data-action="workspace.add-cancel" disabled={busy} onClick={onClose}>{t("取消")}</button>
</div>
</form>
</div>,
document.body,
);
}
+3
View File
@@ -25,6 +25,7 @@ import { usePaint } from "./paint";
import { rememberActivePane, savedActivePane } from "./activepane";
import { Sidebar } from "./Sidebar";
import { Sky } from "./Sky";import { useAddWorkspace } from "./addws";
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
import { PaneTabs } from "./PaneTabs";
import { Onboarding } from "./Onboarding";
import { Welcome } from "./Welcome";
@@ -754,6 +755,8 @@ export function App({ hub }: { hub: HubPort }) {
</div>
{adder.pathOpen && <AddWorkspacePrompt busy={adder.busy} onSubmit={adder.addPath} onClose={adder.closePath} />}
{feedback && (networkPort ?? activePort) && (
<Feedback port={(networkPort ?? activePort)!} tab={feedback} onClose={() => setFeedback(null)} onError={setError} onUnread={setFeedbackUnread} />
)}
@@ -0,0 +1,12 @@
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import { COMMUNITY } from "./communityLinks";
export function AuthorLine({ port }: { port: Pick<AgentPort, "openExternal"> }) {
return (
<div className="comm-author">
<span className="comm-k">{t("作者与维护者")}</span>
<a href={COMMUNITY.authorUrl} data-action="community.author" onClick={(e) => { e.preventDefault(); void port.openExternal(COMMUNITY.authorUrl).catch(() => {}); }}>{COMMUNITY.author}</a>
</div>
);
}
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { BackupRestore } from "./BackupRestore";
import { MockPort } from "../port/mock";
import type { AgentPort, BackupEntry, PluginPlan } from "../port/port";
afterEach(cleanup);
const backup: BackupEntry = {
id: "plugins", label: "laptop", format: 1, appVersion: "2.20.5", platform: "darwin/arm64",
categories: ["extensions"], ciphertextBytes: 2048, createdAt: "2026-09-27T00:00:00Z",
};
const sources = { alpha: "/packages/alpha", beta: "/packages/beta" };
function plan(name: keyof typeof sources): PluginPlan {
return {
ok: true, status: "planned", applied: false, planId: `ticket-${name}`, actions: [{
kind: "plugin", action: "install_plugin_package", name, status: "planned", riskLevel: "low", skillCount: 1,
}],
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
async function setup() {
const port = new MockPort() as unknown as AgentPort;
vi.spyOn(port, "applyBackup").mockResolvedValue({
applied: ["plugins"], plugins: Object.entries(sources).map(([name, source]) => ({ name, source })),
});
const preview = vi.spyOn(port, "planPlugin").mockImplementation(async ({ source }) => plan(source === sources.alpha ? "alpha" : "beta"));
const install = vi.spyOn(port, "installPlugin").mockResolvedValue({ ...plan("alpha"), status: "done", applied: true, actions: [] });
render(<BackupRestore port={port} backup={backup} onClose={() => {}} />);
await userEvent.type(screen.getByLabelText("加密口令"), "local passphrase");
await userEvent.click(screen.getByRole("button", { name: "预览差异" }));
await userEvent.click(await screen.findByRole("button", { name: /恢复所选/ }));
await screen.findByText("以下插件需要走正常的安装确认:");
return { port, preview, install };
}
async function select(name: keyof typeof sources) {
await userEvent.click(document.querySelector<HTMLButtonElement>(`[data-action="backup.install-plugin"][data-target="${name}"]`)!);
}
describe("restored plugin installation ownership", () => {
it("switches the source before inspection and disables the already selected row", async () => {
const { preview, install } = await setup();
await select("alpha");
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.alpha);
expect(document.querySelector<HTMLButtonElement>('[data-action="backup.install-plugin"][data-target="alpha"]')!.disabled).toBe(true);
await select("beta");
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.beta);
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await screen.findByRole("button", { name: "安装" });
expect(preview).toHaveBeenCalledOnce();
expect(preview).toHaveBeenCalledWith(expect.objectContaining({ source: sources.beta }));
expect(install).not.toHaveBeenCalled();
});
it("discards the previous confirmation and applies only the newly inspected ticket", async () => {
const { preview, install } = await setup();
await select("alpha");
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await screen.findByRole("button", { name: "安装" });
await select("beta");
expect(screen.queryByRole("button", { name: "安装" })).toBeNull();
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.beta);
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull());
expect(preview).toHaveBeenCalledTimes(2);
expect(install).toHaveBeenCalledOnce();
expect(install).toHaveBeenCalledWith(expect.objectContaining({ source: sources.beta, planId: "ticket-beta" }));
});
it.each(["success", "failure"])("ignores an old pending preview's %s after switching", async (outcome) => {
const { preview, install } = await setup();
const pending = deferred<PluginPlan>();
preview.mockImplementationOnce(() => pending.promise);
await select("alpha");
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await select("beta");
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.beta);
await act(async () => {
if (outcome === "success") pending.resolve(plan("alpha"));
else pending.reject(new Error("old alpha preview failed"));
await pending.promise.catch(() => {});
});
expect(screen.queryByText("old alpha preview failed")).toBeNull();
expect(screen.queryByRole("button", { name: "安装" })).toBeNull();
expect(screen.getByRole<HTMLButtonElement>("button", { name: "查看内容" }).disabled).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
expect(install).toHaveBeenCalledWith(expect.objectContaining({ source: sources.beta, planId: "ticket-beta" }));
});
it.each(["success", "failure", "refusal"])("keeps the new confirmation when an old install returns %s", async (outcome) => {
const { install } = await setup();
const pending = deferred<PluginPlan>();
install.mockImplementationOnce(() => pending.promise);
await select("alpha");
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
await select("beta");
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await screen.findByRole("button", { name: "安装" });
await act(async () => {
if (outcome === "failure") pending.reject(new Error("old alpha install failed"));
else pending.resolve({ ...plan("alpha"), ok: outcome === "success", status: outcome === "success" ? "done" : "blocked", applied: outcome === "success", actions: [] });
await pending.promise.catch(() => {});
});
expect(screen.queryByText("old alpha install failed")).toBeNull();
expect(screen.getByRole<HTMLButtonElement>("button", { name: "安装" }).disabled).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "安装" }));
expect(install).toHaveBeenNthCalledWith(1, expect.objectContaining({ source: sources.alpha, planId: "ticket-alpha" }));
expect(install).toHaveBeenNthCalledWith(2, expect.objectContaining({ source: sources.beta, planId: "ticket-beta" }));
});
it("does not dismiss a later selection of the same plugin when its first install completes", async () => {
const { install } = await setup();
const pending = deferred<PluginPlan>();
install.mockImplementationOnce(() => pending.promise);
await select("alpha");
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
await select("beta");
await select("alpha");
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
await screen.findByRole("button", { name: "安装" });
await act(async () => { pending.resolve({ ...plan("alpha"), status: "done", applied: true, actions: [] }); await pending.promise; });
expect(screen.getByRole<HTMLButtonElement>("button", { name: "安装" }).disabled).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "安装" }));
expect(install).toHaveBeenCalledTimes(2);
});
});
@@ -99,7 +99,7 @@ export function BackupRestore({ port, backup, onClose }: { port: AgentPort; back
<span className="nm">{p.name}</span>
<code className="meta">{p.source}{p.version ? " · " + p.version : ""}</code>
</div>
<button className="btn sm" data-action="backup.install-plugin" data-target={p.name} onClick={() => setInstalling(p)}>
<button className="btn sm" data-action="backup.install-plugin" data-target={p.name} disabled={installing?.name === p.name} onClick={() => setInstalling({ ...p })}>
{t("安装…")}
</button>
</li>
@@ -111,7 +111,7 @@ export function BackupRestore({ port, backup, onClose }: { port: AgentPort; back
<button className="act" data-action="backup.done" onClick={onClose}>{t("完成")}</button>
</div>
{installing && (
<AddPlugin port={port} source={installing.source} onClose={() => setInstalling(null)} onInstalled={() => setInstalling(null)} />
<AddPlugin key={installing.name} port={port} source={installing.source} onClose={() => setInstalling(null)} onInstalled={() => setInstalling((current) => current === installing ? null : current)} />
)}
</div>
);
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Community } from "./Community";
import { AuthorLine } from "./AuthorLine";
import { profileUrl } from "./communityLinks";
import contributors from "../data/contributors.json";
import type { AgentPort } from "../port/port";
const QR = import.meta.glob("../assets/qq-group-qr.svg", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const portWith = (openExternal = vi.fn(async () => {})) => ({ openExternal }) as unknown as Pick<AgentPort, "openExternal">;
describe("community and contributors", () => {
it("names the group and shows its number", () => {
render(<Community port={portWith()} logins={["a"]} />);
expect(screen.getByText("DeepSeek-Reasonix官方群")).toBeTruthy();
expect(screen.getByText("1093562660")).toBeTruthy();
});
it("opens the join link, Discord and issues through the host", async () => {
const openExternal = vi.fn(async () => {});
render(<Community port={portWith(openExternal)} logins={["a"]} />);
await userEvent.click(screen.getByRole("button", { name: "加入 QQ 群" }));
expect(openExternal).toHaveBeenLastCalledWith("https://qm.qq.com/q/i59b0z2R8s");
await userEvent.click(screen.getByRole("button", { name: "Discord" }));
expect(openExternal).toHaveBeenLastCalledWith("https://discord.gg/XF78rEME2D");
await userEvent.click(screen.getByRole("button", { name: "GitHub issues" }));
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues");
});
it("says so, with the address, when the host cannot open it", async () => {
const openExternal = vi.fn(async () => { throw new Error("no browser"); });
render(<Community port={portWith(openExternal)} logins={[]} />);
await userEvent.click(screen.getByRole("button", { name: "Discord" }));
expect((await screen.findByRole("alert")).textContent).toContain("https://discord.gg/XF78rEME2D");
});
it("copies the group number", async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
render(<Community port={portWith()} logins={[]} />);
await userEvent.click(screen.getByRole("button", { name: "复制群号" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("1093562660"));
});
it("references a QR image that exists", () => {
render(<Community port={portWith()} logins={[]} />);
const img = screen.getByRole("img", { name: "QQ 群二维码" }) as HTMLImageElement;
expect(img.getAttribute("src")).toBeTruthy();
expect(Object.values(QR)[0]).toMatch(/^<svg /);
});
it("shows the Douyin ID, copies it, and keeps the QR hidden until asked", async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
render(<Community port={portWith()} logins={[]} />);
expect(screen.getByText("做游戏的小鱼")).toBeTruthy();
expect(screen.getByText("22703872788")).toBeTruthy();
expect(screen.queryByRole("img", { name: "抖音二维码" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "复制抖音号" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("22703872788"));
const toggle = screen.getByRole("button", { name: "显示二维码" });
await userEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect((screen.getByRole("img", { name: "抖音二维码" }) as HTMLImageElement).getAttribute("src")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "收起二维码" }));
expect(screen.queryByRole("img", { name: "抖音二维码" })).toBeNull();
});
it("has no Douyin link, only the ID", () => {
const { container } = render(<Community port={portWith()} logins={[]} />);
expect(container.innerHTML).not.toMatch(/douyin\.com/);
});
it("links the author to their GitHub profile through the host", async () => {
const openExternal = vi.fn(async () => {});
render(<AuthorLine port={portWith(openExternal)} />);
expect(screen.getByText("作者与维护者")).toBeTruthy();
const link = screen.getByRole("link", { name: "esengine" });
expect(link.getAttribute("href")).toBe("https://github.com/esengine");
await userEvent.click(link);
expect(openExternal).toHaveBeenCalledWith("https://github.com/esengine");
});
it("lists contributors as links to their exact profiles", async () => {
const openExternal = vi.fn(async () => {});
render(<Community port={portWith(openExternal)} logins={["SivanCola", "o-n-e"]} />);
const link = screen.getByRole("link", { name: "SivanCola" });
expect(link.getAttribute("href")).toBe("https://github.com/SivanCola");
await userEvent.click(link);
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/SivanCola");
await userEvent.click(screen.getByRole("button", { name: "查看全部贡献者" }));
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors");
});
it("never turns a hostile login into a URL", () => {
const hostile = ["a/b", 'x"y', "../../evil", "-lead", "trail-", "a--b", "", "x".repeat(40), "https://evil.test"];
for (const login of hostile) expect(profileUrl(login)).toBeNull();
expect(profileUrl("A-b1")).toBe("https://github.com/A-b1");
render(<Community port={portWith()} logins={[...hostile, "good"]} />);
const people = screen.getByRole("list", { name: "贡献者" });
expect(within(people).getAllByRole("link").map((a) => a.getAttribute("href"))).toEqual(["https://github.com/good"]);
});
it("collapses a long list and expands it on request", async () => {
const logins = Array.from({ length: 60 }, (_, i) => `user${i}`);
render(<Community port={portWith()} logins={logins} />);
const people = screen.getByRole("list", { name: "贡献者" });
expect(within(people).getAllByRole("link")).toHaveLength(24);
await userEvent.click(screen.getByRole("button", { name: "显示全部 60 位" }));
expect(within(people).getAllByRole("link")).toHaveLength(60);
await userEvent.click(screen.getByRole("button", { name: "收起" }));
expect(within(people).getAllByRole("link")).toHaveLength(24);
});
it("offers no toggle for a short list", () => {
render(<Community port={portWith()} logins={["a", "b"]} />);
expect(screen.queryByRole("button", { name: /显示全部/ })).toBeNull();
});
it("ships a contributors file whose every entry is a valid login", () => {
expect(contributors.length).toBeGreaterThan(0);
for (const login of contributors) expect(profileUrl(login)).not.toBeNull();
expect(contributors.map((l) => l.toLowerCase())).not.toContain("esengine");
});
});
@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import contributors from "../data/contributors.json";
import qrSrc from "../assets/qq-group-qr.svg";
import douyinSrc from "../assets/douyin-qr.jpg";
import { CopyButton } from "./CopyButton";
import { COMMUNITY, profileUrl } from "./communityLinks";
const COLLAPSED = 24;
export function Community({ port, logins = contributors }: { port: Pick<AgentPort, "openExternal">; logins?: string[] }) {
const [all, setAll] = useState(false);
const [failed, setFailed] = useState("");
const [douyinQr, setDouyinQr] = useState(false);
const open = (url: string) =>
void port.openExternal(url).then(() => setFailed(""), () => setFailed(t("无法打开浏览器,请手动访问 {at}", { at: url })));
const people = useMemo(
() => logins.flatMap((login) => { const url = profileUrl(login); return url ? [{ login, url }] : []; }),
[logins],
);
const shown = all ? people : people.slice(0, COLLAPSED);
return (
<div className="comm">
<div className="comm-join">
<img className="comm-qr" src={qrSrc} alt={t("QQ 群二维码")} width={185} height={185} />
<div className="comm-qq">
<span className="comm-name">{COMMUNITY.qqName}</span>
<span className="comm-num">
<span className="comm-k">{t("群号")}</span>
<code>{COMMUNITY.qqNumber}</code>
<CopyButton text={COMMUNITY.qqNumber} iconOnly showFeedback label={t("复制群号")} />
</span>
<span className="comm-how">{t("用 QQ 扫描二维码,或点击下方按钮加入。")}</span>
<span className="comm-acts">
<button type="button" className="btn" data-primary data-action="community.join" onClick={() => open(COMMUNITY.qqJoin)}>{t("加入 QQ 群")}</button>
<button type="button" className="btn" data-action="community.discord" onClick={() => open(COMMUNITY.discord)}>Discord</button>
<button type="button" className="btn" data-action="community.issues" onClick={() => open(COMMUNITY.issues)}>GitHub issues</button>
</span>
</div>
</div>
<div className="comm-dy">
<span className="comm-k">{t("抖音")}</span>
<span className="comm-dy-name">{COMMUNITY.douyinName}</span>
<span className="comm-k">{t("抖音号")}</span>
<code>{COMMUNITY.douyinId}</code>
<CopyButton text={COMMUNITY.douyinId} iconOnly showFeedback label={t("复制抖音号")} />
<button type="button" className="btn sm" data-action="community.douyin-qr" aria-expanded={douyinQr} aria-controls="comm-dy-qr" onClick={() => setDouyinQr((v) => !v)}>
{douyinQr ? t("收起二维码") : t("显示二维码")}
</button>
</div>
{douyinQr && (
<img id="comm-dy-qr" className="comm-dy-qr" src={douyinSrc} alt={t("抖音二维码")} width={180} height={269} />
)}
{failed && <p className="comm-fail" role="alert">{failed}</p>}
<div className="comm-who">
<div className="comm-who-hd">
<h4>{t("贡献者")}</h4>
<span className="comm-k">{t("按提交数排序,名单随版本更新")}</span>
<button type="button" className="btn sm" data-action="community.contributors" onClick={() => open(COMMUNITY.contributors)}>{t("查看全部贡献者")}</button>
</div>
<ul className="comm-list" aria-label={t("贡献者")}>
{shown.map(({ login, url }) => (
<li key={login}>
<a href={url} data-action="community.profile" data-target={login} onClick={(e) => { e.preventDefault(); open(url); }}>{login}</a>
</li>
))}
</ul>
{people.length > COLLAPSED && (
<button type="button" className="btn sm comm-more" data-action="community.more" aria-expanded={all} onClick={() => setAll((v) => !v)}>
{all ? t("收起") : t("显示全部 {n} 位", { n: people.length })}
</button>
)}
</div>
</div>
);
}
@@ -7,7 +7,7 @@ import { MockPort } from "../port/mock";
import type { AgentPort, ApprovalMode, Attachment, Completion, ModelEntry, Preset, SessionStatus } from "../port/port";
import { draftKey } from "./drafts";
afterEach(() => { cleanup(); localStorage.clear(); });
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
const status = (over: Partial<SessionStatus> = {}) =>
({
@@ -27,6 +27,19 @@ function deferred<T>() {
return { promise, resolve };
}
function touchPointer() {
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(pointer: coarse)",
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => false,
}) as MediaQueryList);
}
function draw(
over: { running?: boolean; onSubmit?: (text: string) => Promise<boolean>; port?: MockPort; st?: SessionStatus; changeCount?: number; host?: string } = {},
) {
@@ -58,6 +71,35 @@ describe("composer submission", () => {
expect((screen.getByRole("button", { name: "发送" }) as HTMLButtonElement).disabled).toBe(false);
});
it("keeps Enter in the textarea when a touch-first pointer has focus", () => {
touchPointer();
const { box, onSubmit } = draw();
fireEvent.change(box, { target: { value: "两行输入", selectionStart: 4 } });
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText("点按发送 · 回车换行")).toBeTruthy();
expect(box.hasAttribute("aria-keyshortcuts")).toBe(false);
});
it("still submits through the Send button on a touch-first pointer", async () => {
touchPointer();
const { box, onSubmit } = draw();
fireEvent.change(box, { target: { value: "点按发送", selectionStart: 4 } });
fireEvent.keyDown(box, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("点按发送"));
});
it("shows the tap-to-steer hint on a touch-first pointer during a live turn", () => {
touchPointer();
const { box, onSubmit } = draw({ running: true });
fireEvent.change(box, { target: { value: "补充一句", selectionStart: 4 } });
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText("点按插话 · 回车换行")).toBeTruthy();
});
it("locks repeated Enter presses until the first submit settles", async () => {
const pending = deferred<boolean>();
const onSubmit = vi.fn(() => pending.promise);
@@ -471,3 +513,31 @@ describe("a model mode switch", () => {
await waitFor(() => expect(set).toHaveBeenCalledWith(""));
});
});
describe("a line taken back from the queue", () => {
const props = () => ({ port: new MockPort() as unknown as AgentPort, status: status(), running: false, onSubmit: async () => true, onChanged: vi.fn(), onError: vi.fn() });
it("lands in the box", () => {
const view = render(<Composer {...props()} />);
view.rerender(<Composer {...props()} restore={{ n: 1, text: "取回的这一句" }} />);
expect((view.container.querySelector("textarea") as HTMLTextAreaElement).value).toBe("取回的这一句");
});
it("goes under what was typed meanwhile instead of replacing it", () => {
const p = props();
const view = render(<Composer {...p} />);
const box = view.container.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(box, { target: { value: "正在写的", selectionStart: 4 } });
view.rerender(<Composer {...p} restore={{ n: 1, text: "取回的这一句" }} />);
expect(box.value).toBe("正在写的\n取回的这一句");
});
it("restores the same text twice when it is taken back twice", () => {
const p = props();
const view = render(<Composer {...p} restore={{ n: 1, text: "甲" }} />);
const box = view.container.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(box, { target: { value: "", selectionStart: 0 } });
view.rerender(<Composer {...p} restore={{ n: 2, text: "甲" }} />);
expect(box.value).toBe("甲");
});
});
+22 -28
View File
@@ -10,6 +10,7 @@ import { CompletionMenu, useCompletion } from "./Completion";
import { ChipMirror, useSkillChips } from "./ChipMirror";
import { useIme } from "./ime";
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { kindOf, nameOf, previewURL } from "./chipfile";
import { useIntake } from "./useIntake";
import type { Dropped } from "./filedrop";
import type { Quote } from "./cards/SayCard";
@@ -17,6 +18,7 @@ import { StudioIcon } from "./StudioIcon";
import { usePromptRefine } from "./PromptRefine";
import { useProviderOrder } from "../state/providerorder";
import { useDraft } from "./useDraft";
import { touchKeyboard } from "./touchKeyboard";
interface Props {
port: AgentPort;
@@ -26,6 +28,9 @@ interface Props {
// twice two requests. Quoting the same reply again is an ordinary thing to
// do, and comparing the string alone would drop the second one.
quote?: Quote;
// A line taken back from the queue. The counter makes the same text twice
// two requests, as it does for a quote.
restore?: { n: number; text: string };
// Resolves false when the line never left, so what was typed comes back
// rather than being lost to a refusal the user could not have prevented.
// Bumped when something outside asks for the cursor — answering a plan card
@@ -58,30 +63,6 @@ type Chip =
| { k: "paste"; id: string; body: string; lines: number; name?: string }
| { k: "quote"; id: string; body: string; turn?: number; lines: number };
// Two screenshots pasted in a row are one filename apart, which is the one
// thing the chip has to tell them by. The preview comes off the blob that was
// just attached — the kernel keeps the bytes, this keeps a handle to look at.
function previewURL(blob: Blob): string | undefined {
try {
return URL.createObjectURL(blob);
} catch {
return undefined;
}
}
// A dropped file has no preview to stand behind: the host named it, it was
// never read. Its kind fills the square, because a blank one reads as an image
// that failed to load.
function kindOf(path: string): string {
const name = nameOf(path);
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
}
function nameOf(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}
function chipName(c: Chip): string {
if (c.k === "quote") return c.turn === undefined ? t("引用回复") : t("引用第 {n} 轮回复", { n: c.turn });
return c.k === "paste" ? c.name ?? t("粘贴的文本") : c.name;
@@ -110,7 +91,8 @@ function releaseChip(c: Chip) {
let chipSeq = 0;
const chipId = () => `c${++chipSeq}`;
export function Composer({ port, status, running, quote, focus, onSubmit, onChanged, onError, onSettings = () => {}, changeCount = 0, pulse = 0, draftKey = "" }: Props) {
export function Composer({ port, status, running, quote, restore, focus, onSubmit, onChanged, onError, onSettings = () => {}, changeCount = 0, pulse = 0, draftKey = "" }: Props) {
const touch = touchKeyboard();
const providerOrder = useProviderOrder();
const [branch, setBranch] = useState("");
useEffect(() => {
@@ -172,6 +154,16 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
queueMicrotask(() => box.current?.focus());
}, [quote?.n]);
useEffect(() => {
if (!restore?.n) return;
setText((prev) => {
const next = prev.trim() ? `${prev.replace(/\s+$/, "")}\n${restore.text}` : restore.text;
pending.current = next.length;
return next;
});
queueMicrotask(() => box.current?.focus());
}, [restore?.n]);
const moveTo = useCallback((next: string, at: number) => {
pending.current = at;
type(next, at);
@@ -528,7 +520,7 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
role="combobox"
aria-label={t("任务输入")}
aria-describedby={guide}
aria-keyshortcuts="Enter Shift+Enter"
aria-keyshortcuts={touch ? undefined : "Enter Shift+Enter"}
aria-busy={submitting}
readOnly={submitting}
aria-expanded={menu.open}
@@ -601,7 +593,7 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
menu.dismiss();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey && !touch) {
e.preventDefault();
send();
}
@@ -623,7 +615,9 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
? t("正在添加附件…")
: failed
? t("有附件添加失败,请重试或移除")
: t(running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行")}
: t(touch
? running ? "点按插话 · 回车换行" : "点按发送 · 回车换行"
: running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行")}
</span>
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
</div>
+5 -4
View File
@@ -18,7 +18,7 @@ export async function copyText(text: string) {
if (!ok) throw new Error("copy rejected");
}
export function CopyButton({ text, iconOnly = false, className, label: what }: { text: string; iconOnly?: boolean; className?: string; label?: string }) {
export function CopyButton({ text, iconOnly = false, showFeedback = false, className, label: what }: { text: string; iconOnly?: boolean; showFeedback?: boolean; className?: string; label?: string }) {
const [state, setState] = useState<"idle" | "done" | "failed">("idle");
const timer = useRef<number | null>(null);
@@ -34,7 +34,7 @@ export function CopyButton({ text, iconOnly = false, className, label: what }: {
// A clipboard the host denied is not the same as nothing happening, and the
// reader is about to try again — so the failure says so instead of staying idle.
const label = state === "done" ? t("已复制") : state === "failed" ? t("复制不了") : what ?? t("复制回复");
const label = state === "done" ? t("已复制") : state === "failed" ? t("复制失败,请重试") : what ?? t("复制回复");
return (
<button
@@ -42,12 +42,13 @@ export function CopyButton({ text, iconOnly = false, className, label: what }: {
type="button"
data-icon={iconOnly ? "" : undefined}
data-state={state}
data-feedback={showFeedback && state !== "idle" ? "" : undefined}
onClick={copy}
aria-label={what ?? t("复制这段回答")}
title={label}
>
{iconOnly && <StudioIcon name={state === "done" ? "check" : "copy"} />}
<span className={iconOnly ? "sr-only" : undefined} aria-live="polite">{label}</span>
{iconOnly && <StudioIcon name={state === "done" ? "check" : state === "failed" ? "warning" : "copy"} />}
<span className={iconOnly && !(showFeedback && state !== "idle") ? "sr-only" : undefined} aria-live="polite">{label}</span>
</button>
);
}
@@ -40,10 +40,12 @@ describe("the deck readings", () => {
expect(settled.querySelector('[data-action="deck.jobs"]')?.hasAttribute("data-live")).toBe(false);
});
// Delegates that stopped are history the transcript keeps; a reading left on
// the rail after they stopped reads as work still going.
it("shows the delegates only while one is running", () => {
// Finished delegates stay reachable (their transcript opens from here), but
// only a running one animates the reading.
it("keeps finished delegates listed and animates only while one is running", () => {
expect(draw([task(true)], []).querySelector('[data-action="deck.agents"]')?.hasAttribute("data-live")).toBe(true);
expect(draw([task(false)], []).querySelector('[data-action="deck.agents"]')).toBeNull();
const done = draw([task(false)], []).querySelector('[data-action="deck.agents"]');
expect(done).not.toBeNull();
expect(done?.hasAttribute("data-live")).toBe(false);
});
});
+16 -8
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JobEntry } from "../port/port";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import { agentsIn } from "./panels/derive";
import { Agents } from "./panels/Agents";
import { AgentTranscript } from "./panels/AgentTranscript";
import { Jobs } from "./panels/Jobs";
import type { Task } from "./panels/Agents";
import { useDismiss } from "./dismiss";
@@ -18,19 +19,25 @@ export type Deck = "" | "agents" | "jobs";
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void> }) {
const agentsBox = useRef<HTMLDivElement>(null);
const jobsBox = useRef<HTMLDivElement>(null);
const [viewing, setViewing] = useState("");
const viewed = viewing ? tasks.find((x) => x.id === viewing) : undefined;
useEffect(() => {
if (viewing && !viewed) setViewing("");
}, [viewing, viewed]);
const closeView = useCallback(() => {
setViewing("");
agentsBox.current?.querySelector<HTMLElement>('[data-action="deck.agents"]')?.focus();
}, []);
const shut = useCallback(() => onOpen(() => ""), [onOpen]);
useDismiss(open === "agents", agentsBox, shut);
useDismiss(open === "jobs", jobsBox, shut);
const liveAgents = useMemo(() => agentsIn(tasks.filter((x) => x.running)), [tasks]);
const liveJobs = useMemo(() => jobs.filter((j) => j.status === "running").length, [jobs]);
if (liveAgents === 0 && jobs.length === 0) return null;
if (tasks.length === 0 && jobs.length === 0) return null;
// Only while a delegate is running: a finished one is history, which the
// transcript and the side panel keep, and a reading left standing after the
// work stopped reads as work still going.
return (
<>
{liveAgents > 0 && (
{tasks.length > 0 && (
<div ref={agentsBox} className="studio-deck-anchor" data-open={open === "agents" ? "" : undefined}>
<button
type="button"
@@ -42,14 +49,15 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob }: { tasks: T
onClick={() => onOpen((was) => (was === "agents" ? "" : "agents"))}
>
<StudioIcon name="branch" />
<b>{liveAgents}</b>
<b>{agentsIn(tasks)}</b>
<span>{t("子代理")}</span>
</button>
<div className="studio-deck-pop" role="dialog" aria-label={t("子代理")}>
<Agents tasks={tasks} />
<Agents tasks={tasks} onOpen={(id) => { shut(); setViewing(id); }} />
</div>
</div>
)}
{viewed && <AgentTranscript task={viewed} onClose={closeView} />}
{jobs.length > 0 && (
<div ref={jobsBox} className="studio-deck-anchor" data-open={open === "jobs" ? "" : undefined}>
<button
@@ -225,7 +225,7 @@ const CODES: [string, number, Record<string, string | number> | undefined, RegEx
[FEEDBACK_CODE.duplicate, 409, undefined, /刚刚已经提交过/],
[FEEDBACK_CODE.badToken, 409, undefined, /没有认出这台电脑/],
[FEEDBACK_CODE.offline, 502, undefined, /没能连上反馈服务/],
[FEEDBACK_CODE.unavailable, 502, undefined, /暂时出了问题/],
[FEEDBACK_CODE.unavailable, 502, undefined, /已填的内容都还在/],
[FEEDBACK_CODE.internal, 500, undefined, /本机保存反馈记录时出错/],
[FEEDBACK_CODE.imageMetadata, 400, undefined, /隐藏信息/],
[FEEDBACK_CODE.badBody, 400, undefined, /没能被解析/],
@@ -313,6 +313,17 @@ describe("refusals", () => {
expect(screen.getByRole("button", { name: "重试发送" })).toBeTruthy();
});
it("keeps an unavailable report and offers the existing GitHub destination", async () => {
const { port } = setup();
sessionStorage.setItem("rx-mock-feedback-fault", FEEDBACK_CODE.unavailable);
await fill("Neutral report fixture", "tester");
await userEvent.click(send());
expect((await screen.findByRole("alert")).textContent).toContain("稍等片刻再试,也可以直接到 GitHub 提交问题");
expect(body().value).toBe("Neutral report fixture");
await userEvent.click(screen.getByRole("button", { name: "去 GitHub" }));
expect(port.openExternal).toHaveBeenCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues/new/choose");
});
it("points a duplicate at My feedback and a disabled channel at GitHub", async () => {
const { port } = setup();
port.sendFeedback = vi.fn(async () => {
@@ -379,7 +379,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
{failure.code === FEEDBACK_CODE.duplicate && (
<button type="button" className="btn sm" data-action="feedback.mine" onClick={onMine}>{t("查看我的反馈")}</button>
)}
{failure.code === FEEDBACK_CODE.disabled && (
{(failure.code === FEEDBACK_CODE.disabled || failure.code === FEEDBACK_CODE.unavailable) && (
<button type="button" className="btn sm" data-action="feedback.link" onClick={() => onFile(FEEDBACK_REPO_ISSUES + "new/choose")}>
{t("去 GitHub")}
</button>
@@ -249,7 +249,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
))}
</ol>}
<FeedbackThread item={item} fresh={fresh[item.receipt] ?? 0} onShowAll={unfolded} />
<FeedbackReplyBox port={port} item={item} limit={replyBytes} offline={mine.offline} onSent={sent} onStale={load} />
<FeedbackReplyBox port={port} item={item} limit={replyBytes} offline={mine.offline} onSent={sent} onFile={onFile} onStale={load} />
</li>
))}
</ul>
@@ -109,20 +109,26 @@ describe("a question waiting for the reporter", () => {
[FEEDBACK_CODE.rateLimited, /提交得太频繁/],
[FEEDBACK_CODE.badToken, /身份已经变了/],
[FEEDBACK_CODE.offline, /你写的回复还在/],
[FEEDBACK_CODE.unavailable, /暂时出了问题/],
[FEEDBACK_CODE.unavailable, /你写的回复还在/],
[FEEDBACK_CODE.disabled, /暂时关闭/],
])("says what %s means and keeps the text", async (code, words) => {
const port = portWith(asking);
port.replyFeedback = vi.fn(async () => {
throw new HttpError(409, code, { code, error: code });
});
render(<FeedbackMine port={port} onFile={() => {}} />);
const onFile = vi.fn();
render(<FeedbackMine port={port} onFile={onFile} />);
await screen.findByText("FB-AAAA-0001");
await userEvent.type(box(), "macOS 15");
await userEvent.click(sendButton());
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(words);
expect(box().value).toBe("macOS 15");
if (code === FEEDBACK_CODE.unavailable) {
expect(alert.textContent).toContain("重发前先刷新列表确认是否送达");
await userEvent.click(screen.getByRole("button", { name: "去 GitHub" }));
expect(onFile).toHaveBeenCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues/new/choose");
}
await waitFor(() => expect(port.myFeedback).toHaveBeenCalledTimes(code === FEEDBACK_CODE.notReplyable ? 2 : 1));
});
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { current, t } from "../i18n";
import { FEEDBACK_CODE, FEEDBACK_REPLYABLE, type FeedbackItem, type FeedbackReply } from "../port/feedback";
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, type FeedbackItem, type FeedbackReply } from "../port/feedback";
import type { AgentPort } from "../port/port";
import { replyFailure, type FeedbackFailure } from "./feedbackfailure";
import { StudioIcon } from "./StudioIcon";
@@ -69,10 +69,11 @@ interface ReplyProps {
limit: number | null;
offline: boolean;
onSent: (receipt: string) => void;
onFile: (url: string) => void;
onStale: () => void;
}
export function FeedbackReplyBox({ port, item, limit, offline, onSent, onStale }: ReplyProps) {
export function FeedbackReplyBox({ port, item, limit, offline, onSent, onFile, onStale }: ReplyProps) {
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
@@ -152,6 +153,11 @@ export function FeedbackReplyBox({ port, item, limit, offline, onSent, onStale }
<div className="fbk-note" role="alert" data-tone="error" data-code={failure.code}>
<StudioIcon name="warning" />
<span>{failure.message}</span>
{failure.code === FEEDBACK_CODE.unavailable && (
<button type="button" className="btn sm" data-action="feedback.link" onClick={() => onFile(FEEDBACK_REPO_ISSUES + "new/choose")}>
{t("去 GitHub")}
</button>
)}
</div>
)}
<div className="fbk-acts">
+9 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { DOCK, Gutter, dockMax } from "./Gutter";
import { DOCK, Gutter, RAIL, dockMax, widthOf } from "./Gutter";
afterEach(cleanup);
@@ -33,3 +33,11 @@ describe("workbench width on wide displays", () => {
expect(separator.getAttribute("aria-valuemax")).toBe("880");
});
});
describe("workspace column floor", () => {
it("raises a width saved below the floor, where the session filters no longer fit", () => {
localStorage.setItem(RAIL.key, "176");
expect(widthOf(RAIL)).toBe(RAIL.min);
expect(RAIL.min).toBeGreaterThanOrEqual(232);
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ export interface Span {
// 拖出来的宽度是读者对自己这块屏幕的判断,不是布局的默认值 —— 所以它归本地
// 存,不进内核配置:换台机器、换块屏幕,判断本来就不同。
export const RAIL: Span = { min: 176, max: 440, def: 264, key: "rx-rail-w", css: "--rail-open", col: "--rail-w" };
export const RAIL: Span = { min: 232, max: 440, def: 264, key: "rx-rail-w", css: "--rail-open", col: "--rail-w" };
export const SIDE: Span = { min: 244, max: 540, def: 316, key: "rx-side-w", css: "--side-open", col: "--side-w" };
// The workbench beside the conversation. Wider than the rail because what sits
// in it is a page someone is reading, not a list of names.
@@ -88,6 +88,7 @@ describe("market list", () => {
const port = new MockPort() as unknown as AgentPort;
let finish!: (value: MarketDetail) => void;
port.marketList = async () => ({ packages: [row("a/kit", true)], limit: 24, offset: 0 });
port.marketMyVote = async () => ({ signedIn: false, value: 0 });
port.marketDetail = vi.fn()
.mockRejectedValueOnce(new Error("offline"))
.mockImplementationOnce(() => new Promise<MarketDetail>((resolve) => { finish = resolve; }));
+6 -1
View File
@@ -361,6 +361,9 @@ const VIEWS: [View, string][] = [["browse", "浏览"], ["mine", "我的发布"],
// page rather than two sections: what the market adds shows up on the other tab.
// Publishing spends the account session, so it is offered only while signed in.
export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSignIn, onApplying }: Props & { account: AccountState | null; onSignIn: () => void; onApplying?: (applying: boolean) => void }) {
const connection = useRef({ port });
if (connection.current.port !== port) connection.current = { port };
const owner = connection.current;
const [view, setView] = useState<View>("browse");
const [applying, setApplying] = useState(false);
const applyingChanged = useCallback((busy: boolean) => { setApplying(busy); onApplying?.(busy); }, [onApplying]);
@@ -388,7 +391,9 @@ export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSig
)
)}
{at === "browse" && <Market port={port} onInstalled={onInstalled} onViewInstalled={onViewInstalled} onSignIn={onSignIn} />}
{at === "mine" && <MyPackages port={port} onInstalled={onInstalled} onViewInstalled={onViewInstalled} onApplying={applyingChanged} />}
{at === "mine" && <MyPackages key={handle} port={port} onInstalled={() => {
if (connection.current === owner) onInstalled();
}} onViewInstalled={onViewInstalled} onApplying={applyingChanged} />}
{at === "publish" && handle && <PublishForm port={port} handle={handle} onMine={() => setView("mine")} />}
</Group>
);
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { MockPort } from "../port/mock";
import type { AccountState, AgentPort, MarketPackage, MarketPlan } from "../port/port";
import { MarketGroup } from "./Market";
afterEach(cleanup);
const account = (handle: string): AccountState => ({
signedIn: true, user: { handle, email: `${handle}@example.com`, label: handle },
});
const draw = (port: AgentPort, handle: string, onInstalled = () => {}) => (
<MarketGroup port={port} account={account(handle)} onInstalled={onInstalled} onSignIn={() => {}} />
);
it("keeps an open author preview when the same account state is refreshed", async () => {
const port = new MockPort() as unknown as AgentPort;
const read = vi.spyOn(port, "myMarket");
const preview = vi.spyOn(port, "planOwnMarket");
const view = render(draw(port, "demo"));
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
const row = (await screen.findByText("ship-notes")).closest("li")!;
await userEvent.click(within(row).getByRole("button", { name: "安装" }));
await screen.findByText(/demo\/ship-notes .* 将安装以下内容/);
view.rerender(draw(port, "demo"));
expect(screen.getByText(/demo\/ship-notes .* 将安装以下内容/)).toBeTruthy();
expect(read).toHaveBeenCalledTimes(1);
expect(preview).toHaveBeenCalledTimes(1);
});
it("refreshes installed capabilities when the old author's install completes on the same host", async () => {
const port = new MockPort() as unknown as AgentPort;
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
const plan = await port.planOwnMarket({ slug: pkg.slug });
const fresh = { ...pkg, handle: "other", slug: "other/new-package", name: "new-package" };
vi.spyOn(port, "myMarket").mockResolvedValueOnce([pkg]).mockResolvedValue([fresh]);
let finish!: (out: MarketPlan) => void;
vi.spyOn(port, "installOwnMarket").mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
const onInstalled = vi.fn();
const view = render(draw(port, "demo", onInstalled));
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
view.rerender(draw(port, "other", onInstalled));
await screen.findByText("new-package");
await act(async () => finish({ ...plan, applied: true, status: "done" }));
expect(onInstalled).toHaveBeenCalledTimes(1);
expect(screen.getByText("new-package")).toBeTruthy();
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
});
it("closes the old author's preview when the account changes on the same connection", async () => {
const port = new MockPort() as unknown as AgentPort;
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
const fresh = { ...pkg, handle: "other", slug: "other/new-package", name: "new-package" };
const read = vi.spyOn(port, "myMarket").mockResolvedValueOnce([pkg]).mockResolvedValue([fresh]);
const preview = vi.spyOn(port, "planOwnMarket");
const install = vi.spyOn(port, "installOwnMarket");
const view = render(draw(port, "demo"));
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
await screen.findByText(/demo\/ship-notes .* 将安装以下内容/);
view.rerender(draw(port, "other"));
expect(await screen.findByText("new-package")).toBeTruthy();
expect(screen.queryByText(/demo\/ship-notes .* 将安装以下内容/)).toBeNull();
expect(read).toHaveBeenCalledTimes(2);
expect(preview).toHaveBeenCalledTimes(1);
expect(install).not.toHaveBeenCalled();
});
it.each([
["success", false], ["failure", false], ["success", true], ["failure", true],
])("ignores the earlier account's list %s on the same connection (return=%s)", async (outcome, returnToAccount) => {
const port = new MockPort() as unknown as AgentPort;
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
const other = { ...pkg, handle: "other", slug: "other/current-package", name: "current-package" };
const fresh = { ...pkg, slug: "demo/fresh-package", name: "fresh-package" };
let finish!: (rows: MarketPackage[]) => void;
let fail!: (error: Error) => void;
const read = vi.spyOn(port, "myMarket")
.mockImplementationOnce(() => new Promise((resolve, reject) => { finish = resolve; fail = reject; }))
.mockResolvedValueOnce([other]).mockResolvedValue([fresh]);
const view = render(draw(port, "demo"));
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
view.rerender(draw(port, "other"));
await screen.findByText("current-package");
if (returnToAccount) {
view.rerender(draw(port, "demo"));
await screen.findByText("fresh-package");
}
await act(async () => {
if (outcome === "success") finish([pkg]);
else fail(new Error("old account inventory failed"));
});
expect(screen.getByText(returnToAccount ? "fresh-package" : "current-package")).toBeTruthy();
expect(screen.queryByText("ship-notes")).toBeNull();
expect(screen.queryByText("old account inventory failed")).toBeNull();
expect(read).toHaveBeenCalledTimes(returnToAccount ? 3 : 2);
});
@@ -208,7 +208,8 @@ describe("the account's own packages", () => {
const row = (await screen.findByText(pkg.name)).closest("li")!;
await userEvent.click(row.querySelector<HTMLButtonElement>('[data-action="market.own-inspect"]')!);
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
await screen.findByText("Some actions failed");
await screen.findByText("有项目未安装成功,原因见下方。");
expect(screen.getByRole("alert").textContent).toContain("unavailable-server: server unavailable");
const installed = document.querySelector(".mkt-installed")!;
expect(installed.textContent).toContain("working-server");
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { OwnInstall } from "./MarketOwn";
import { boot, STORAGE, t } from "../i18n";
import type { AgentPort, MarketPackage, MarketPlan } from "../port/port";
afterEach(cleanup);
const pkg = { slug: "demo/notes-kit", kind: "skill", latestVersion: "1.0.0", status: "private" } as MarketPackage;
const preview = (id: string, version: string): MarketPlan => ({
ok: true, status: "planned", applied: false, slug: pkg.slug, version, planId: id,
contentDigest: `sha256:${(id === "consumed" ? "a" : "b").repeat(64)}`, unreviewed: true,
actions: ["notes", "checks"].map((name) => ({ kind: "skill", name, action: "copy_skill", status: "planned", riskLevel: "low" })),
});
const original = preview("consumed", "1.0.0");
const fresh = preview("fresh", "2.0.0");
const failed = (applied: boolean): MarketPlan => ({
...original, ok: false, status: "failed", applied,
actions: original.actions!.map((action) => ({ ...action, status: "failed", error: "The target is unavailable", next: "Correct the target and preview again" })),
});
const successful: MarketPlan = { ...fresh, status: "done", applied: true, actions: fresh.actions!.map((action) => ({ ...action, status: "done" })) };
function language(lang: string) {
localStorage.setItem(STORAGE, lang);
boot();
}
async function confirm() {
await userEvent.click(await screen.findByRole("checkbox"));
await userEvent.click(screen.getByRole("button", { name: t("安装") }));
}
it.each(["zh", "en"].flatMap((lang) => [false, true].map((applied) => ({ lang, applied }))))("re-previews a failed author install with fresh version, digest and consent ($lang, applied=$applied)", async ({ lang, applied }) => {
language(lang);
let finish!: (plan: MarketPlan) => void;
const planning = vi.fn().mockResolvedValueOnce(original).mockImplementationOnce(() => new Promise<MarketPlan>((resolve) => { finish = resolve; }));
const installing = vi.fn().mockResolvedValueOnce(failed(applied)).mockResolvedValueOnce(successful);
const onInstalled = vi.fn();
const onApplying = vi.fn();
const onBack = vi.fn();
const port = { planOwnMarket: planning, installOwnMarket: installing } as unknown as AgentPort;
render(<OwnInstall port={port} pkg={pkg} onBack={onBack} onInstalled={onInstalled} onApplying={onApplying} />);
await confirm();
expect((await screen.findAllByRole("alert"))[0].textContent).toContain("The target is unavailable");
await userEvent.click(screen.getByRole("button", { name: t("重试") }));
expect(planning).toHaveBeenCalledTimes(2);
expect(planning).toHaveBeenLastCalledWith({ slug: pkg.slug, replace: false });
expect(screen.queryByRole("checkbox")).toBeNull();
expect(screen.queryByRole("button", { name: t("安装") })).toBeNull();
expect(screen.queryByRole("button", { name: t("重试") })).toBeNull();
expect(installing).toHaveBeenCalledTimes(1);
await act(async () => finish(fresh));
const seen = screen.getByRole<HTMLInputElement>("checkbox");
expect(seen.checked).toBe(false);
expect(screen.getByRole<HTMLButtonElement>("button", { name: t("安装") }).disabled).toBe(true);
expect(screen.getByText(t("内容摘要:{digest}", { digest: fresh.contentDigest! }))).toBeTruthy();
await userEvent.click(seen);
await userEvent.click(screen.getByRole("button", { name: t("安装") }));
expect(installing).toHaveBeenLastCalledWith({ slug: pkg.slug, version: "2.0.0", planId: "fresh", replace: false, digest: fresh.contentDigest });
expect(onInstalled).toHaveBeenCalledTimes(applied ? 2 : 1);
expect(onApplying.mock.calls.filter(([value]) => value)).toHaveLength(2);
expect(onBack).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: t("重试") })).toBeNull();
});
it.each(["zh", "en"])("recovers a new preview failure without reapplying the consumed plan (%s)", async (lang) => {
language(lang);
const planning = vi.fn().mockResolvedValueOnce(original).mockRejectedValueOnce(new Error("source unavailable")).mockResolvedValueOnce(fresh);
const installing = vi.fn().mockResolvedValueOnce(failed(true)).mockResolvedValueOnce(successful);
const port = { planOwnMarket: planning, installOwnMarket: installing } as unknown as AgentPort;
render(<OwnInstall port={port} pkg={pkg} onBack={() => {}} onInstalled={() => {}} />);
await confirm();
await userEvent.click(await screen.findByRole("button", { name: t("重试") }));
expect(await screen.findByText("source unavailable")).toBeTruthy();
expect(installing).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: t("重试") }));
expect(await screen.findByRole("checkbox")).toBeTruthy();
expect(planning).toHaveBeenCalledTimes(3);
expect(installing).toHaveBeenCalledTimes(1);
await confirm();
expect(installing).toHaveBeenLastCalledWith({ slug: pkg.slug, version: "2.0.0", planId: "fresh", replace: false, digest: fresh.contentDigest });
});
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { boot, STORAGE, t } from "../i18n";
import { MockPort } from "../port/mock";
import type { AgentPort, MarketPackage } from "../port/port";
import { MyPackages } from "./MarketPublish";
afterEach(() => { cleanup(); localStorage.setItem(STORAGE, "zh"); boot(); });
it.each(["zh", "en"].flatMap((lang) => ["success", "failure"].map((outcome) => ({ lang, outcome }))))(
"keeps the review result after an earlier install refresh $outcome ($lang)",
async ({ lang, outcome }) => {
localStorage.setItem(STORAGE, lang);
boot();
const port = new MockPort() as unknown as AgentPort;
const published = await port.publishMarket({
kind: "theme", name: "review-refresh-demo", visibility: "private",
source: "https://github.com/demo/themes/tree/" + "a".repeat(40) + "/dusk",
});
const read = port.myMarket.bind(port);
const submit = port.submitMarket.bind(port);
let finishReview!: () => Promise<MarketPackage>;
const review = vi.spyOn(port, "submitMarket").mockImplementation((slug) => new Promise((resolve) => {
finishReview = async () => { const pkg = await submit(slug); resolve(pkg); return pkg; };
}));
let finishList!: () => void;
const refresh = vi.spyOn(port, "myMarket").mockImplementationOnce(read).mockImplementationOnce(async () => {
const rows = await read();
return new Promise((resolve, reject) => {
finishList = () => outcome === "success" ? resolve(rows) : reject(new Error("old inventory failed"));
});
});
const onInstalled = vi.fn();
render(<MyPackages port={port} onInstalled={onInstalled} />);
const row = (await screen.findByText(published.package.name)).closest("li")!;
await userEvent.click(within(row).getByRole("button", { name: t("提交审核") }));
await userEvent.click(within(row).getByRole("button", { name: t("安装") }));
await userEvent.click(await screen.findByRole("button", { name: t("安装") }));
await waitFor(() => expect(onInstalled).toHaveBeenCalledTimes(1));
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2));
await act(async () => { await finishReview(); });
await act(async () => finishList());
await userEvent.click(screen.getByRole("button", { name: t("返回我的发布") }));
const current = (await screen.findByText(published.package.name)).closest("li")!;
expect(within(current).getByText(t("审核中"))).toBeTruthy();
expect(within(current).getByText(t("已安装"))).toBeTruthy();
expect(within(current).queryByRole("button", { name: t("提交审核") })).toBeNull();
expect(screen.queryByText("old inventory failed")).toBeNull();
expect(review).toHaveBeenCalledTimes(1);
expect(onInstalled).toHaveBeenCalledTimes(1);
},
);
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { OwnInstall } from "./MarketOwn";
import { MyPackages } from "./MarketPublish";
import { MarketGroup } from "./Market";
import { MockPort } from "../port/mock";
import type { AccountState, AgentPort, MarketPlan } from "../port/port";
afterEach(cleanup);
const signedIn: AccountState = { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } };
async function pendingInstall(port: AgentPort) {
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
const plan = await port.planOwnMarket({ slug: pkg.slug });
let finish!: (out: MarketPlan) => void;
vi.spyOn(port, "installOwnMarket").mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
return { pkg, finish: () => finish({ ...plan, applied: true, status: "done" }) };
}
async function installFromList() {
const row = (await screen.findByText("ship-notes")).closest("li")!;
await userEvent.click(within(row).getByRole("button", { name: "安装" }));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
}
it("notifies a successful install after the confirmation unmounts", async () => {
const port = new MockPort() as unknown as AgentPort;
const pending = await pendingInstall(port);
const onInstalled = vi.fn();
const view = render(<OwnInstall port={port} pkg={pending.pkg} onBack={() => {}} onInstalled={onInstalled} />);
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
view.unmount();
await act(async () => pending.finish());
expect(onInstalled).toHaveBeenCalledTimes(1);
});
it("does not notify an install from an earlier lifetime of the same port", async () => {
const port = new MockPort() as unknown as AgentPort;
const next = new MockPort() as unknown as AgentPort;
const pending = await pendingInstall(port);
const onInstalled = vi.fn();
const draw = (active: AgentPort) => <OwnInstall port={active} pkg={pending.pkg} onBack={() => {}} onInstalled={onInstalled} />;
const view = render(draw(port));
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
view.rerender(draw(next));
await screen.findByRole("button", { name: "安装" });
view.rerender(draw(port));
await screen.findByRole("button", { name: "安装" });
await act(async () => pending.finish());
expect(onInstalled).not.toHaveBeenCalled();
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
expect(screen.getByRole<HTMLButtonElement>("button", { name: "安装" }).disabled).toBe(false);
});
it.each([false, true])("ignores the old keyed install after MyPackages changes port (return=%s)", async (returnToPort) => {
const port = new MockPort() as unknown as AgentPort;
const next = new MockPort() as unknown as AgentPort;
const pending = await pendingInstall(port);
const onInstalled = vi.fn();
const draw = (active: AgentPort) => <MyPackages port={active} onInstalled={onInstalled} />;
const view = render(draw(port));
await installFromList();
view.rerender(draw(next));
await screen.findByText("ship-notes");
if (returnToPort) {
view.rerender(draw(port));
await screen.findByText("ship-notes");
}
await act(async () => pending.finish());
expect(onInstalled).not.toHaveBeenCalled();
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
});
it.each([false, true])("ignores the signed-out install after MarketGroup changes port (return=%s)", async (returnToPort) => {
const port = new MockPort() as unknown as AgentPort;
const next = new MockPort() as unknown as AgentPort;
const pending = await pendingInstall(port);
const onInstalled = vi.fn();
const draw = (active: AgentPort, account: AccountState) => <MarketGroup port={active} account={account} onInstalled={onInstalled} onSignIn={() => {}} />;
const view = render(draw(port, signedIn));
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
await installFromList();
view.rerender(draw(port, { signedIn: false }));
await screen.findByRole("button", { name: "去登录" });
view.rerender(draw(next, { signedIn: false }));
if (returnToPort) view.rerender(draw(port, { signedIn: false }));
await act(async () => pending.finish());
expect(onInstalled).not.toHaveBeenCalled();
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
expect(screen.getByRole("button", { name: "去登录" })).toBeTruthy();
});
+6 -3
View File
@@ -24,6 +24,8 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
const generation = useRef(0);
const connection = useRef({ port });
if (connection.current.port !== port) connection.current = { port };
const replace = !!pkg.installed && pkg.installed.version !== pkg.latestVersion;
useEffect(() => {
@@ -48,6 +50,7 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
const install = async () => {
if (!plan) return;
const current = generation.current;
const owner = connection.current;
setBusy(true);
onApplying?.(true);
setError("");
@@ -55,9 +58,8 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
const out = await port.installOwnMarket({
slug: pkg.slug, version: plan.version, planId: plan.planId, replace, digest: plan.contentDigest,
});
if (current !== generation.current) return;
setDone(out);
if (out.applied) onInstalled();
if (current === generation.current) setDone(out);
if (out.applied && connection.current === owner) onInstalled();
} catch (e) {
if (current === generation.current) setError(reason(e));
} finally {
@@ -81,6 +83,7 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
{installed.length > 0 && <ul className="mkt-installed">{installed.map((action, i) => <li key={`${action.kind}:${action.name}:${i}`}>{action.name}</li>)}</ul>}
<div className="acts">
{back}
{!done.ok && <button className="act" data-action="market.own-retry" onClick={() => setAttempt((n) => n + 1)}>{t("重试")}</button>}
{location && onViewInstalled && (
<button className="act" data-action="market.view-installed" onClick={() => onViewInstalled(location.kind, location.name!)}>{t("查看已安装能力")}</button>
)}
Loaded 100 of 484 files, more files were not shown because too many files have changed in this diff. Show more