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
YHH 13f238e6f4 Merge pull request #11492 from KHG420/codex/studio-mcp-operation-guard
fix(studio): guard conflicting MCP row operations
2026-09-30 19:00:24 -07:00
YHH 0209b345a0 Merge pull request #11495 from KHG420/codex/studio-skill-operation-owner
fix(studio): scope skill operation results and refresh current inventory
2026-09-30 18:57:14 -07: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
YHH d98ae85fb0 Merge pull request #11490 from esengine/fix/feedback-reply-test-race
test(feedback): read the reply stub's hits under its lock
2026-09-30 18:34:38 -07:00
xiexian 687186fcdd fix(studio): scope skill results and refresh current inventory 2026-10-01 09:31:31 +08:00
xiexian d30fd833d9 fix(studio): guard conflicting MCP row operations 2026-10-01 09:08:42 +08:00
esengine 459c2ae5c4 test(feedback): read the reply stub's hits under its lock 2026-09-30 18:00:24 -07: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
YHH 6cc249f1d4 Merge pull request #11482 from esengine/release/studio-2.24.0-notes
docs(release): Studio 2.24.0 release notes
2026-09-30 17:36:02 -07:00
xiexian e5a801c603 feat(studio): persist manual project order without changing launch project 2026-10-01 08:35:14 +08:00
esengine fe5ea48568 docs(release): Studio 2.24.0 release notes 2026-09-30 17:29:36 -07:00
YHH 3aa259f2e2 Merge pull request #11480 from KHG420/codex/studio-publish-connection-state
fix(studio): reset publish form when its owner changes
2026-09-30 17:26:57 -07:00
YHH ed9c424991 Merge pull request #11474 from KHG420/codex/studio-api-notes-example
docs(studio): add a local API documentation scenario
2026-09-30 17:26:07 -07:00
YHH 9328ef003a Merge pull request #11478 from KHG420/codex/studio-market-connection-state
fix(studio): refresh market state across runtime connections
2026-09-30 17:24:07 -07: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
YHH 490f3d0bfe Merge pull request #11479 from esengine/fix/11434-host-handshake
fix(studio): wait longer for a cold kernel start, retry an early exit once, and name the cause
2026-09-30 17:03:11 -07:00
esengine 64c8aa5170 fix(studio): wait longer for a cold kernel start, retry an early exit once, and name the cause
The shell gave the kernel 20 s to handshake and reported every failure as a
sentence. The wait is now 60 s (REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS), a small
starting window appears after 3 s, an exit or spawn failure within 10 s is
retried once, and launch failures carry a typed code (timeout, exited, spawn,
overflow) with a matching explanation in the error box.

Refs #11434
2026-09-30 16:54:16 -07:00
xiexian 00d9c2e490 docs(studio): add a source-linked API guide example 2026-10-01 07:48:20 +08:00
xiexian ff1163da75 fix(studio): refresh market state across runtime connections 2026-10-01 07:47:55 +08:00
xiexian 6cd9593404 fix(studio): reset publish form when its owner changes 2026-10-01 07:44:43 +08:00
YHH b3d7dd50fc Merge pull request #11476 from KHG420/codex/studio-compatible-mcp-ownership
fix(studio): preserve package ownership on explicit MCP connection
2026-09-30 16:37:16 -07:00
YHH f8e541a86b Merge pull request #11466 from KHG420/codex/studio-author-apply-generation
fix(studio): isolate author install responses by connection
2026-09-30 16:35:26 -07:00
YHH b5ea839c94 Merge pull request #11446 from esengine/feat/feedback-replies-ui
feat(studio): reply threads, needs-input and unread badge in the feedback dialog
2026-09-30 16:07:35 -07:00
xiexian 4c8eda7344 fix(studio): preserve package ownership on explicit MCP connection 2026-10-01 06:59:24 +08:00
xiexian dfd8aac901 fix(studio): isolate author installs across connection changes 2026-10-01 06:56:28 +08:00
YHH f69e2c5798 Merge pull request #11472 from KHG420/codex/studio-issue-fix-example
docs(plugins): add a runnable repository issue exercise
2026-09-30 15:39:20 -07:00
YHH 1918bb4f55 Merge pull request #11357 from BuGlessRB/open/ask-autosubmit
feat(cli): auto-submit a fully answered ask batch, gated on auto_submit / 功能(cli):多题问询答完最后一题即自动提交,由 auto_submit 控制
2026-09-30 15:38:35 -07:00
YHH 53d21288df Merge pull request #11465 from KHG420/codex/studio-market-retry-focus
fix(studio): preserve focus through market read recovery
2026-09-30 15:36:09 -07:00
YHH 3c0c265c22 Merge pull request #11467 from KHG420/codex/studio-compatible-mcp-effect
test(plugins): cover compatible MCP install lifecycle
2026-09-30 15:15:30 -07:00
YHH e179b51c52 Merge pull request #11469 from KHG420/codex/studio-update-feedback
fix(studio): preserve applying updates across settings controls
2026-09-30 15:10:44 -07:00
YHH 28b6680353 Merge pull request #11468 from KHG420/codex/studio-disabled-mcp-tools-5444
feat(studio): support disabling individual MCP tools
2026-09-30 15:02:44 -07:00
xiexian 788d2b8d41 fix(studio): preserve keyboard focus across market read recovery 2026-10-01 05:54:19 +08:00
YHH f5e0d83f0d Merge pull request #11454 from KHG420/codex/studio-hook-event-labels-11383
fix(studio): localize automation event labels
2026-09-30 14:48:18 -07:00
xiexian 3b21d25de8 docs(plugins): add a runnable repository issue exercise 2026-10-01 05:42:27 +08:00
xiexian c3b51178cb fix(studio): preserve applying updates across settings controls 2026-10-01 05:26:48 +08:00
YHH 18df489e23 Merge pull request #11449 from KHG420/codex/studio-marketplace-subdir-11378
fix(studio): install git-subdir marketplace plugins
2026-09-30 14:16:47 -07:00
YHH 5598442a99 Merge pull request #11459 from KHG420/codex/studio-bundled-skill-effect
test(studio): verify copied skill references reach the provider
2026-09-30 14:15:50 -07:00
YHH 3b8bb5492b Merge pull request #11460 from KHG420/codex/studio-update-panel-guard
fix(studio): isolate inline package updates through apply
2026-09-30 14:13:47 -07:00
xiexian 1718074787 test(plugins): cover compatible MCP install lifecycle 2026-10-01 05:11:41 +08:00
xiexian bb013c4bc9 feat(studio): filter disabled MCP tools before registration 2026-10-01 05:09:47 +08:00
YHH 1cbd447539 Merge pull request #11456 from KHG420/codex/studio-author-read-races
test(studio): cover stale author package reads
2026-09-30 14:09:11 -07:00
Stephen R. van den Berg 5b609815c5 feat(cli): auto-submit a fully answered ask batch, gated on auto_submit
Answering the last question of a multi-question ask with nothing left
unanswered commits the batch immediately, skipping the extra confirmation
Enter; an incomplete batch jumps to the first unanswered question instead. The
behaviour is gated on a new top-level `auto_submit` option (default false):
with it on the ask drops its Submit tab, and without it the Submit tab is shown
and still needs its explicit Enter.

`auto_submit` is a user preference: LoadForRoot holds it across the project
merge so a cloned repository cannot turn it on, and neither the annotated
project render nor the project delta carries it. Answering moves on to the next
unanswered question, so answering one the panel was thrown back to skips ahead
to the next gap rather than stopping on the already-answered question that
follows it; only the last question, with nothing left unanswered, commits. With
auto-submit on there is no Submit tab to page onto, so right stops at the last
question rather than landing on a hidden tab that commits.

- config: new top-level auto_submit, user/global only; rendered in the user's annotated TOML and omitted from a project render and the project delta.
- tui: Options.AutoSubmit; nextQuestion advances to the next unanswered question and commits on the last; right stops at the last question instead of a hidden Submit tab.
- program_test.go: the commit, next-unanswered-after-skip-back, skipped-question, hidden-tab, no-option and arrow-stops-at-last cases.
- docs: auto_submit in CONFIG_PATHS.md, reasonix.example.toml, and the Guide shortcut tables (en and zh-CN).
2026-09-30 22:42:33 +02:00
Stephen R. van den Berg 0102bf3a6a refactor(config): split provider-source scope and the [ui] section into files
Move the providerSourceScope type out of config.go and the [ui] table render
out of render.go into their own files, so both sit under their recorded
repolint budgets with room for the options that follow.
2026-09-30 22:42:33 +02:00
YHH 873990724d Merge pull request #11445 from esengine/feat/feedback-replies-kernel
feat(feedback): reply threads, triage statuses and unread state in the client
2026-09-30 13:38:50 -07:00
xiexian 9d950f34bc fix(studio): describe automation events in the UI language 2026-10-01 04:38:08 +08:00
xiexian e9882d667e fix(studio): keep package updates isolated until apply settles 2026-10-01 04:27:31 +08:00
YHH 0af416ce89 Merge pull request #11453 from KHG420/codex/studio-install-preview-i18n
fix(studio): localize installation trust disclosures
2026-09-30 13:24:20 -07:00
YHH ee625f5e41 Merge pull request #11443 from KHG420/codex/studio-own-installed-entry
fix(studio): locate capabilities after author installs
2026-09-30 13:23:25 -07:00
xiexian d5a5a6e70f test(studio): verify copied skill references reach the provider 2026-10-01 04:13:52 +08:00
YHH 53b82d1362 Merge pull request #11442 from KHG420/codex/studio-market-retry
fix(studio): retry failed market list and detail reads
2026-09-30 13:10:30 -07:00
xiexian 5c78af6b09 fix(studio): install git-subdir marketplace plugins 2026-10-01 04:09:53 +08:00
xiexian dfbe776afb fix(studio): localize installation trust disclosures 2026-10-01 04:06:09 +08:00
xiexian 0056f8386b fix(studio): locate capabilities after author installs 2026-10-01 03:53:40 +08:00
YHH bf8210aebf Merge pull request #11439 from esengine/fix/skill-invocation-flags
fix(skill): honour disable-model-invocation, user-invocable and argument-hint
2026-09-30 12:53:16 -07:00
xiexian 88966f614f test(studio): cover stale author package reads 2026-10-01 03:46:07 +08:00
YHH 46542dd558 Merge pull request #11450 from KHG420/codex/studio-package-operation-guard
fix(studio): guard concurrent package operations
2026-09-30 12:36:43 -07:00
YHH 4376074c23 Merge pull request #11447 from KHG420/codex/studio-plugin-update-effect
test(studio): verify plugin updates replace MCP contributions
2026-09-30 12:35:53 -07:00
YHH ff6f93280e Merge pull request #11444 from KHG420/codex/studio-author-read-retry
fix(studio): retry failed author market reads
2026-09-30 12:34:57 -07:00
xiexian 651d2bd4df fix(studio): retry failed market list and detail reads 2026-10-01 03:32:43 +08:00
YHH d6eed9aee4 Merge pull request #11422 from esengine/feat/model-overrides-11392
feat(studio): per-model context window and max output in the provider form
2026-09-30 12:18:34 -07:00
YHH 695802235e Merge pull request #11435 from esengine/feat/feedback-ui
feat(studio): feedback dialog with screenshots, environment block and my-feedback list
2026-09-30 12:17:36 -07:00
esengine 5d786d6de0 feat(studio): reply threads, needs-input and unread badge in the feedback dialog
My feedback shows the thread under each report as plain-text bubbles, a
needs-your-input chip with an inline reply box (byte counter, errors per code),
status chips for answered and closed, and a New mark that stays for the visit
in which the replies were read. The sidebar entry and the command palette item
carry an unread count that is read when the app opens and when the page opens,
never on a timer. The form copy says a report becomes public only if it is
filed as an issue, and keeps the warning about secrets and screenshots.

Refs #11424
2026-09-30 12:16:20 -07:00
esengine 01a52ce15e feat(feedback): reply threads, triage statuses and unread state in the client
The kernel reads the new statuses (needs_info, answered, closed), each report's
thread and needsInput, keeps the last-seen reply id per report locally and
exposes unread/hasNew for an entry badge. A reply goes through the controller
port, serve (POST /feedback/{receipt}/reply and /seen) and the TUI
(/feedback show, /feedback reply with --yes as the first token). The new
worker codes become sentinels: feedback.reply_limit, feedback.not_replyable
and feedback.challenge_required. A reply is never retried. Thread text is
cleaned of escape sequences before any frontend sees it. Submit copy no longer
says every report becomes public.

Refs #11424
2026-09-30 12:16:12 -07:00
YHH ad99a35714 Merge pull request #11452 from esengine/ci/feedback-sync-workersdev
ci(feedback-sync): reach the worker through workers.dev
2026-09-30 12:13:05 -07:00
esengine 74318f9d49 ci(feedback-sync): reach the worker through workers.dev 2026-09-30 11:58:16 -07:00
esengine a237c5843e fix(skill): refuse user-only skills as task profiles and in capability routing 2026-09-30 11:43:20 -07:00
YHH fa85a69b9d Merge pull request #11448 from esengine/fix/feedback-sync-403-diag
fix(feedback): show the response origin and body snippet when the worker refuses the sync token
2026-09-30 11:42:27 -07:00
xiexian 1320621cbe fix(studio): guard concurrent package operations 2026-10-01 02:37:10 +08:00
esengine c116fd76ec fix(feedback): show the response origin and body snippet when the worker refuses the sync token 2026-09-30 11:32:11 -07:00
xiexian e3ae0e78ab test(studio): verify plugin updates replace MCP contributions 2026-10-01 02:27:33 +08:00
YHH 6d9266fc8e Merge pull request #11440 from esengine/feat/feedback-sync-replies
feat(feedback): mirror reporter replies, publish only released items and images
2026-09-30 11:18:25 -07:00
xiexian d2a86112a4 fix(studio): retry failed author market reads 2026-10-01 02:14:44 +08:00
esengine d08999485a feat(feedback): mirror reporter replies to their issue, publish only released items and images
The worker now holds new submissions by default. The converter still
creates issues only for received items, links only images the worker marks
released, and posts one idempotent bot comment per reporter reply on items
that have an issue, then acks it. Contact is never read by the converter,
which a test pins.

Refs #11424
2026-09-30 10:52:34 -07:00
YHH 42ef9dd191 Merge pull request #11438 from esengine/fix/remote-error-diagnosis-studio
fix(remote): classify relay connection failures and show the specific cause
2026-09-30 10:00:04 -07:00
esengine cf2146408a fix(skill): honour disable-model-invocation, user-invocable and argument-hint 2026-09-30 09:53:07 -07:00
esengine 226984ba69 fix(remote): say why a relay connection failed instead of one generic sentence
A browser cannot read the status of a refused WebSocket upgrade, so a
rate limit, a refused origin, an expired ticket, a network loss and an
unreachable gateway all read as "relay temporarily unavailable". When the
socket errors before opening, one probe round (offline check, the gateway
health route, and an attachment preflight that only an allowed origin passes)
now classifies the failure by typed code; a 429 from the account service
carries its Retry-After. The notice names the cause, offers reconnect
(counting down a rate limit), and a reconnect that runs out of retries ends
with the last attempt's cause, or idle when the relay had reclaimed the
lease. The generic sentence remains only when the probe itself fails.
2026-09-30 09:51:57 -07:00
YHH f309fed993 Merge pull request #11432 from esengine/feat/feedback-client
feat(feedback): in-app feedback client, controller port, serve endpoints, TUI /feedback
2026-09-30 09:07:05 -07:00
esengine 6db27d94ae feat(studio): feedback dialog with screenshots, environment block and my-feedback list
Refs #11424
2026-09-30 08:52:25 -07:00
esengine f72102c8a6 feat(feedback): in-app feedback client, controller port, serve endpoints and TUI /feedback
Adds internal/platform/feedback: env collection, local redaction (the
crashreport implementation, now exported), image re-encode with a metadata
self-check, idempotent submit with retry, persisted install identity and
receipts, and GET /v1/feedback/mine with typed sentinel errors.

The controller exposes it as the Feedback port (SessionAPI), serve answers
GET /feedback/env, GET /feedback/mine, POST /feedback and POST /feedback/name
with dotted refusal codes, and /feedback [list|name|<category> --yes <text>]
is a controller slash command so the TUI inherits it. Nothing reaches a model
request or the cache-stable prefix.

Refs #11424
2026-09-30 08:45:43 -07:00
YHH 7733545274 Merge pull request #11427 from esengine/feat/feedback-sync
ci(feedback): converter and status sync for in-app feedback
2026-09-30 08:25:58 -07:00
YHH fa39e7db7f Merge pull request #11379 from chuiwenwei/feat/studio-index-corpus-v2
feat(bench): give the index corpus a measured efficiency substrate
2026-09-30 08:20:39 -07:00
YHH 21b3b35210 Merge pull request #11423 from esengine/fix/11421-git-bash-discovery
fix(sandbox): find Git Bash outside Program Files when a WSL bash shadows it on PATH
2026-09-30 08:04:41 -07:00
esengine 714c4dc935 ci(feedback): sync in-app feedback into issues and report status back
Adds a scheduled converter (scripts/feedback-sync.mjs) that turns pending
Studio feedback into issues, idempotently via a hidden marker, and reports
in_progress/fixed/wontfix/duplicate back to the worker. A fix recorded as
"next" is upgraded to its studio-v tag once the tag exists.
2026-09-30 08:04:34 -07:00
Guan09 a2a429e3aa fix(studio): tighten user message action spacing 2026-09-30 23:02:44 +08:00
esengine 85810c192d fix(sandbox): find Git Bash outside Program Files when a WSL bash shadows it on PATH
exec.LookPath returns only the first bash.exe on PATH, which is normally the
WSL launcher in System32, and the fallback list covered only the default
Program Files roots. A Git for Windows install elsewhere (with just its cmd
directory on PATH) was therefore never detected, so Studio offered PowerShell
only. Candidates now also come from every PATH directory and from the Git
tree the git.exe on PATH belongs to, and the WSL launcher is rejected in the
candidate loops too.

Fixes #11421
2026-09-30 07:51:57 -07:00
Will 5e2985ab6f feat(bench): fill the index substrate with six tasks that hold on both arms
Screened 87 candidates, staged: one pass over the pool, then two more only for
the candidates that were clean on both boundary arms. The six that held on both
arms in all three passes stay, the rest are dropped, so the efficiency substrate
only contains tasks that qualify:

  quarter  i62-epochlatch, i37-dispatchlag
  half     i106-budgetseal, i70-ingestfloor
  default  i47-tenantlease, i51-routedelay

All six are SnippetStop, SnippetThenRead or DefensiveRead in each of their
eighteen judged cells - nothing post-sufficient.

What the screening found: the answer kind decides whether a candidate holds.
Half-tier candidates whose answer was a bare number never survived (0 of 8);
the same tier with a codename answer survived 11 of 20. The dirty class in both
cases was PostSufficientRetrieval - the model re-checking a value it already had
- so the shape that needs no checking is the one that holds.

Also: i07 and i10 were re-measured after the review asked for it (their old
numbers came from the grep shape they no longer use), NeverSufficient is
reported separately rather than counted as clean, and the i11/i12 rows are gone
because those tasks are not in the corpus.
2026-09-30 22:49:27 +08:00
YHH 6f5c150339 Merge pull request #11425 from esengine/fix/11418-studio-launch-workspace
fix(studio): do not open the app's own install folder as the first workspace
2026-09-30 07:40:21 -07:00
YHH 46c784ba5e Merge pull request #11402 from esengine/ci/studio-cli-pointer
ci(release): studio writes the CLI update pointer the installed updaters read
2026-09-30 07:31:32 -07:00
esengine fb3a57133b fix(studio): do not open the app's own install folder as the first workspace
The host took its working directory as the first pane's project. A Start menu
launch inherits the install directory and a Finder launch the filesystem root,
so the app's folder was listed in the sidebar again after every removal. The
directory now counts only when it is outside the app's own directory and not a
filesystem root; otherwise the first remembered folder that still exists is
opened, then the home directory.

Fixes #11418
2026-09-30 07:22:40 -07:00
esengine b5b91b1c71 feat(studio): set context window and max output per model on a provider
The provider form only edited one window and one output cap for every model
on a connection. model_overrides already carried both per model and the
kernel resolves them for requests and compaction, so the edit endpoint and
the form now read and write them.

A model with no entry inherits the connection's value, shown as a grey
placeholder; a blank field is never sent as an override, and clearing one
removes the entry.
2026-09-30 07:19:36 -07:00
esengine 7db17aff95 ci(release): studio writes the CLI update pointer the installed updaters read
A cli-pointer job publishes the Studio release's CLI archives as a GitHub
release vX.Y.Z (the only shape the 1.x updater accepts), records it
immutably in R2, then advances cli/{stable,preview}/latest.json. It reuses
the 1.x manifest validators and pointer ordering, only moves forward, and
is gated by STUDIO_PUBLISHES_CLI.

cli-gate requires CLI_PUBLISH_FROZEN so two lines never write the CLI
channels. cli-tag creates the vX.Y.Z tag with the release tag identity in
one inline step of a job that runs no repository code. An existing release
is trusted on its own checksums, never compared with a rebuild.
2026-09-30 07:16:14 -07:00
YHH e29dad555f Merge pull request #11417 from esengine/feat/scheduled-exec-3c
fix(schedule): close the remaining review findings before the ticker lands
2026-09-30 06:14:01 -07:00
esengine 539f2368e9 fix(schedule): close the remaining review findings before the ticker lands
- The host creates the schedule store directory (0700) before a command's
  sandbox is built, so the forbid-read mask exists for it on Linux and on macOS
  paths that go through a symlink.
- Document in schedule/doc.go that the confirmed digest defends only against
  sandboxed writers (Windows, sandbox off, yolo and MCP/plugin processes can
  still write the store).
- Only the model's own usage report for an executor request counts toward
  metering; estimates and side calls no longer let unmetered requests slip past.
- The job handle is released on the ready-refused, ready-timeout and cancelled
  paths.
- A stored session path must be a .jsonl file in a sessions directory under the
  state root.
- Tests: the report's untrusted mark, the stderr sanitising, the supervisor
  watch that cancels and forces the process out, and bubblewrap tests for the
  forbidden-directory mask. The child's own state check is dropped; Start
  refuses a run that is not running.

Refs #11406
2026-09-30 06:01:41 -07:00
YHH eb4641d630 Merge pull request #11416 from esengine/feat/scheduled-exec-3b
fix(schedule): close the review findings on the supervised child
2026-09-30 05:57:01 -07:00
esengine e51f8f59f6 fix(schedule): close the review findings on the supervised child
- A run whose provider reports no usage is stopped after two unreported
  requests and charged the whole per-run cap instead of 0.
- The start of a run is one manifest transition guarded by a token that only
  travels over the supervisor's pipe (its hash is what the manifest holds); the
  child announces it holds the run's lease before it is released, and the
  supervisor holds a supervision lock from before the spawn until the run is
  settled, so the reaper never sees a gap.
- The read-only posture runs no config migration, so it rewrites no config file.
- Seatbelt denies writes as well as reads under forbid-read directories, as the
  bubblewrap mask already did, so a blind overwrite of the schedule store fails.
- Windows refuses to run a child that a Job Object cannot hold; the job handle is
  released; a child whose supervisor left is forced out after a grace period.
- The stored report is marked untrusted, the session path must lie under the
  state root, the stderr tail is sanitized, the supervisor's backstops use the
  policy minimum, the child reads the same user config file the loader does.
- Tests for the mutants the review found surviving; supervised runs are bounded
  by a context so a missing kill fails instead of hanging.

Refs #11406
2026-09-30 05:40:21 -07:00
YHH c6aae2e730 Merge pull request #11413 from esengine/feat/scheduled-exec
feat(schedule): run a claimed scheduled run in a supervised child process
2026-09-30 05:30:59 -07:00
Guan09 e1f30114d2 fix(studio): show copy feedback and remove empty user header 2026-09-30 20:22:49 +08:00
esengine cbcc9551f9 feat(schedule): run a claimed scheduled run in a supervised child process
Adds the hidden `reasonix schedule exec <trigger-id>` child and a supervisor
that starts it, counts the tokens it reports and ends it, process tree and
all, when the wall clock or 1.25x the per-run token cap passes. A run that is
killed, crashes or loses its usage stream is charged the whole per-run cap.

The child holds an operating-system lease for the run, waits for the
supervisor's release line (so a run the store already settled spends nothing;
MarkRunning returning ErrRunSettled kills the child), reads its policy from the
user's configuration only, builds the read-only posture, and keeps reading its
stdin: the end of that stream is the supervisor going away, and the child then
stops and settles itself. A run that parks one kind of request too often
(max_repeat_parks, default 3, user level) is ended.

The final report and parked records are sanitized and bounded by the store on
the way in (PutResult). The schedule store directory joins the protected read
set of every run and the file tools' write guard; Grant.Tools narrows the read
tools of the posture.

Refs #11406
2026-09-30 05:05:42 -07:00
YHH 83856b0f00 Merge pull request #11408 from esengine/feat/scheduled-observe-posture
feat(boot): a read-only posture for unattended runs
2026-09-30 04:18:30 -07:00
esengine 08c717c2fa test(boot): the dot-dot case names a missing file on windows, which is not a leak 2026-09-30 04:07:35 -07:00
esengine e0b45d186a fix(builtin): resolve read paths the way the system opens them
realPath no longer cleans a POSIX path lexically before resolving it, so a
'..' after a symlink is checked against the file the system opens. On Windows
it asks for the final path through a handle, which follows junctions and
settles short names, case and the extended and UNC spellings. read_file
re-checks the scope against the open file. LockSurface freezes SetTools and
SetExtensions once assembly has installed them; Sanitize now removes
zero-width, tag and line-separator characters and runs before a record reaches
any store; a workspace root is refused when the home directory is unknown.

Refs #11406
2026-09-30 04:00:34 -07:00
esengine 2fcb221fad fix(boot): confine the read-only posture to the workspace and close review findings
Read tools take readRoots, checked after symlinks resolve, with the typed
refusal workspace.read_outside_scope; walkers, glob matches and ripgrep
(--no-follow) honour it. The posture forces both secret-protection switches
on and refuses a workspace containing the home directory or filesystem root.
No hook loads under the posture (an empty non-nil scope list). Rebuilding an
observed controller without the posture is refused (ErrObserveRebuild) and
the executor's gate and asker are locked. Approval records keep the host's
text in Summary and the model's arguments in Detail, marked untrusted and
stripped of control and bidi characters. A run may park at most 32 records,
then it is ended (ErrParkLimit). A tool outside the ceiling is refused with
posture.tool_not_allowed instead of unknown tool.

Refs #11406
2026-09-30 03:46:34 -07:00
esengine a32c4a4c3e feat(boot): a read-only posture for unattended runs
Adds a host-only Options.Observe that builds a run which can only read
files. The tool ceiling is decided by what each tool declares about its
reach (tool.Reach), enforced by a registry that refuses every other Add.
Approvals and ask questions are parked as Pending records and never
answered; the gate and asker cannot be replaced through the controller.
The workspace's reasonix.toml, .mcp.json, project hooks and folder grants
are not read, no MCP server or extension sidecar starts, and the run
context rides the turn tail as <scheduled-run>. The posture records
remote_content=false and whether an OS sandbox backs the tool filter.

Refs #11406
2026-09-30 03:25:40 -07:00
YHH a03391a13f Merge pull request #11407 from esengine/feat/schedule-store
feat(schedule): scheduled-runs store, policy and budget accounting (slice 1)
2026-09-30 03:22:53 -07:00
Guan09 23ade961ba feat(studio): 调整用户消息操作栏并增加复制按钮 2026-09-30 18:20:53 +08:00
esengine d8ad8bdc3f feat(schedule): add the scheduled-runs store, policy and budget accounting
Slice 1 of the scheduled-runs P0 (#11406): a machine-wide manifest with atomic
writes and a schema version, a Policy that only the user layer can loosen,
pessimistic budget accounting, and claim-before-start with deterministic
trigger ids. No process is started and nothing is wired to a frontend.
2026-09-30 03:14:54 -07:00
Will e47a2037f2 feat(bench): screen replacement candidates for the index substrate
The review settled the bar: a task qualifies only if it is clean on both of its
boundary arms, and none of the first six candidates is. This adds five more,
authored to the same shape but with a single value each and a call whose
arguments name the topic (./scripts/x {{cue}}), which is the shape the review
pointed at for i07 and i10.

- i15-ingest-lag and i16-commit-fence (quarter), i17-route-budget and
  i18-salvage-epoch (half), i19-tenant-quota (default).
- All five preflight clean at their own tier's generation.
- The corpus stays unqualified until six candidates hold on both arms across a
  three-pass batch; the ones that do not are dropped rather than promoted.
2026-09-30 17:52:28 +08:00
YHH 092a0272cd Merge pull request #11404 from esengine/fix/memory-overflow-11398
fix(studio): wrap long memory titles inside the row
2026-09-30 02:24:11 -07:00
esengine f151f5ba9a fix(studio): wrap long memory titles inside the row instead of overflowing
The title button in a memory row was flex:none, so a long title (or an
unbroken path/identifier) pushed the scope, date and action past the
container and made the Forget label break vertically. Let it shrink and
wrap within 60% of the row, and keep the trailing action on one line.
2026-09-30 02:11:34 -07:00
YHH 0019ad31d0 Merge pull request #11399 from esengine/ci/studio-cli-channels-no-approval
ci(release): studio cli-channels publishes without an approval environment
2026-09-30 01:55:45 -07:00
esengine 205f4ef09f ci(release): studio cli-channels publishes without an approval environment
Withdraws the studio-cli-channels environment from #11395 at the owner's
decision: no human approval. The job reads the shared NPM_TOKEN and
HOMEBREW_TAP_TOKEN. The contract test and STUDIO_RELEASE.md follow.
2026-09-30 01:45:41 -07:00
759 changed files with 51637 additions and 2547 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 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
+77
View File
@@ -0,0 +1,77 @@
name: Sync in-app feedback
# Turns feedback submitted from Reasonix Studio into issues and reports issue
# state (in progress, fixed in a tag, won't fix, duplicate) back to the worker,
# and mirrors the reporter's replies onto their issue as bot comments.
# Text from the public reaches the script only through the worker's JSON and
# the GitHub API, never through a shell command line or an expression.
on:
schedule:
- cron: '*/10 * * * *'
- cron: '5 3 * * *'
workflow_dispatch:
inputs:
dry_run:
description: Print what would happen without creating issues or writing back
required: false
default: false
type: boolean
full:
description: Re-read every open entry instead of only recently changed issues
required: false
default: false
type: boolean
permissions:
contents: read
issues: write
concurrency:
group: feedback-sync
cancel-in-progress: false
jobs:
sync:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
timeout-minutes: 8
steps:
- name: Check secret
id: gate
env:
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
run: |
if [ -z "$FEEDBACK_ADMIN_TOKEN" ]; then
echo "::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped"
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.gate.outputs.run == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# git tag --contains needs full history; the tag API would cost a request per entry.
fetch-depth: 0
persist-credentials: false
- if: steps.gate.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Sync
if: steps.gate.outputs.run == 'true'
env:
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
# crash.reasonix.io answers runner IPs with a managed challenge; workers.dev serves only the converter's admin paths.
FEEDBACK_BASE_URL: https://reasonix-crash-report.reasonix.workers.dev
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
FULL: ${{ github.event.inputs.full || 'false' }}
SCHEDULE: ${{ github.event.schedule }}
run: |
set -euo pipefail
args=()
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
# The daily schedule sweeps everything the change window could have missed.
if [ "$FULL" = "true" ] || [ "$SCHEDULE" = "5 3 * * *" ]; then args+=(--full); fi
node scripts/feedback-sync.mjs "${args[@]}"
+149 -30
View File
@@ -1107,6 +1107,108 @@ jobs:
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
# A CLI channel that moves while 1.x still publishes would have two lines
# writing it, so every CLI job waits for this check. It runs no repository code.
cli-gate:
name: require the 1.x line to be frozen
needs: resolve
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Require CLI_PUBLISH_FROZEN
env:
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
run: |
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
exit 1
fi
# The v<version> tag the update pointer needs is created here, by the release
# identity the 1.x line also uses, because only that identity may create v*
# tags. This job checks out nothing and runs no repository code; the one step
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
# tag or branch, through the studio-release environment's ref policy.
cli-tag:
name: create the CLI tag
needs: [resolve, publish, cli-gate]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
environment: studio-release
permissions:
contents: read
steps:
- name: Create v<version> on the studio commit
env:
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
READ_TOKEN: ${{ github.token }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
repo=esengine/DeepSeek-Reasonix
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
tag="v${CLI_VERSION#v}"
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
echo "$tag publishes no CLI pointer; no tag is created"
exit 0
fi
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
case "$GITHUB_REF" in
refs/tags/studio-v* | refs/heads/studio) ;;
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
esac
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
actor="$(write_api user)"
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
behind | identical) ;;
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
esac
# Prints the commit the tag points at; status 1 only when the tag does not exist.
tag_commit() {
local ref kind sha
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
cat "$RUNNER_TEMP/tag.error" >&2
exit 1
}
read -r kind sha <<<"$ref"
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
echo "$sha"
}
if existing="$(tag_commit)"; then
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
# A concurrent run may have created the same tag in the meantime.
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
cat "$RUNNER_TEMP/create.error" >&2
exit 1
fi
echo "created $tag at $APPROVED_SHA"
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
# line owns them until this one takes the CLI over; the repository variable
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
@@ -1115,42 +1217,15 @@ jobs:
# candidate cask would become the default install.
cli-channels:
name: publish the CLI to npm and Homebrew
needs: [resolve, publish]
needs: [resolve, publish, cli-gate, cli-tag]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
# Owner approval before anything reaches npm or the tap: the switch alone
# would otherwise publish on every studio tag.
environment: studio-cli-channels
# id-token lets npm publish --provenance attach a Sigstore attestation; the
# registry token still authenticates the publish itself.
permissions:
contents: read
id-token: write
steps:
# A job naming a missing environment gets an unprotected one, so the
# gate is verified rather than assumed; the tokens below exist only in it.
- name: Require the approval gate on studio-cli-channels
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
case "$status" in
200) ;;
404)
echo "::error::environment studio-cli-channels does not exist"
exit 1 ;;
*)
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
exit 1 ;;
esac
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
@@ -1164,7 +1239,7 @@ jobs:
registry-url: https://registry.npmjs.org
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -1175,7 +1250,7 @@ jobs:
- name: Update the Homebrew cask
if: needs.resolve.outputs.candidate != 'true'
env:
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
@@ -1190,3 +1265,47 @@ jobs:
fi
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
git push origin HEAD:main
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
# sit under that tag, so the archives go to a v<version> release. Only a
# -preview.N version moves preview; other prereleases publish nothing.
cli-pointer:
name: publish the CLI update pointer
needs: [resolve, publish, cli-gate, cli-tag]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
concurrency:
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
cancel-in-progress: false
permissions:
contents: write # create the CLI release; cli-tag has already made its tag
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli-archives
path: cli-archives
- name: Publish the release, its record and the pointer
env:
GH_TOKEN: ${{ github.token }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
REPOSITORY: ${{ github.repository }}
ARCHIVES_DIR: cli-archives
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::error::R2 credentials are not configured; the CLI pointer was not written"
exit 1
fi
bash scripts/publish-cli-pointer.sh
+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>
+82 -11
View File
@@ -3,12 +3,12 @@
Two questions about long-horizon memory, and the harness that makes their
answers trustworthy.
-mode=preflight build all 12 tasks under every arm and check the contract
-mode=preflight build all 18 tasks under every arm and check the contract
-mode=snippet does a rank-1 hit hand over the whole answer?
-mode=adversarial send a model after the answer through every host surface
-mode=run-search Experiment R: is searchable canonical a real capability?
-mode=run-boundary Experiment I, paired: 6 tasks across their own cue boundary
-mode=run-index Experiment I, full: 6 tasks x 4 index budgets
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
-mode=calibrate find each index task's PlantAfterGen for its cue tier
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
@@ -31,8 +31,8 @@ The most likely reason is that two of the six index tasks carry heavy-tail
stopping behaviour that swamps the effect being measured.
Existing evidence is not sufficient to tune the shipped 1% budget in either
direction. Reopening this wants a new corpus, not more samples: see
"Index corpus v2" below.
direction. Reopening this wants a new corpus, not more samples: that substrate
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
@@ -74,11 +74,83 @@ scanned for answer literals before the first provider request and again after
the turn. Any answer reaching the model through a tool that is not `recall`
marks the run contaminated and takes it out of every statistic.
## Index corpus v2, if this is reopened
## Index corpus v2
The present index tasks are not a fit substrate: two of six carry stopping
behaviour that dominates the signal. A task should qualify only if a run-index
run reports a `stopping_class` other than `PostSufficientRetrieval` or
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
`i47-tenantlease` and `i112-lagquota` (default).
They came out of five batches of candidates, each screened the same way: one
`-mode=run-index` pass over the batch, then two more only for the candidates that
were clean on both boundary arms, and only the candidates that held on both arms
in all three passes stayed. A candidate that does not hold is dropped, not
promoted. The numbers are candidates, per tier:
| batch | quarter | half | default |
| --- | --- | --- | --- |
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
That table counts candidates that held on both arms in all three passes, which is
not the size of the substrate. 89 candidates were authored, 77 were screened (the
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
the other 19 survivors are spares rather than corpus members. Candidates from the
earlier round are not counted here.
| task | tier | cue present | cue absent |
| --- | --- | ---: | ---: |
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
| `i118-fencelag` | quarter | 3/3 | 3/3 |
| `i106-budgetseal` | half | 3/3 | 3/3 |
| `i70-ingestfloor` | half | 3/3 | 3/3 |
| `i47-tenantlease` | default | 3/3 | 3/3 |
| `i112-lagquota` | default | 3/3 | 3/3 |
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
`NeverSufficient`.
Four of the first six were flagged in review for sharing answer values, probe
queries and prompt wording within a tier. Separating them and re-screening those
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
were replaced - `i90-recoverylag` was tried first and did not hold either, and
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
cosmetic: the shared wording had been carrying two of the six.
Nothing in the task shape separates the survivors from the rest: within the half
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
second, and codename answers survived 8 of 16 in the third. An earlier claim here
that the answer kind decides whether a candidate holds does not survive this
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
failures - the model re-checking a value it already had - but which tasks hit it
was not predictable from their shape at this sample.
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
once each with `-mode=run-index -task <id>`, per task and per arm:
| task | tier | cue present | cue absent |
| --- | --- | --- | --- |
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
and `i01` is clean on both. The cue-present arm is clean for four of them
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
stopping side rather than being retitled. All six are excluded from `run-index`
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
task added to the corpus declares a role, so it cannot arrive without one.
A task qualifies only if it is clean on both of its boundary arms - the
cue-present run and the cue-absent run - in every run of a screening batch. Clean
means a `stopping_class` other than `PostSufficientRetrieval` or
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
all qualify, and a defensive read is deliberately not counted as waste. The
class comes from the three counts in that report — `searches_after_sufficient`,
@@ -86,8 +158,7 @@ class comes from the three counts in that report — `searches_after_sufficient`
sufficiency was reached and how many rounds followed it. The remaining
requirements still apply — successful tool call, answer in the output rather
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
efficiency one.
nothing in the workspace.
`-mode=calibrate` is not a screening tool: it builds and reads back the
fixtures, and reads nothing else, so its cue-visibility profile is the same for
+132 -7
View File
@@ -26,6 +26,12 @@ const (
tierDefault = "default" // visible at default only
)
// role places an index task in the efficiency substrate or the stopping corpus.
const (
roleEfficiency = "efficiency"
roleStopping = "stopping"
)
// plantKind is how a task's target enters the transcript.
const (
plantAssistant = "assistant" // the model's own account: never index-addressed
@@ -45,6 +51,8 @@ type contextTask struct {
AnswerVars []string
CueVar string
CueTier string
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
Role string
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
// so what follows a cue decides which budget addresses it. Frozen, because
// a run that searched for it would let the tested code move its goalposts.
@@ -121,6 +129,20 @@ func num(name string, lo, hi int) varSpec {
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
}
// efficiencyTasks is the substrate a study measures on. Every task in it
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
// because a task carrying post-sufficient stopping behaviour swamps the effect
// an efficiency batch exists to measure.
func efficiencyTasks() []contextTask {
var out []contextTask
for _, t := range indexTasks() {
if t.Role == roleEfficiency {
out = append(out, t)
}
}
return out
}
func searchTasks() []contextTask {
return []contextTask{
{
@@ -192,10 +214,104 @@ func searchTasks() []contextTask {
}
}
func indexTasks() []contextTask {
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
func efficiencyIndexTasks() []contextTask {
out := indexQuarterTasks()
out = append(out, indexHalfTasks()...)
out = append(out, indexDefaultTasks()...)
return out
}
// indexQuarterTasks holds the quarter-tier literals.
func indexQuarterTasks() []contextTask {
return []contextTask{
{
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input",
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
ProbeQuery: "dispatchlag dispatch lag epoch",
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "p37", CallTool: "bash",
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
PlantBody: "dispatch lag epoch = {{epoch}}",
},
{
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
ProbeQuery: "fencelag fence lag",
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "x118", CallTool: "bash",
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
PlantBody: "fence lag = {{v}}",
},
}
}
// indexHalfTasks holds the half-tier literals.
func indexHalfTasks() []contextTask {
return []contextTask{
{
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
ProbeQuery: "ingestfloor quota",
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "q70", CallTool: "bash",
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
{
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
ProbeQuery: "budgetseal route quota",
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "h106", CallTool: "bash",
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
}
}
// indexDefaultTasks holds the default-tier literals.
func indexDefaultTasks() []contextTask {
return []contextTask{
{
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
ProbeQuery: "tenantlease tenant lease",
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "p47", CallTool: "bash",
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
PlantBody: "tenant lease = {{lease}}",
},
{
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
ProbeQuery: "lagquota lagquota lease",
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "d112", CallTool: "bash",
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
PlantBody: "lagquota lease = {{lease}}",
},
}
}
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
// batches and measurable on their own with -task <id>.
func stoppingIndexTasks() []contextTask {
return []contextTask{
{
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
ProbeQuery: "transport fallback probe selector",
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
@@ -206,7 +322,7 @@ func indexTasks() []contextTask {
PlantBody: "fallback selector = {{selector}}",
},
{
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
ProbeQuery: "scheduler handoff grace marker",
Vars: []varSpec{
@@ -220,7 +336,7 @@ func indexTasks() []contextTask {
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
},
{
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input",
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
ProbeQuery: "coalescing policy completion window",
Vars: []varSpec{
@@ -234,7 +350,7 @@ func indexTasks() []contextTask {
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
},
{
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input",
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
ProbeQuery: "recovery fence epoch check",
Vars: []varSpec{
@@ -248,7 +364,7 @@ func indexTasks() []contextTask {
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
},
{
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input",
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
ProbeQuery: "cache probe prefix salt scope",
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
@@ -259,7 +375,7 @@ func indexTasks() []contextTask {
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
},
{
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
ProbeQuery: "dispatch handoff probe mode",
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
@@ -272,6 +388,12 @@ func indexTasks() []contextTask {
}
}
// indexTasks is the whole index corpus, the efficiency substrate first.
func indexTasks() []contextTask {
out := efficiencyIndexTasks()
return append(out, stoppingIndexTasks()...)
}
func allTasks() []contextTask {
return append(searchTasks(), indexTasks()...)
}
@@ -332,6 +454,9 @@ func validateCorpus() error {
if t.CueVar == "" {
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
}
if t.Role != roleEfficiency && t.Role != roleStopping {
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
}
if v, _ := tierScales(t.CueTier); v == nil {
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
}
+8 -1
View File
@@ -62,7 +62,14 @@ func main() {
case "adversarial":
os.Exit(runAdversarial(root))
case "run-index":
os.Exit(runExperiment(experimentIndex, root, *dry, tasks))
batch := tasks
if *only == "" {
// A batch measures the efficiency substrate only. Naming one task
// with -task runs that task, role included, so the stopping corpus
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
+28 -5
View File
@@ -19,16 +19,24 @@ func TestCorpusIsWellFormed(t *testing.T) {
if got := len(searchTasks()); got != 6 {
t.Errorf("search experiment has %d tasks, want 6", got)
}
if got := len(indexTasks()); got != 6 {
t.Errorf("index experiment has %d tasks, want 6", got)
if got := len(indexTasks()); got != 12 {
t.Errorf("index experiment has %d tasks, want 12", got)
}
stopping := map[string]bool{}
tiers := map[string]int{}
for _, task := range indexTasks() {
if task.Role == roleStopping {
stopping[task.ID] = true
continue
}
tiers[task.CueTier]++
}
// Two per tier is what makes the staircase readable: the same task can be
// compared across the boundary its cue sits on, instead of two different
// questions being compared across two arms.
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
}
// Two per tier, over the efficiency substrate, is what makes the staircase
// readable: the same task can be compared across the boundary its cue sits
// on, instead of two different questions being compared across two arms.
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
if tiers[tier] != 2 {
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
@@ -36,6 +44,21 @@ func TestCorpusIsWellFormed(t *testing.T) {
}
}
// The role decides which batch a task enters; the literal lists are only where
// it is declared, so the two must not drift.
func TestIndexListsMatchTheirRoles(t *testing.T) {
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
if task.Role != roleEfficiency {
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
}
}
for _, task := range stoppingIndexTasks() {
if task.Role != roleStopping {
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
}
}
}
// The invariant the whole instantiation layer exists for: no literal a scorer
// needs may be readable in this repository before a run starts.
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
+1 -1
View File
@@ -232,7 +232,7 @@ func runBoundaries(root string) int {
var all []contextMetrics
var cueSide, noCueSide []contextMetrics
for _, t := range indexTasks() {
for _, t := range efficiencyTasks() {
cue, noCue := boundaryPair(t.CueTier)
for _, side := range []struct {
scale string
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"os"
"path/filepath"
"strings"
"reasonix/internal/assembly/boot"
"reasonix/internal/frontend/serve"
)
// launchWorkspace is the folder the first pane opens. The working directory
// counts only when someone chose it: a Start-menu or Finder launch inherits the
// install directory or the filesystem root, and taking that as a project puts
// 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.LaunchWorkspaces(), home)
}
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
if userChoseDir(cwd, appExe) {
return cwd
}
for _, dir := range remembered {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir
}
}
if home != "" {
return home
}
return cwd
}
func userChoseDir(cwd, appExe string) bool {
if cwd == "" || filepath.Dir(cwd) == cwd {
return false
}
rel, err := filepath.Rel(filepath.Dir(appExe), cwd)
if err != nil {
return true
}
inside := rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
return !inside
}
@@ -0,0 +1,92 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
cases := []struct {
name, cwd, exe string
want bool
}{
{"install directory", app, exe, false},
{"below the install directory", filepath.Join(app, "resources"), exe, false},
{"filesystem root", filepath.VolumeName(base) + string(filepath.Separator), exe, false},
{"a project elsewhere", filepath.Join(base, "project"), exe, true},
{"a sibling sharing the name prefix", app + "-notes", exe, true},
{"unpackaged run keeps the directory", app, "", true},
}
for _, c := range cases {
if got := userChoseDir(c.cwd, c.exe); got != c.want {
t.Errorf("%s: userChoseDir(%q) = %v, want %v", c.name, c.cwd, got, c.want)
}
}
}
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
mk := func(name string) string {
d := filepath.Join(base, name)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
return d
}
a, b, home := mk("a"), mk("b"), mk("home")
gone := filepath.Join(base, "gone")
proj := mk("proj")
cases := []struct {
name string
cwd string
remembered []string
want string
}{
{"chosen directory wins over the list", proj, []string{a}, proj},
{"list head", app, []string{a, b}, a},
{"stale head skipped", app, []string{gone, b}, b},
{"all stale falls to home", app, []string{gone}, home},
{"empty list falls to home", app, nil, home},
}
for _, c := range cases {
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
}
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)
}
}
+13 -7
View File
@@ -34,6 +34,7 @@ import (
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
"reasonix/internal/platform/appupdate"
"reasonix/internal/platform/feedback"
"reasonix/internal/platform/instanceid"
"reasonix/internal/platform/notify"
"reasonix/internal/platform/remotecloud"
@@ -311,14 +312,19 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
paneSink = reporter.Wrap(paneSink)
}
}
root := boot.ResolveWorkspaceRoot("")
root := launchWorkspace(shell.exe)
built, err := boot.BuildRuntime(ctx, boot.Options{
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
Stderr: logs,
StatsSource: surface.Desktop,
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
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")
}
}
+23 -99
View File
@@ -2,7 +2,6 @@ package main
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
@@ -11,10 +10,7 @@ import (
"gopkg.in/yaml.v3"
)
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
// The 1.x line reads these repository secrets; studio must never touch them.
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
var cliChannelSecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
t.Helper()
@@ -94,8 +90,8 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
if job == nil {
t.Fatal("cli-channels job is missing")
}
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
if mappingValue(job, "environment") != nil {
t.Fatal("cli-channels must not declare an environment; the switch and the tag rule are its controls")
}
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
@@ -113,18 +109,11 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
}
}
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
}
assertGateBeforePublishing(t, job)
for _, name := range cliChannelSecrets {
if !secretRefPattern(name).MatchString(scalarString(job)) {
t.Fatalf("cli-channels does not read secrets.%s", name)
}
}
if name, found := refersTo(job, legacyRegistrySecrets); found {
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
}
}
func scalarString(node *yaml.Node) string {
@@ -133,6 +122,20 @@ func scalarString(node *yaml.Node) string {
return strings.Join(scalars, "\n")
}
func TestReleaseStudioGrantsNoWriteAll(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
if top := mappingValue(root, "permissions"); top != nil && top.Kind == yaml.ScalarNode && top.Value == "write-all" {
t.Error("release-studio.yml grants write-all")
}
jobs := jobsOf(t, root)
for i := 0; i+1 < len(jobs.Content); i += 2 {
name, job := jobs.Content[i].Value, jobs.Content[i+1]
if p := mappingValue(job, "permissions"); p != nil && p.Kind == yaml.ScalarNode && p.Value == "write-all" {
t.Errorf("release-studio.yml job %s grants write-all", name)
}
}
}
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
files, err := filepath.Glob("../../.github/workflows/*.yml")
if err != nil || len(files) == 0 {
@@ -173,7 +176,7 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
if err != nil || len(files) == 0 {
t.Fatalf("no workflows found: %v", err)
}
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
guarded := cliChannelSecrets
for _, file := range files {
root := parseWorkflowFile(t, file)
jobs := mappingValue(root, "jobs")
@@ -196,18 +199,18 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
continue
}
if name, found := refersTo(root, cliChannelSecrets); found {
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
t.Errorf("%s reaches %s, which only release-studio.yml cli-channels may read", file, name)
}
}
}
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
for _, text := range []string{
"${{ secrets.STUDIO_NPM_TOKEN }}",
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
"${{ secrets.NPM_TOKEN }}",
"${{ secrets['NPM_TOKEN'] }}",
`${{ secrets["NPM_TOKEN"] }}`,
"${{ toJSON(secrets) }}",
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
"${{ secrets[format('NPM_{0}', 'TOKEN')] }}",
} {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
@@ -225,82 +228,3 @@ func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
t.Error("an unrelated secret was reported")
}
}
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
t.Helper()
steps := mappingValue(job, "steps")
if steps == nil || steps.Kind != yaml.SequenceNode {
t.Fatal("cli-channels has no steps")
}
gate := -1
for i, step := range steps.Content {
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
gate = i
break
}
}
if gate < 0 {
t.Fatal("cli-channels has no environment check step")
}
for i, step := range steps.Content[:gate] {
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
t.Errorf("step %d runs or gets credentials before the environment check", i)
}
}
for i, step := range steps.Content {
if i > gate {
continue
}
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
if nodeContains(step, name) {
t.Errorf("step %d references %s before or at the environment check", i, name)
}
}
}
}
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash not available")
}
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
job := mappingValue(jobsOf(t, root), "cli-channels")
var script string
for _, step := range mappingValue(job, "steps").Content {
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
script = run
break
}
}
if script == "" {
t.Fatal("environment check step not found")
}
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
cases := []struct {
name, response string
pass bool
}{
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
{"empty body", "HTTP/2.0 200 OK\n\n", false},
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
}
for _, tc := range cases {
dir := t.TempDir()
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
out, err := cmd.CombinedOutput()
passed := err == nil && strings.Contains(string(out), "PASSED")
if passed != tc.pass {
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
job := mappingValue(jobsOf(t, root), "cli-pointer")
if job == nil {
t.Fatal("cli-pointer job is missing")
}
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
}
needs := mappingValue(job, "needs")
if needs == nil || needs.Kind != yaml.SequenceNode {
t.Fatal("cli-pointer must list its needs")
}
seen := map[string]bool{}
for _, n := range needs.Content {
seen[n.Value] = true
}
if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] {
t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen)
}
concurrency := mappingValue(job, "concurrency")
if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") {
t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli-<channel> group")
}
if mappingScalar(concurrency, "cancel-in-progress") != "false" {
t.Fatal("cli-pointer must not cancel a run that is mid-write")
}
permissions := mappingValue(job, "permissions")
if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 ||
mappingScalar(permissions, "contents") != "write" {
t.Fatal("cli-pointer permissions must be exactly contents: write")
}
if !nodeContains(job, "scripts/publish-cli-pointer.sh") {
t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh")
}
if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") {
t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script")
}
if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found {
t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name)
}
}
func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
gate := mappingValue(jobs, "cli-gate")
if gate == nil {
t.Fatal("cli-gate job is missing")
}
if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
}
if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") {
t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN")
}
if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") {
t.Fatal("cli-gate must run no repository code and read no secret")
}
for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} {
needs := mappingValue(mappingValue(jobs, name), "needs")
found := false
for _, n := range needs.Content {
found = found || n.Value == "cli-gate"
}
if !found {
t.Fatalf("%s does not wait for cli-gate", name)
}
}
for _, name := range []string{"cli-channels", "cli-pointer"} {
found := false
for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content {
found = found || n.Value == "cli-tag"
}
if !found {
t.Fatalf("%s does not wait for cli-tag", name)
}
}
}
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
func workflowFiles(t *testing.T) []string {
t.Helper()
var files []string
for _, pattern := range []string{"*.yml", "*.yaml"} {
found, err := filepath.Glob("../../.github/workflows/" + pattern)
if err != nil {
t.Fatal(err)
}
files = append(files, found...)
}
if len(files) == 0 {
t.Fatal("no workflows found")
}
return files
}
// The token may appear in one inline step of Studio's cli-tag job and in no
// other env scope: not the workflow's, not a job's, not another step's.
func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) {
holders := 0
for _, file := range workflowFiles(t) {
root := parseWorkflowFile(t, file)
studio := filepath.Base(file) == "release-studio.yml"
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Value == "jobs" {
continue
}
if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found {
t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value)
}
}
jobs := mappingValue(root, "jobs")
if jobs == nil {
continue
}
for i := 0; i+1 < len(jobs.Content); i += 2 {
id, job := jobs.Content[i].Value, jobs.Content[i+1]
allowed := studio && id == "cli-tag"
for j := 0; j+1 < len(job.Content); j += 2 {
key, value := job.Content[j].Value, job.Content[j+1]
if key != "steps" {
if what, found := refersTo(value, releaseTagSecrets); found {
t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key)
}
continue
}
for _, step := range value.Content {
if what, found := refersTo(step, releaseTagSecrets); found {
holders++
if !allowed {
t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what)
}
if mappingScalar(step, "uses") != "" {
t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id)
}
} else if allowed && mappingScalar(step, "uses") != "" {
t.Errorf("cli-tag must check out nothing and run no action")
}
}
}
}
}
if holders != 1 {
t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders)
}
}
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 };
+57 -12
View File
@@ -5,22 +5,40 @@ const PROTOCOL_VERSION = 1;
// A child that never sends a handshake must not be able to grow this process
// while it waits, nor hold the launch open forever.
const HANDSHAKE_LIMIT = 4096;
const HANDSHAKE_TIMEOUT_MS = 20000;
const HANDSHAKE_TIMEOUT_MS = 60000;
const SLOW_AFTER_MS = 3000;
// HostStartError says why a launch has no kernel. code is one of "timeout",
// "exited", "spawn", "overflow" or "malformed"; callers branch on it, never on
// the sentence.
class HostStartError extends Error {
constructor(code, message, detail = {}) {
super(message);
this.name = "HostStartError";
this.code = code;
Object.assign(this, detail);
}
}
// start spawns the kernel and reads the handshake it writes to stdout. Every
// line after it is an act a handover asks of this process, because that pipe is
// the only channel pointing this way. stdin is the lease: the kernel drains
// when this end closes, which is what stops it outliving a parent that exited
// without being asked to.
function start(binary, args, { onStderr, onExit, onAct } = {}) {
function start(binary, args, { onStderr, onExit, onAct, onSlow, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.setEncoding("utf8");
if (onStderr) child.stderr.on("data", onStderr);
if (onExit) child.on("exit", onExit);
const ready = firstLine(child).then(({ line, rest }) => {
const ready = firstLine(child, { onSlow, timeoutMs }).then(({ line, rest }) => {
// Only once the handshake parses: a child whose first line was not one has
// said nothing this process should act on.
const handshake = parse(line);
let handshake;
try {
handshake = parse(line);
} catch (err) {
throw new HostStartError("malformed", err.message);
}
if (onAct) readActs(child, rest, onAct);
return handshake;
});
@@ -60,19 +78,31 @@ function readActs(child, rest, onAct) {
drain();
}
function firstLine(child) {
function firstLine(child, { onSlow, timeoutMs }) {
return new Promise((resolve, reject) => {
let buffered = "";
const began = Date.now();
const elapsed = () => Date.now() - began;
const settle = (fn, value) => {
clearTimeout(timer);
clearTimeout(slow);
child.stdout.off("data", onData);
child.off("exit", onEarlyExit);
child.off("error", onSpawnFailure);
fn(value);
};
const slow = onSlow ? setTimeout(onSlow, Math.min(SLOW_AFTER_MS, timeoutMs)) : undefined;
const timer = setTimeout(
() => settle(reject, new Error("the kernel sent no handshake")),
HANDSHAKE_TIMEOUT_MS,
() =>
settle(
reject,
new HostStartError(
"timeout",
`the kernel was still running but sent no handshake within ${Math.round(timeoutMs / 1000)} s`,
{ elapsedMs: elapsed() },
),
),
timeoutMs,
);
const onData = (chunk) => {
buffered += chunk;
@@ -81,15 +111,22 @@ function firstLine(child) {
return settle(resolve, { line: buffered.slice(0, end), rest: buffered.slice(end + 1) });
}
if (buffered.length > HANDSHAKE_LIMIT) {
settle(reject, new Error("the handshake ran past its limit"));
settle(reject, new HostStartError("overflow", "the handshake ran past its limit"));
}
};
const onEarlyExit = (code) =>
settle(reject, new Error(`the kernel exited with ${code} before saying anything`));
const onEarlyExit = (code, signal) =>
settle(
reject,
new HostStartError("exited", `the kernel exited with ${code ?? `signal ${signal}`} before saying anything`, {
exitCode: code,
signal,
elapsedMs: elapsed(),
}),
);
// A program that could not be started emits this and never exits, so without
// it the launch waited out the handshake timeout and then reported the
// kernel as silent — which sent the reader looking at the kernel.
const onSpawnFailure = (err) => settle(reject, new Error(`the kernel could not be started: ${err.message}`));
const onSpawnFailure = (err) => settle(reject, new HostStartError("spawn", `the kernel could not be started: ${err.message}`, { elapsedMs: elapsed() }));
child.stdout.setEncoding("utf8");
child.stdout.on("data", onData);
child.on("exit", onEarlyExit);
@@ -128,4 +165,12 @@ function parse(line) {
return { origin: url.origin, token: body.token };
}
module.exports = { start, parse, readActs, PROTOCOL_VERSION };
const RETRY_WITHIN_MS = 10000;
// shouldRetry allows one more attempt only for a kernel that stopped, or could
// not be run, soon after it was started.
function shouldRetry(err, attempt, elapsedMs) {
return attempt < 2 && (err?.code === "exited" || err?.code === "spawn") && elapsedMs <= RETRY_WITHIN_MS;
}
module.exports = { shouldRetry, start, parse, readActs, HostStartError, PROTOCOL_VERSION, HANDSHAKE_TIMEOUT_MS };
+87 -25
View File
@@ -7,15 +7,17 @@ if (relaunchForOzonePlatform(app, process)) return;
const fs = require("node:fs/promises");
const { existsSync } = require("node:fs");
const path = require("node:path");
const { start } = require("./host");
const { start, shouldRetry, HANDSHAKE_TIMEOUT_MS } = require("./host");
const { showStarting } = require("./starting");
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");
@@ -23,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
@@ -45,6 +47,7 @@ const LIGHTS = { x: 11, y: 20 };
const DEFAULT_SIZE = { width: 1440, height: 900 };
const MIN_SIZE = { width: 760, height: 480 };
const HOST_DRAIN_MS = 1000;
const RETRY_DELAY_MS = 1000;
const where = {
packaged: app.isPackaged,
@@ -70,6 +73,69 @@ let reload = null;
let logs = null;
let handshaken = false;
let starting = null;
function closeStarting() {
const win = starting;
starting = null;
if (win && !win.isDestroyed()) win.close();
}
function handshakeTimeout() {
const ms = Number(process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS);
return Number.isFinite(ms) && ms > 0 ? ms : HANDSHAKE_TIMEOUT_MS;
}
// A kernel that exits or cannot be spawned within seconds is tried once more:
// a scanner holding a freshly written binary lets go about that fast. A slow
// kernel is waited for instead, never restarted.
async function launchKernel(args) {
for (let attempt = 1; attempt <= 2; attempt++) {
if (quitting) throw new Error("the launch was ended by quit");
const began = Date.now();
logs.shell.line(`host: starting ${hostBinary} (attempt ${attempt})`);
kernel = start(hostBinary, args, {
timeoutMs: handshakeTimeout(),
onSlow: () => {
logs.shell.line("host: no handshake yet; showing the starting window");
if (starting || quitting) return;
const win = showStarting(app.getLocale());
starting = win;
win.once("closed", () => {
if (starting !== win) return;
starting = null;
logs.shell.line("host: starting window closed; ending the launch");
app.quit();
});
},
onStderr: (text) => {
logs.host.raw(text);
process.stderr.write(text);
},
onExit: (code, signal) => {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
if (handshaken && code !== 0 && !quitting) app.quit();
},
onAct: handOver,
});
const current = kernel;
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
current.child.stderr.on("close", () => logs.host.flush());
try {
return await current.ready;
} catch (err) {
if (quitting || !shouldRetry(err, attempt, Date.now() - began)) {
err.attempts = attempt;
closeStarting();
throw err;
}
logs.shell.line(`host: ${err.message}; retrying once`);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
}
}
async function boot() {
// Which build this is belongs to the shell: inside the bundle the kernel's
// own os.Executable() names the host binary, not the application around it.
@@ -86,23 +152,7 @@ async function boot() {
// inside the bundle rather than being it.
args.push("-studio-app", process.execPath, "-studio-app-pid", String(process.pid));
}
logs.shell.line(`host: starting ${hostBinary}`);
kernel = start(hostBinary, args, {
onStderr: (text) => {
logs.host.raw(text);
process.stderr.write(text);
},
onExit: (code, signal) => {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
if (handshaken && code !== 0 && !quitting) app.quit();
},
onAct: handOver,
});
kernel.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
kernel.child.stderr.on("close", () => logs.host.flush());
const ready = await kernel.ready;
const ready = await launchKernel(args);
logs.addSecret(ready.token);
handshaken = true;
logs.shell.line(`host: handshake from ${ready.origin}`);
@@ -110,13 +160,14 @@ async function boot() {
client = new StudioHost(ready.origin, ready.token);
await armCredential(ready);
win = createWindow();
closeStarting();
guard(win.webContents);
win.webContents.once("render-process-gone", (_event, details) => {
if (win.isVisible() || details.reason !== "crashed") return;
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());
@@ -201,6 +252,7 @@ function onWindowClose(event) {
// showWindow brings it back from wherever it went. Show alone is a no-op on a
// window that is merely buried, so the focus is what actually raises it.
function showWindow() {
if (starting && !starting.isDestroyed()) starting.focus();
if (quitting || !win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
reload?.revive();
@@ -263,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();
@@ -300,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" },
);
@@ -377,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) {
@@ -423,7 +481,11 @@ function handOver(act) {
if (act === "quit") app.quit();
}
app.on("window-all-closed", () => app.quit());
// Before the handshake the only window is the starting one, and the launch
// decides for itself whether to go on.
app.on("window-all-closed", () => {
if (handshaken) app.quit();
});
// Closing this end of the pipe is what tells the kernel to drain. Without it a
// session file is left being written by a process nobody is holding open.
+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 };
+25 -12
View File
@@ -159,17 +159,29 @@ function openLogs(userData, options = {}) {
};
}
function startupFailure(locale, reason, logDir) {
if (String(locale).toLowerCase().startsWith("zh")) {
return {
title: "Reasonix Studio 无法启动",
detail: `${reason}\n\n日志位于:\n${logDir}`,
};
const HINTS = {
timeout: [
"The kernel started but did not finish starting in time. Security software scanning a new or updated install is a common cause; starting Studio again usually works.",
"内核已启动,但没有在限定时间内完成启动。安全软件扫描新安装或刚更新的程序是常见原因;再次启动通常可以。",
],
exited: [
"The kernel process stopped before it was ready. Anything it printed is above; host.log has the rest.",
"内核进程在就绪前就停止了。它输出过的内容在上面,host.log 里有更多。",
],
spawn: [
"The kernel program could not be run. Security software blocking or locking it is a common cause.",
"无法运行内核程序。安全软件拦截或占用它是常见原因。",
],
};
function startupFailure(locale, reason, logDir, code) {
const zh = String(locale).toLowerCase().startsWith("zh");
const hint = HINTS[code]?.[zh ? 1 : 0];
const body = hint ? `${reason}\n\n${hint}` : reason;
if (zh) {
return { title: "Reasonix Studio 无法启动", detail: `${body}\n\n日志位于:\n${logDir}` };
}
return {
title: "Reasonix Studio could not start",
detail: `${reason}\n\nLogs are in:\n${logDir}`,
};
return { title: "Reasonix Studio could not start", detail: `${body}\n\nLogs are in:\n${logDir}` };
}
// failStartup is the one way a launch that cannot come up ends: recorded, told
@@ -177,8 +189,9 @@ function startupFailure(locale, reason, logDir) {
// words when it never finished its handshake, and is already redacted.
function failStartup({ app, dialog, logs, locale }, err, hostOutput = "") {
logs.shell.line(`startup failed: ${err?.stack || err}`);
const reason = [String(err?.message || err), hostOutput].filter(Boolean).join("\n\n");
const text = startupFailure(locale, logs.redact(reason), logs.dir);
const tried = err?.attempts > 1 ? ` (after ${err.attempts} attempts)` : "";
const reason = [`${String(err?.message || err)}${tried}`, hostOutput].filter(Boolean).join("\n\n");
const text = startupFailure(locale, logs.redact(reason), logs.dir, err?.code);
try {
dialog.showErrorBox(text.title, text.detail);
} catch (e) {
+41
View File
@@ -0,0 +1,41 @@
"use strict";
const { BrowserWindow } = require("electron");
const TEXT = {
en: ["Reasonix Studio is starting", "The first start after installing or rebooting can take a minute."],
zh: ["Reasonix Studio 正在启动", "安装或重启电脑后的首次启动可能需要一分钟。"],
};
function startingPage(locale) {
const [title, note] = String(locale).toLowerCase().startsWith("zh") ? TEXT.zh : TEXT.en;
return `<!doctype html><meta charset="utf-8"><title>${title}</title><style>
:root{color-scheme:light dark;--bg:#fafafa;--fg:#1c1c1e;--mute:#6b6b70}
@media(prefers-color-scheme:dark){:root{--bg:#1b1b1d;--fg:#ececee;--mute:#9a9aa0}}
body{margin:0;height:100vh;display:flex;flex-direction:column;justify-content:center;gap:8px;padding:0 28px;box-sizing:border-box;background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}
h1{margin:0;font-size:16px;font-weight:600}p{margin:0;color:var(--mute);line-height:1.5}
.bar{height:3px;margin-top:10px;border-radius:2px;background:var(--mute);opacity:.35;overflow:hidden;position:relative}
.bar::after{content:"";position:absolute;inset:0 70% 0 0;background:var(--fg);animation:run 1.4s ease-in-out infinite}
@keyframes run{from{transform:translateX(-100%)}to{transform:translateX(340%)}}
</style><h1>${title}</h1><p>${note}</p><div class="bar"></div>`;
}
// showStarting is the only sign of life while the kernel has not answered: until
// it does there is no window of the application's own to draw.
function showStarting(locale) {
const win = new BrowserWindow({
width: 380,
height: 150,
resizable: false,
minimizable: false,
maximizable: false,
frame: true,
title: "Reasonix Studio",
autoHideMenuBar: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false },
});
win.removeMenu();
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startingPage(locale))}`);
return win;
}
module.exports = { showStarting, startingPage };
+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();
+178 -4
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 () => {
@@ -314,6 +393,79 @@ test("a kernel that cannot be started says so, rather than going quiet", async (
});
});
test("a launch failure carries a typed cause, and a slow kernel is waited for, not restarted", async () => {
const { start } = require("../src/host.js");
const node = process.execPath;
let slow = 0;
const silent = start(node, ["-e", "setTimeout(()=>{},5000)"], { timeoutMs: 400, onSlow: () => slow++ });
await assert.rejects(silent.ready, (err) => {
assert.equal(err.code, "timeout");
assert.match(err.message, /still running/);
return true;
});
silent.child.kill();
assert.equal(slow, 1, "the slow state was not reported once");
const quick = start(node, ["-e", "process.exit(7)"]);
await assert.rejects(quick.ready, (err) => err.code === "exited" && err.exitCode === 7 && /exited with 7/.test(err.message));
const missing = start(path.join(os.tmpdir(), "reasonix-no-such-kernel-c9"), []);
await assert.rejects(missing.ready, (err) => err.code === "spawn");
const handshake = JSON.stringify({ version: 1, origin: "http://127.0.0.1:1", token: "x".repeat(32) });
const fine = start(node, ["-e", `console.log(${JSON.stringify(handshake)});setTimeout(()=>{},300)`], { timeoutMs: 400 });
assert.equal((await fine.ready).origin, "http://127.0.0.1:1");
});
test("a retry needs an early stop, is capped at one, and never follows a timeout", () => {
const { shouldRetry } = require("../src/host.js");
const exited = { code: "exited" };
assert.equal(shouldRetry(exited, 1, 500), true);
assert.equal(shouldRetry({ code: "spawn" }, 1, 500), true);
assert.equal(shouldRetry(exited, 1, 10001), false, "an exit after the early window was retried");
assert.equal(shouldRetry(exited, 2, 500), false, "a second retry was allowed");
assert.equal(shouldRetry({ code: "timeout" }, 1, 500), false);
assert.equal(shouldRetry(new Error("x"), 1, 500), false);
});
test("a handshake that does not parse is a typed failure", async () => {
const { start } = require("../src/host.js");
const bad = start(process.execPath, ["-e", "console.log('hello')"]);
await assert.rejects(bad.ready, (err) => err.code === "malformed");
});
test("a kernel that never answers is started once and not retried", { skip: process.platform === "win32" }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-silent-"));
const host = path.join(dir, "host.sh");
fs.writeFileSync(host, "#!/bin/sh\nexec sleep 5\n", { mode: 0o755 });
process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS = "400";
const shell = loadShell({ lock: true, host });
try {
await shell.quitted;
const log = shell.shellLog();
assert.equal((log.match(/host: starting /g) || []).length, 1);
assert.doesNotMatch(log, /retrying once/);
assert.match(shell.calls[0][2], /still running/);
} finally {
delete process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS;
shell.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("each failure cause gets its own explanation in both languages", () => {
const { startupFailure } = require("../src/shelllog.js");
const seen = new Set();
for (const code of ["timeout", "exited", "spawn"]) {
for (const locale of ["en", "zh-CN"]) {
const { detail } = startupFailure(locale, "r", "/logs", code);
assert.ok(!seen.has(detail), `${code}/${locale} repeated another cause's text`);
seen.add(detail);
}
}
assert.equal(startupFailure("en", "r", "/logs", "overflow").detail, "r\n\nLogs are in:\n/logs");
});
// A fake child: only the stdout half readActs touches, and a way to push bytes
// through it in whatever chunks the test wants -- which is the point, since the
// bug this guards is a line split across two of them.
@@ -1104,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;
@@ -1163,8 +1316,29 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
assert.match(shell.hostLog(), /bad option/, "the host's stderr never reached host.log");
const log = shell.shellLog();
assert.match(log, /host: exited code=9 signal=null before its handshake/);
assert.match(log, /startup failed: Error: /);
assert.match(log, /startup failed: HostStartError: /);
assert.equal((log.match(/host: starting /g) || []).length, 2, "an early exit was not retried exactly once");
assert.match(log, /retrying once/);
assert.match(detail, /after 2 attempts/);
assert.match(detail, /stopped before it was ready/, "no cause was given for an early exit");
} finally {
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 # 跟随系统语言:中文各写法都归中文,其余归英文
+5 -2
View File
@@ -4,6 +4,7 @@ import { track as trackWidth } from "../src/ui/viewport";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/studio.css";
import "../src/styles/feedback.css";
import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
@@ -44,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 };
}
@@ -148,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);
+43
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,37 @@ 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" },
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
@@ -196,6 +231,8 @@ export const ACTIONS: UIAction[] = [
{ id: "market.sort", kind: "view", target: "none", proof: "interaction" },
{ id: "market.pinned", kind: "view", target: "none", proof: "interaction" },
{ id: "market.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "market.retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.detail-retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.more", kind: "view", target: "none", proof: "interaction" },
{ id: "market.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.back", kind: "navigation", target: "none", proof: "interaction" },
@@ -215,8 +252,11 @@ export const ACTIONS: UIAction[] = [
// The account's own packages: a preview, an install pinned to that preview's
// digest, and sending a private one to review.
{ id: "market.own-inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.own-retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ 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" },
@@ -254,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" },
@@ -268,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"
]
+18
View File
@@ -16,16 +16,20 @@ import { EN_STORAGE } from "./en_storage";
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,
...EN_COMPOSER,
...EN_BACKUP,
...EN_FEEDBACK,
...EN_KERNEL,
...EN_REMOTE,
...EN_WINDOW,
@@ -67,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",
@@ -276,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",
@@ -331,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}",
@@ -1090,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",
@@ -1213,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",
@@ -0,0 +1,133 @@
// English for the in-app feedback dialog and its refusals.
export const EN_FEEDBACK: Record<string, string> = {
"反馈": "Feedback",
"已完成:": "Done: ",
"当前:": "Current: ",
"尚未开始:": "Not yet: ",
"发送反馈": "Send feedback",
"我的反馈": "My feedback",
"问题": "Bug",
"建议": "Idea",
"疑问": "Question",
"其他": "Other",
"已收到": "Received",
"已登记": "Recorded",
"处理中": "In progress",
"已修复": "Fixed",
"不予修复": "Won't fix",
"重复": "Duplicate",
"已登记为 {issue}": "Recorded as {issue}",
"已在 {version} 修复": "Fixed in {version}",
"已修复,将随下个版本发布": "Fixed, ships in the next version",
"与 {issue} 重复": "Duplicate of {issue}",
"已解决": "Resolved",
"有些反馈是用这台电脑以前的身份发出的,已经查不到它们的最新状态。": "Some reports were sent under this computer's earlier identity, so their current status can no longer be read.",
"状态已无法追踪": "Status no longer trackable",
"处理进展": "Progress",
"刷新列表": "Refresh list",
"每次打开这一页时刷新。状态和回复来自维护者,已登记的反馈会跟随对应的 GitHub 议题。":
"Refreshed each time you open this page. Status and replies come from the maintainers; a report that was filed follows its GitHub issue.",
"暂时连不上反馈服务。下面是保存在本机的记录,状态可能不是最新的。":
"Cannot reach the feedback service right now. This is what was saved on this machine, and statuses may be out of date.",
"正在读取你的反馈…": "Loading your feedback…",
"还没有提交过反馈。发出第一条之后,它的进展会出现在这里。": "No feedback sent yet. Once you send one, its progress shows up here.",
"复制回执号": "Copy receipt number",
"回执号": "Receipt number",
"已收到你的反馈": "Your feedback was received",
"回执号是这份反馈的凭据,请留着它。我们会先看一遍,只有被登记为 GitHub 议题的内容才会公开。进展、议题链接,以及我们的回复或提问,都会出现在「我的反馈」里。":
"Keep the receipt number: it identifies this report. We read it first, and only what we file as a GitHub issue becomes public. Progress, the issue link and any reply or question from us appear under My feedback.",
"你的文字里有看起来像密钥的内容,已在发出前打码。": "Text in your report that looked like a secret was masked before it was sent.",
"查看我的反馈": "View my feedback",
"再写一条": "Write another",
"只有被我们登记为议题的反馈才会公开":
"It becomes public only if we file it as an issue",
"如果登记为 GitHub 议题,你的文字、昵称和截图会发布在 esengine/DeepSeek-Reasonix 仓库,任何人都看得到;不登记就不会公开。你会在这里收到回执,登记后还会看到议题链接,我们也可能在这里回复或向你提问。":
"If we file it as a GitHub issue, your text, nickname and screenshots are posted in the esengine/DeepSeek-Reasonix repository, where anyone can read them; if we do not, it stays private. You will see a receipt here and, once filed, the issue link, and we may reply or ask you questions here.",
"请不要在文字或截图里写入密钥、账号密码、聊天记录或私有代码:一旦公开就收不回来。":
"Do not put secrets, passwords, chats or private code in the text or in screenshots: once public it cannot be taken back.",
"发现的是安全漏洞?请不要在这里提交,{link}。": "Found a security vulnerability? Do not post it here; {link}.",
"按安全策略私下报告": "report it privately under the security policy",
"反馈内容": "Feedback details",
"反馈类别": "Feedback category",
"发生了什么?": "What happened?",
"描述你遇到的情况,或你希望它怎样。": "Describe what you ran into, or what you would like instead.",
"{n} / {max} 字节": "{n} / {max} bytes",
"昵称(登记为议题时公开)": "Nickname (public if filed as an issue)",
"如果登记为议题,会显示为「由 {name} 提交」。用昵称即可,不必真名。":
"If it is filed as an issue it shows as \"Reported by {name}\". A nickname is fine; no real name needed.",
"联系方式(可选,私密)": "Contact (optional, private)",
"邮箱或 QQ。只有维护者看得到,绝不会出现在 GitHub 上。": "Email or QQ. Only the maintainers see it; it is never posted on GitHub.",
"截图(可选,登记为议题时公开)":
"Screenshots (optional, public if filed as an issue)",
"移除截图 {name}": "Remove screenshot {name}",
"添加截图…": "Add screenshots…",
"选择截图文件": "Choose screenshot files",
"也可以直接粘贴或拖进来。PNG 或 JPEG,最多 {n} 张,每张不超过 {size} MB,较大的图会自动缩小。":
"You can also paste or drop them here. PNG or JPEG, up to {n} images, {size} MB each; large images are shrunk automatically.",
"如果这份反馈被登记为公开议题,截图会和文字一起公开。发送前请确认没有露出密钥、聊天记录或私有代码。":
"If this report is filed as a public issue, your screenshots are published with your text. Before sending, check they show no keys, chats or private code.",
"{name}:只支持 PNG 或 JPEG": "{name}: only PNG or JPEG is supported",
"{name}:超过 {size} MB,没有添加": "{name}: larger than {size} MB, not added",
"{name}:最多 {n} 张,没有添加": "{name}: the limit is {n} images, not added",
"随反馈一起发送的环境信息": "Environment sent with your report",
"入口": "Surface",
"渠道": "Channel",
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
"去 GitHub": "Go to GitHub",
"重试发送": "Retry sending",
"正文不能为空,请写下你遇到的情况。": "The text is empty. Please describe what happened.",
"正文太长了,请缩短后再发。": "The text is too long. Please shorten it and send again.",
"请填写昵称。": "Please enter a nickname.",
"昵称太长了,请缩短后再发。": "The nickname is too long. Please shorten it.",
"联系方式太长了,请缩短后再发。": "The contact is too long. Please shorten it.",
"请重新选择反馈类别。": "Please pick a category again.",
"截图数量超出上限,请去掉几张后再发。": "Too many screenshots. Remove a few and send again.",
"截图只支持 PNG 或 JPEG。": "Screenshots must be PNG or JPEG.",
"有截图太大了,请换小一点的图。": "A screenshot is too large. Use a smaller one.",
"有截图无法读取,请换一张图。": "A screenshot could not be read. Try another image.",
"内容不符合要求,请检查后重试。": "Something in the form was not accepted. Please check it and try again.",
"没能连上反馈服务。已填的内容都还在,网络恢复后可以直接重试,不会重复提交。":
"Could not reach the feedback service. Everything you entered is kept; retry once the network is back and it will not be filed twice.",
"这次的内容没能被解析,请检查后重试。": "The request could not be read. Please check the form and try again.",
"内容太大,服务端没有接收。请缩短文字,或去掉一张截图后再发。": "The report was too large for the service. Shorten the text or remove a screenshot, then send again.",
"提交得太频繁了,请等 {secs} 秒后再试。": "You are sending too fast. Try again in {secs} seconds.",
"提交得太频繁了,请稍等一会儿再试。": "You are sending too fast. Please wait a moment and try again.",
"相同内容刚刚已经提交过,不需要再发一次。可以在「我的反馈」里查看它。": "The same text was just sent, so there is no need to send it again. You can find it under My feedback.",
"反馈通道今天的接收量已满,不是你发得太多。内容都还在,请明天再试。": "The feedback channel has reached its capacity for today; you did not send too much. Your text is kept, so please try again tomorrow.",
"有一张截图没能清除其中的隐藏信息。请换一张,或重新导出后再添加。": "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.",
"反馈服务暂时出了问题。已填的内容都还在,请稍等片刻再试,也可以直接到 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",
"维护者已回复": "Answered by us",
"已关闭此反馈": "Closed",
"需要你回复": "Needs your reply",
"等你补充信息": "Waiting for your information",
"维护者": "Maintainers",
"新": "New",
"显示更早的 {n} 条": "Show {n} earlier messages",
"回复": "Reply",
"回复维护者": "Reply to the maintainers",
"你的回复": "Your reply",
"发送回复": "Send reply",
"离线时暂时不能回复。": "You cannot reply while offline.",
"这份反馈已有公开议题 #{n}:你的回复会被公开转发到那个议题里。请不要写入密钥、密码或私有内容。": "This report already has a public issue, #{n}: your reply is copied there publicly. Do not include secrets, passwords or private content.",
"只发给维护者,不会公开。请不要写入密钥、密码或私有代码。": "Sent to the maintainers only; it is not made public. Do not include secrets, passwords or private code.",
"找不到这条反馈,请刷新列表后再试。": "That report could not be found. Refresh the list and try again.",
"回复不能为空。": "The reply is empty.",
"回复太长了,请缩短后再发。": "The reply is too long. Please shorten it and send again.",
"回复不符合要求,请检查后重试。": "The reply was not accepted. Please check it and try again.",
"这份反馈的回复次数已到上限,或你回复得太频繁了。请稍后再试,必要时另外提交一条新反馈。": "This report has reached its reply limit, or you replied too often. Try again later, or send a new report.",
"这份反馈现在不接收回复,它可能已经处理完毕或被关闭。请刷新列表查看最新状态。": "This report takes no reply right now; it may be finished or closed. Refresh the list to see its latest status.",
"这台电脑的反馈身份已经变了,这份反馈不能再从这里回复。可以另外提交一条新反馈。": "This computer's feedback identity has changed, so this report can no longer be answered from here. You can send a new report.",
"反馈通道暂时关闭,暂时不能回复。": "The feedback channel is switched off for now, so replies are not possible.",
"没能连上反馈服务。你写的回复还在;如果不确定它有没有送达,请先刷新列表看看,再决定要不要重发。": "Could not reach the feedback service. Your reply is kept; if you are not sure it arrived, refresh the list first, then decide whether to send it again.",
"回复没有发出去,请稍后重试。": "The reply was not sent. Please try again later.",
"服务暂时要求额外验证,当前版本还不能显示验证步骤。请稍后再试,或直接到 GitHub 提交问题。": "The service temporarily asks for an extra verification step that this version cannot show. Try again later, or file an issue on GitHub directly.",
"{n} 项待查看": "{n} to review",
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
"回复已发送。": "Reply sent.",
};
+28 -1
View File
@@ -3,6 +3,20 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
"这份反馈现在不接收回复": "This report takes no reply right now",
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
"反馈内容过大,请缩短文字或减少截图": "That feedback is too large - shorten the text or attach fewer screenshots",
"提交太频繁了,请稍后再试": "Too many submissions - try again a little later",
"反馈通道暂时关闭,请稍后再试,或直接到 GitHub 提交问题": "Feedback is switched off for now - try again later, or open an issue on GitHub",
"相同内容的反馈刚刚提交过了": "The same feedback was just sent",
"反馈服务没有接受本机的反馈身份,请重试": "The feedback service did not accept this install's identity - try again",
"无法连接反馈服务,请检查网络后重试,已填内容会保留": "The feedback service cannot be reached - check the network and retry; what you wrote is kept",
"反馈服务暂时无法处理请求,请稍后再试": "The feedback service could not handle that - try again later",
"本机保存反馈记录失败,请重试": "Feedback could not be recorded on this machine - try again",
"该决定已不符合当前状态:计划在你回答前已发生变更": "That decision is no longer current - the plan changed before you answered",
"该条已发送给模型,无法撤回": "That line already reached the model - it cannot be taken back",
// 能力开关:名字、这台机器的存档、以及服务器自己
@@ -119,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",
@@ -149,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}",
@@ -158,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",
@@ -228,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}",
@@ -131,6 +131,25 @@ export const EN_REMOTE: Record<string, string> = {
"请先更新这台电脑上的 Studio,再连接。": "Update Studio on this computer before connecting.",
"无法授权 Web Studio,请重新登录后再试。": "Web Studio could not be authorized. Sign in again and retry.",
"连接远程 Studio 超时,请检查电脑是否在线。": "Connecting to Remote Studio timed out. Check that the computer is online.",
"网络已断开": "You're offline",
"当前设备没有网络。请检查网络连接后重新连接。": "This device has no network. Check your connection, then reconnect.",
"无法连接远程服务": "Can't reach the remote service",
"当前网络访问不到 Reasonix 远程服务,可能是网络受限或服务暂时中断。请切换网络后重新连接。":
"This network can't reach the Reasonix remote service. It may be restricted, or the service may be briefly down. Switch networks and reconnect.",
"此网址不能使用远程中转": "This address can't use the relay",
"打开 reasonix.io": "Open reasonix.io",
"远程中转拒绝了当前网页的来源。请从 reasonix.io 打开 Web Studio 后再连接。":
"The relay rejected this page's origin. Open Web Studio from reasonix.io, then connect.",
"尝试次数过多": "Too many attempts",
"请求过于频繁。请等待 {n} 秒后重新连接。": "Too many requests. Wait {n} seconds, then reconnect.",
"请求过于频繁。请稍等片刻后重新连接。": "Too many requests. Wait a moment, then reconnect.",
"中转拒绝了这次连接": "The relay refused this connection",
"连接凭证可能已过期,或这台电脑上同时连接的设备已满。请重新连接;仍然失败请等一分钟再试。":
"The connection ticket may have expired, or this computer already has its maximum number of connected devices. Reconnect; if it still fails, wait a minute.",
"长时间没有操作": "Inactive for a while",
"为节省资源,中转已回收这条空闲连接。点击重新连接即可继续。": "The relay released this idle connection. Reconnect to continue.",
"远程连接失败": "Remote connection failed",
"{action}({n})": "{action} ({n})",
"远程中转服务暂时不可用,请稍后重试。": "The remote relay is temporarily unavailable. Try again shortly.",
"远程 Studio 身份校验失败,请停止连接并检查设备。": "Remote Studio identity verification failed. Stop and check the device.",
"需要重新登录": "Sign in again",
+59 -2
View File
@@ -187,6 +187,20 @@ export const EN_SETTINGS: Record<string, string> = {
"工具": "Tool",
"处理方式": "Verdict",
"添加规则": "Add a rule",
"工具执行前": "Before a tool runs",
"工具执行后": "After a tool runs",
"工具执行失败后": "After a tool fails",
"请求权限时": "When permission is requested",
"提交消息时": "When a message is submitted",
"本轮结束时": "When the turn ends",
"本轮失败时": "When the turn fails",
"模型响应完成后": "After a model response completes",
"会话开始时": "When a session starts",
"会话结束时": "When a session ends",
"子代理开始时": "When a subagent starts",
"子代理结束时": "When a subagent ends",
"需要你关注时": "When your attention is needed",
"压缩上下文前": "Before context is compacted",
"例如 git push:* (留空 = 整个工具)": "e.g. git push:* (empty = the whole tool)",
"例如 *.env* (留空 = 整个工具)": "e.g. *.env* (empty = the whole tool)",
"文件工具读写 .env 一律拒绝。bash 中的 cat 属于另一条路径,本规则不覆盖":
@@ -268,6 +282,14 @@ export const EN_SETTINGS: Record<string, string> = {
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
"{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置限制": "Limits per model",
"留空的模型沿用上面的值,灰字是它当前继承到的值;填写后只对该模型生效。":
"A blank field follows the values above; the grey text is what that model inherits now. A value applies to that model only.",
"{model} 的上下文窗口": "Context window for {model}",
"{model} 的最大输出": "Max output for {model}",
"继承 {n}": "Inherits {n}",
"继承:未声明": "Inherits: not declared",
"继承:自动": "Inherits: automatic",
"按模型设置档位": "Levels per model",
"同一接入下有多家厂商的模型时,可为单个模型指定档位;「继承接入设置」沿用上面的档位。模型自己的档位优先。":
"When one connection serves several vendors' models, give a model levels of its own. Inherit uses the levels above; a model's own levels win.",
@@ -324,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.",
@@ -409,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)",
@@ -451,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…",
@@ -595,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",
@@ -740,8 +775,30 @@ export const EN_SETTINGS: Record<string, string> = {
"这个来源包含 {n} 个技能,会全部安装": "This source holds {n} skills, and all of them will be installed",
"我已看过这 {n} 个技能,全部安装": "I have read these {n} skills - install all of them",
"以下项目需要逐项看过再安装": "Read each of these before installing",
"以下项目会在本机运行程序": "These run programs on this machine",
"以下项目会改变可用能力": "These change what is available",
"以下项目仅添加文件": "These only add files",
"运行于 Reasonix 内部,可读取整个会话、绕过权限并直接操作本机": "Runs inside Reasonix, can read the whole session, bypass permissions and operate this machine directly",
"自动化钩子": "Automatic hooks",
"在会话生命周期内自动执行": "Run automatically during the session lifecycle",
"为 agent 提供的能力与内置工具等同": "Provides capabilities equivalent to the agent's built-in tools",
"{n} 个 MCP 服务": "{n} MCP services",
"1 个 MCP 服务": "1 MCP service",
"1 条钩子": "1 hook",
"1 个技能": "1 skill",
"1 个命令": "1 command",
"1 个子代理": "1 subagent",
"1 个提示词": "1 prompt",
"1 套配色": "1 palette",
"将启动": "Will start",
"将连接至": "Will connect to",
"一个常驻进程": "a long-running process",
"工具 {names}": "tools {names}",
"一条钩子": "a hook",
"一个外部服务": "an external service",
"{n} 个外部服务": "{n} external services",
"一个独立的 MCP 服务": "a standalone MCP service",
"{n} 个独立的 MCP 服务": "{n} standalone MCP services",
"无法读取 {name}": "Could not read {name}",
"发布者": "Publisher",
"审核版本": "Reviewed version",
+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"));
+28 -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": "该会话未启用记忆",
@@ -327,6 +340,20 @@ const SAID: Record<string, string> = {
"wallet.unauthorized": "该供应商拒绝了当前密钥,无法读取余额",
"wallet.unreachable": "该供应商的余额接口无响应",
"wallet.unreadable": "无法解析该供应商余额接口返回的内容",
"feedback.invalid": "反馈内容不符合要求,请检查后重试",
"feedback.too_large": "反馈内容过大,请缩短文字或减少截图",
"feedback.rate_limited": "提交太频繁了,请稍后再试",
"feedback.disabled": "反馈通道暂时关闭,请稍后再试,或直接到 GitHub 提交问题",
"feedback.duplicate": "相同内容的反馈刚刚提交过了",
"feedback.bad_token": "反馈服务没有接受本机的反馈身份,请重试",
"feedback.offline": "无法连接反馈服务,请检查网络后重试,已填内容会保留",
"feedback.unavailable": "反馈服务暂时无法处理请求,请稍后再试",
"feedback.internal": "本机保存反馈记录失败,请重试",
"feedback.busy": "反馈通道今日已满,请明天再试",
"feedback.image_metadata": "截图无法清除其中的元数据,请换一张或先用截图工具重新导出",
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
"feedback.not_replyable": "这份反馈现在不接收回复",
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
// ── 远程连接停下来问的那一句 ───────────────────────────────────
"ask.not_found": "不存在该待回答的问题",
@@ -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: "队列记录损坏,这一条是从残留文件里找回的",
};
+31 -16
View File
@@ -6,6 +6,8 @@ import { track as trackWidth } from "./ui/viewport";
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";
@@ -14,7 +16,13 @@ import { install as installFileDrop } from "./ui/filedrop";
import { host } from "./port/host";
import { play } from "./boot/intro";
import { settled } from "./boot/gate";
import { onRemoteConnectionEnded, remoteConnectionEnded, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
import { failureView, type FailureView } from "./port/cloud_failure";
import { RemoteLinkError } from "./port/cloud_link";
import { RemoteNotice } from "./ui/RemoteNotice";
import { onRemoteConnectionEnded, remoteConnectionEnded, openRemoteHome, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
const failureAction = (view: FailureView) => (view.action === "home" ? openRemoteHome : () => location.reload());
const failureLabel = (view: FailureView) => (view.action === "home" ? t("打开 reasonix.io") : t("重新连接"));
function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }) {
const [ended, setEnded] = useState<RemoteEnd | null>(() => (remote ? remoteConnectionEnded() : null));
@@ -23,22 +31,21 @@ function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }
return onRemoteConnectionEnded(setEnded);
}, [remote]);
const reauth = ended?.kind === "reauth";
const specific = !reauth && ended?.failure ? failureView(ended.failure) : null;
return (
<>
<App hub={hub} />
{ended && remote && (
<div className="remote-closed" role="alert" aria-label={reauth ? t("需要重新登录") : t("远程连接已断开")}>
<span aria-hidden="true" />
<div>
<b>{reauth ? t("需要重新登录") : t("远程连接已断开")}</b>
<p>{reauth
? t("为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。")
: t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}</p>
</div>
<button onClick={() => (reauth ? void signInAgain(remote) : location.reload())}>
{reauth ? t("重新登录") : t("重新连接")}
</button>
</div>
<RemoteNotice
title={reauth ? t("需要重新登录") : specific?.title ?? t("远程连接已断开")}
body={reauth
? t("为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。")
: specific?.body ?? t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}
action={reauth ? t("重新登录") : specific ? failureLabel(specific) : t("重新连接")}
waitS={specific?.waitS}
retryWhenOnline={specific?.retryWhenOnline}
onAction={reauth ? () => void signInAgain(remote) : specific ? failureAction(specific) : () => location.reload()}
/>
)}
</>
);
@@ -153,13 +160,21 @@ pick().then(
arrive(settled);
},
(e: unknown) => {
root.render(
const failure = e instanceof RemoteLinkError && e.failure ? failureView(e.failure) : null;
root.render(failure ? (
<div className="app" data-run="idle">
<RemoteNotice
title={failure.title} body={failure.body} action={failureLabel(failure)} waitS={failure.waitS}
retryWhenOnline={failure.retryWhenOnline} onAction={failureAction(failure)}
/>
</div>
) : (
<div className="app" data-run="idle">
<div className="errbar" role="alert">
<span>{reason(e)}</span>
</div>
</div>,
);
</div>
));
// The failure is the one thing that must not stay behind the boot screen.
arrive(Promise.resolve());
},
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { classifyHandshakeFailure, failureView, retryAfterSeconds } from "./cloud_failure";
import { RemoteLinkError } from "./cloud_link";
import { remoteHub } from "./cloud_remote";
const grantBody = {
grant: { ticket: "t".repeat(64) },
device: { id: "dev-1", publicKey: "", capabilities: ["desktop"], revokedAt: null },
};
class RefusedSocket extends EventTarget {
static last: RefusedSocket | null = null;
readyState = 0;
constructor() {
super();
RefusedSocket.last = this;
queueMicrotask(() => this.dispatchEvent(new Event("error")));
}
close() {}
}
function stubNetwork(routes: {
grant?: () => Response | Promise<Response>;
health?: () => Response | Promise<Response>;
probe?: () => Response | Promise<Response>;
online?: boolean;
}) {
const calls: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input);
calls.push(`${init?.method ?? "GET"} ${new URL(url).pathname}`);
if (url.endsWith("/me/remote-grants")) return routes.grant?.() ?? Response.json(grantBody);
if (url.endsWith("/health")) return routes.health?.() ?? new Response(null, { status: 200 });
if (url.endsWith("/v1/probe")) return routes.probe?.() ?? Response.json({ ok: true });
throw new Error(`unexpected ${url}`);
}));
vi.stubGlobal("WebSocket", RefusedSocket);
Object.defineProperty(navigator, "onLine", { configurable: true, get: () => routes.online ?? true });
return calls;
}
async function failureOf(): Promise<RemoteLinkError> {
const error = await remoteHub("dev-1").then(() => null, (e: unknown) => e);
expect(error).toBeInstanceOf(RemoteLinkError);
return error as RemoteLinkError;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("handshake failure classification", () => {
it("says offline without touching the network", async () => {
const calls = stubNetwork({ online: false });
const found = await classifyHandshakeFailure({ relay: "wss://remote.example", online: () => false, fetch: fetch as typeof globalThis.fetch });
expect(found).toEqual({ code: "offline" });
expect(calls).toEqual([]);
});
it("tells the gateway's own answers apart, and only then names an origin", async () => {
const relay = "wss://remote.example";
const ask = () => classifyHandshakeFailure({ relay, online: () => true, fetch: fetch as typeof globalThis.fetch });
const down = () => { throw new TypeError("Failed to fetch"); };
const refusal = () => new Response(JSON.stringify({ error: { code: "origin_rejected" } }), { status: 403 });
stubNetwork({ probe: down, health: down });
expect(await ask()).toEqual({ code: "unreachable" });
stubNetwork({ probe: refusal });
expect(await ask()).toEqual({ code: "origin_rejected" });
const calls = stubNetwork({});
expect(await ask()).toEqual({ code: "refused" });
expect(calls).toEqual(["GET /v1/probe"]);
});
it("does not blame the origin for outages, throttling or unreadable answers", async () => {
const relay = "wss://remote.example";
const ask = () => classifyHandshakeFailure({ relay, online: () => true, fetch: fetch as typeof globalThis.fetch });
const down = () => { throw new TypeError("Failed to fetch"); };
stubNetwork({ probe: down });
expect(await ask()).toEqual({ code: "unknown" });
stubNetwork({ probe: () => new Response("<html>1101</html>", { status: 500 }) });
expect(await ask()).toEqual({ code: "unreachable" });
stubNetwork({ probe: () => new Response(null, { status: 502 }) });
expect(await ask()).toEqual({ code: "unreachable" });
stubNetwork({ probe: () => new Response("{}", { status: 403 }) });
expect(await ask()).toEqual({ code: "unknown" });
stubNetwork({ probe: () => new Response(null, { status: 429, headers: { "retry-after": "20" } }) });
expect(await ask()).toEqual({ code: "rate_limited", retryAfterS: 20 });
});
it("asks again whether the phone is online once a probe has failed", async () => {
const down = () => { throw new TypeError("Failed to fetch"); };
stubNetwork({ probe: down, health: down });
const answers = [true, false];
const found = await classifyHandshakeFailure({
relay: "wss://remote.example", online: () => answers.shift() ?? false, fetch: fetch as typeof globalThis.fetch,
});
expect(found).toEqual({ code: "offline" });
});
it("reads Retry-After only as a positive number of seconds", () => {
expect(retryAfterSeconds("30")).toBe(30);
expect(retryAfterSeconds("0")).toBeUndefined();
expect(retryAfterSeconds("Wed, 21 Oct 2026 07:28:00 GMT")).toBeUndefined();
expect(retryAfterSeconds(null)).toBeUndefined();
});
it("gives every code its own wording and reconnect action, with the wait only for rate limits", () => {
expect(failureView({ code: "origin_rejected" }).action).toBe("home");
expect(failureView({ code: "offline" }).retryWhenOnline).toBe(true);
expect(failureView({ code: "refused" }).retryWhenOnline).toBe(false);
const codes = ["offline", "unreachable", "origin_rejected", "rate_limited", "refused", "idle", "unknown"] as const;
const titles = codes.map((code) => failureView({ code }).title);
expect(new Set(titles).size).toBe(codes.length);
expect(failureView({ code: "rate_limited", retryAfterS: 42 })).toMatchObject({ waitS: 42, action: "reconnect" });
expect(failureView({ code: "rate_limited", retryAfterS: 9999 }).waitS).toBe(600);
expect(failureView({ code: "refused", retryAfterS: 42 }).waitS).toBe(0);
});
});
describe("remote connect failures", () => {
it("names an offline phone", async () => {
stubNetwork({ online: false, health: () => { throw new TypeError("x"); } });
const error = await failureOf();
expect(error.failure).toEqual({ code: "offline" });
expect(error.kind).toBe("transient");
});
it("names a gateway the phone cannot reach after the socket errors", async () => {
stubNetwork({ probe: () => { throw new TypeError("x"); }, health: () => { throw new TypeError("x"); } });
expect((await failureOf()).failure).toEqual({ code: "unreachable" });
});
it("names an origin the gateway rejects", async () => {
stubNetwork({ probe: () => new Response(JSON.stringify({ error: { code: "origin_rejected" } }), { status: 403 }) });
expect((await failureOf()).failure).toEqual({ code: "origin_rejected" });
});
it("falls back to a refused ticket when the gateway is reachable and the origin allowed", async () => {
stubNetwork({});
expect((await failureOf()).failure).toEqual({ code: "refused" });
});
it("reports an account-service rate limit with its Retry-After", async () => {
stubNetwork({ grant: () => new Response(JSON.stringify({ error: { code: "rate_limited" } }), { status: 429, headers: { "retry-after": "45" } }) });
const error = await failureOf();
expect(error.failure).toEqual({ code: "rate_limited", retryAfterS: 45 });
});
it("reports the account service being unreachable when the grant request throws", async () => {
stubNetwork({ grant: () => { throw new TypeError("Failed to fetch"); } });
expect((await failureOf()).failure).toEqual({ code: "unreachable" });
});
});
@@ -0,0 +1,123 @@
import { t } from "../i18n";
// Why a relay connection could not be made or kept. A browser is told nothing
// about a refused WebSocket upgrade, so these come from what can be observed
// around it, never from the text of an error.
export type RelayFailure =
| "offline"
| "unreachable"
| "origin_rejected"
| "rate_limited"
| "refused"
| "idle"
| "unknown";
export interface FailureDetail {
code: RelayFailure;
retryAfterS?: number;
}
export type FailureAction = "reconnect" | "signin" | "home";
export interface FailureView {
title: string;
body: string;
action: FailureAction;
waitS: number;
retryWhenOnline: boolean;
}
export function failureView({ code, retryAfterS }: FailureDetail): FailureView {
const reconnect = { action: "reconnect" as const, waitS: 0, retryWhenOnline: false };
switch (code) {
case "offline":
return { ...reconnect, retryWhenOnline: true, title: t("网络已断开"), body: t("当前设备没有网络。请检查网络连接后重新连接。") };
case "unreachable":
return {
...reconnect,
title: t("无法连接远程服务"),
body: t("当前网络访问不到 Reasonix 远程服务,可能是网络受限或服务暂时中断。请切换网络后重新连接。"),
};
case "origin_rejected":
return {
...reconnect,
action: "home",
title: t("此网址不能使用远程中转"),
body: t("远程中转拒绝了当前网页的来源。请从 reasonix.io 打开 Web Studio 后再连接。"),
};
case "rate_limited": {
const waitS = retryAfterS && retryAfterS > 0 ? Math.min(Math.ceil(retryAfterS), 600) : 0;
return {
action: "reconnect",
retryWhenOnline: false,
waitS,
title: t("尝试次数过多"),
body: waitS > 0 ? t("请求过于频繁。请等待 {n} 秒后重新连接。", { n: waitS }) : t("请求过于频繁。请稍等片刻后重新连接。"),
};
}
case "refused":
return {
...reconnect,
title: t("中转拒绝了这次连接"),
body: t("连接凭证可能已过期,或这台电脑上同时连接的设备已满。请重新连接;仍然失败请等一分钟再试。"),
};
case "idle":
return {
...reconnect,
title: t("长时间没有操作"),
body: t("为节省资源,中转已回收这条空闲连接。点击重新连接即可继续。"),
};
case "unknown":
return { ...reconnect, title: t("远程连接失败"), body: t("远程中转服务暂时不可用,请稍后重试。") };
}
}
export interface ProbeOptions {
relay: string;
online: () => boolean;
fetch: typeof fetch;
timeoutMs?: number;
}
const PROBE_TIMEOUT_MS = 5_000;
interface Refusal {
error?: { code?: string };
}
// The gateway's /v1/probe answers a CORS reader whether or not its origin is
// allowed, so a status the browser can read is the gateway speaking. When
// nothing readable comes back, the health route (which carries no CORS
// headers) can only show that something answered, and that is "unknown", not
// a diagnosis.
export async function classifyHandshakeFailure(options: ProbeOptions): Promise<FailureDetail> {
if (!options.online()) return { code: "offline" };
const timeout = options.timeoutMs ?? PROBE_TIMEOUT_MS;
const base = options.relay.replace(/^ws/, "http");
let answer: Response;
try {
answer = await options.fetch(`${base}/v1/probe`, { mode: "cors", cache: "no-store", signal: AbortSignal.timeout(timeout) });
} catch {
if (!options.online()) return { code: "offline" };
try {
await options.fetch(`${base}/health`, { mode: "no-cors", cache: "no-store", signal: AbortSignal.timeout(timeout) });
} catch {
return { code: options.online() ? "unreachable" : "offline" };
}
return { code: "unknown" };
}
if (answer.ok) return { code: "refused" };
if (answer.status === 403) {
const body = await answer.json().catch(() => null) as Refusal | null;
if (body?.error?.code === "origin_rejected") return { code: "origin_rejected" };
return { code: "unknown" };
}
if (answer.status === 429) return { code: "rate_limited", retryAfterS: retryAfterSeconds(answer.headers.get("retry-after")) };
return { code: answer.status >= 500 ? "unreachable" : "unknown" };
}
export function retryAfterSeconds(value: string | null): number | undefined {
if (value === null) return undefined;
const seconds = Number(value.trim());
return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined;
}
@@ -254,4 +254,46 @@ describe("remote link", () => {
expect(dial).toHaveBeenCalledTimes(2);
expect(ends[0]?.kind).toBe("ended");
});
it("ends with the last attempt's failure code, and with idle when the relay reclaimed the lease", async () => {
const limited = connection();
const dialLimited = vi.fn(async () => {
throw new RemoteLinkError("transient", "wait", { code: "rate_limited", retryAfterS: 30 });
});
new RemoteLink(limited.conn, dialLimited, (end) => ends.push(end), { retryDelaysMs: [10] });
limited.socket.close(1006, "");
await vi.advanceTimersByTimeAsync(100);
expect(ends[0]).toMatchObject({ kind: "ended", failure: { code: "rate_limited", retryAfterS: 30 } });
const idle = connection();
const dialIdle = vi.fn(async () => { throw new RemoteLinkError("transient", "down"); });
new RemoteLink(idle.conn, dialIdle, (end) => ends.push(end), { retryDelaysMs: [10] });
idle.socket.close(CLOSE_IDLE, "Idle timeout");
await vi.advanceTimersByTimeAsync(100);
expect(ends[1]).toMatchObject({ kind: "ended", failure: { code: "idle" } });
const plain = connection();
new RemoteLink(plain.conn, dialIdle, (end) => ends.push(end), { retryDelaysMs: [10] });
plain.socket.close(1006, "");
await vi.advanceTimersByTimeAsync(100);
expect(ends[2]?.failure).toBeUndefined();
});
it("waits out a Retry-After before dialling again and reports only the last attempt's cause", async () => {
const first = connection();
const dial = vi.fn()
.mockRejectedValueOnce(new RemoteLinkError("transient", "wait", { code: "rate_limited", retryAfterS: 5 }))
.mockRejectedValueOnce(new RemoteLinkError("transient", "down"));
new RemoteLink(first.conn, dial, (end) => ends.push(end), { retryDelaysMs: [10, 10] });
first.socket.close(1006, "");
await vi.advanceTimersByTimeAsync(10);
expect(dial).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(4_000);
expect(dial).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_100);
expect(dial).toHaveBeenCalledTimes(2);
expect(ends[0]?.kind).toBe("ended");
expect(ends[0]?.failure).toBeUndefined();
});
});
+23 -4
View File
@@ -1,4 +1,5 @@
import { t } from "../i18n";
import type { FailureDetail, RelayFailure } from "./cloud_failure";
// Relay close codes, decided by the relay and read here by number alone.
export const CLOSE_IDLE = 4408;
@@ -20,12 +21,17 @@ export function closeAction(code: number): CloseAction {
export interface RemoteEnd {
kind: "ended" | "reauth";
reason: string;
failure?: FailureDetail;
}
// Why a (re)connection attempt failed, as the account service or the relay
// said it: "transient" is worth another attempt, the others are not.
export class RemoteLinkError extends Error {
constructor(readonly kind: "reauth" | "ended" | "transient", readonly reason: string) {
constructor(
readonly kind: "reauth" | "ended" | "transient",
readonly reason: string,
readonly failure?: FailureDetail,
) {
super(reason);
}
}
@@ -62,6 +68,8 @@ export interface RemoteLinkOptions {
retryDelaysMs: number[];
}
const MAX_RETRY_AFTER_MS = 2 * 60 * 1000;
const DEFAULTS: RemoteLinkOptions = {
requestTimeoutMs: 30_000,
retryDelaysMs: [500, 1_000, 2_000, 4_000, 8_000, 15_000],
@@ -102,6 +110,7 @@ export class RemoteLink {
private reconnecting = false;
private ended = false;
private lastHeardAt = Date.now();
private lastCloseCode = 0;
private sending: Promise<void> = Promise.resolve();
private readonly options: RemoteLinkOptions;
@@ -179,6 +188,7 @@ export class RemoteLink {
private lost(code: number, reason: string) {
if (this.ended) return;
this.current = null;
this.lastCloseCode = code;
const action = closeAction(code);
if (action !== "reconnect") {
this.finish({ kind: action === "reauth" ? "reauth" : "ended", reason });
@@ -191,9 +201,11 @@ export class RemoteLink {
private async reconnect() {
this.reconnecting = true;
let failure: FailureDetail | undefined;
let notBefore = 0;
try {
for (const delay of this.options.retryDelaysMs) {
await new Promise((resolve) => setTimeout(resolve, delay));
for (const scheduled of this.options.retryDelaysMs) {
await new Promise((resolve) => setTimeout(resolve, Math.max(scheduled, notBefore)));
if (this.ended) return;
try {
const connection = await this.dial();
@@ -211,9 +223,16 @@ export class RemoteLink {
this.finish({ kind: error.kind, reason: error.reason });
return;
}
failure = error instanceof RemoteLinkError ? error.failure : undefined;
notBefore = Math.min((failure?.retryAfterS ?? 0) * 1000, MAX_RETRY_AFTER_MS);
}
}
this.finish({ kind: "ended", reason: t("远程 Studio 暂无响应,请检查电脑是否在线后重试。") });
failure ??= this.lastCloseCode === CLOSE_IDLE ? { code: "idle" satisfies RelayFailure } : undefined;
this.finish({
kind: "ended",
reason: t("远程 Studio 暂无响应,请检查电脑是否在线后重试。"),
...(failure ? { failure } : {}),
});
} finally {
this.reconnecting = false;
}
+22 -2
View File
@@ -2,12 +2,17 @@ import type { HubPort } from "./hub";
import { SseHub } from "./hub";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { classifyHandshakeFailure, failureView, retryAfterSeconds } from "./cloud_failure";
import { linkCodec, RemoteLink, RemoteLinkError, type RemoteConnection, type RemoteEnd } from "./cloud_link";
const ACCOUNT = (import.meta.env.VITE_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, "");
const RELAY = (import.meta.env.VITE_REMOTE_GATEWAY || "wss://remote.reasonix.io").replace(/\/$/, "");
const REMOTE_HOME = import.meta.env.VITE_REMOTE_HOME || "https://reasonix.io/remote/";
const HANDSHAKE_TIMEOUT_MS = 30_000;
// The socket errored before it opened: the browser hides the status, so the
// caller has to find out why.
export class SocketRefused extends Error {}
let connectionEnded: RemoteEnd | null = null;
const connectionEndedListeners = new Set<(end: RemoteEnd) => void>();
const { bytesToBase64, base64ToBytes, concatChunks } = linkCodec;
@@ -24,6 +29,10 @@ export const onRemoteConnectionEnded = (listener: (end: RemoteEnd) => void) => {
return () => { connectionEndedListeners.delete(listener); };
};
export function openRemoteHome() {
location.href = REMOTE_HOME;
}
// Signing out first is what makes the sign-in page ask: it sends a visitor who
// still holds a session straight on, and that session is the one too old to
// control a computer.
@@ -122,7 +131,7 @@ function waitForSocketOpen(socket: WebSocket, timeout = HANDSHAKE_TIMEOUT_MS): P
const opened = () => { cleanup(); resolve(); };
const failed = () => {
cleanup();
reject(new Error(t("远程中转服务暂时不可用,请稍后重试。")));
reject(new SocketRefused());
};
const timer = setTimeout(() => {
cleanup();
@@ -207,6 +216,10 @@ async function grantFailure(issued: Response): Promise<RemoteLinkError> {
if (issued.status === 404 || code === "device_not_found") {
return new RemoteLinkError("ended", t("这台电脑已从账号中移除。"));
}
if (issued.status === 429) {
const detail = { code: "rate_limited" as const, retryAfterS: retryAfterSeconds(issued.headers.get("retry-after")) };
return new RemoteLinkError("transient", failureView(detail).body, detail);
}
return new RemoteLinkError("transient", t("无法授权 Web Studio,请重新登录后再试。"));
}
@@ -222,7 +235,8 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
body: JSON.stringify({ targetDeviceId: deviceId, scopes: ["desktop"] }),
}));
} catch {
throw new RemoteLinkError("transient", t("远程中转服务暂时不可用,请稍后重试。"));
const detail = { code: navigator.onLine === false ? "offline" as const : "unreachable" as const };
throw new RemoteLinkError("transient", failureView(detail).body, detail);
}
if (!issued.ok) throw await grantFailure(issued);
const { grant, device: target } = await issued.json() as { grant: { ticket: string }; device?: RemoteDevice };
@@ -256,6 +270,12 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
} catch (error) {
try { socket.close(1000, "Handshake failed"); } catch { /* never opened */ }
if (error instanceof RemoteLinkError) throw error;
if (error instanceof SocketRefused) {
const detail = await classifyHandshakeFailure({
relay: RELAY, online: () => navigator.onLine !== false, fetch: nativeFetch,
}).catch(() => ({ code: "unknown" as const }));
throw new RemoteLinkError("transient", failureView(detail).body, detail);
}
throw new RemoteLinkError("transient", reason(error));
}
}
+143
View File
@@ -0,0 +1,143 @@
// In-app feedback: a report the maintainers triage, the receipt that follows
// it, and the thread of replies under it. Field names are the kernel's json
// tags; a refusal is told apart by its code, never by its sentence.
export type FeedbackCategory = "bug" | "idea" | "question" | "other";
export const FEEDBACK_CATEGORIES: readonly FeedbackCategory[] = ["bug", "idea", "question", "other"];
// What a person may see. A report waiting for triage reads as received; one
// the maintainers declined reads as closed, with no reason.
export type FeedbackStatus =
| "received" | "needs_info" | "answered" | "recorded" | "in_progress" | "fixed" | "wontfix" | "duplicate" | "closed";
export const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed",
];
// Only these take a reply from the reporter; the service has the last word.
export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "answered", "recorded", "in_progress"];
export interface FeedbackEnvInfo {
version: string;
commit: string;
surface: string;
os: string;
osVersion: string;
arch: string;
locale: string;
channel: string;
providerKind: string;
}
// uploadBytes bounds one raw file the page may hand over; the kernel shrinks
// and re-encodes it, and imageBytes is what is finally sent.
export interface FeedbackLimits {
bodyBytes: number;
nameChars: number;
contactChars: number;
images: number;
imageBytes: number;
uploadBytes: number;
replyBytes: number;
}
export interface FeedbackEnv {
env: FeedbackEnvInfo;
displayName: string;
limits: FeedbackLimits;
}
export interface FeedbackImage {
name: string;
dataBase64: string;
}
export interface FeedbackRequest {
idempotencyKey: string;
category: FeedbackCategory;
body: string;
displayName: string;
contact?: string;
// The UI language tag, so the issue can be triaged in the reporter's language.
locale?: string;
images: FeedbackImage[];
// Answer to a feedback.challenge_required refusal.
turnstileToken?: string;
}
export interface FeedbackReplyReceipt {
replyId: number;
createdAt: string;
}
export interface FeedbackReceipt {
receipt: string;
status: FeedbackStatus;
createdAt: string;
redacted: boolean;
}
export type FeedbackAuthor = "maintainer" | "user";
// Plain text only: never linked, formatted or interpreted.
export interface FeedbackReply {
id: number;
author: FeedbackAuthor;
body: string;
createdAt: string;
}
export interface FeedbackItem {
receipt: string;
category: FeedbackCategory;
titleSnippet: string;
status: FeedbackStatus;
issueNumber?: number;
issueUrl?: string;
// "vX.Y.Z", or the literal "next" while the fix is merged and not yet shipped.
resolvedVersion?: string;
duplicateOf?: number;
// The report was filed under an install identity this machine no longer holds.
statusUnavailable?: boolean;
// The maintainers are waiting for the reporter's answer.
needsInput: boolean;
// Oldest first.
replies: FeedbackReply[];
// Maintainer replies newer than the last one the kernel was told was seen.
unreadReplies: number;
createdAt: string;
updatedAt: string;
}
// offline: the service was unreachable, so items are the receipts remembered
// on this machine and their statuses may be stale.
// unread counts the reports wanting attention; hasNew is unread > 0.
export interface FeedbackMine {
items: FeedbackItem[];
offline: boolean;
unread: number;
hasNew: boolean;
}
export const FEEDBACK_REPO_ISSUES = "https://github.com/esengine/DeepSeek-Reasonix/issues/";
export const FEEDBACK_NEXT_VERSION = "next";
export const FEEDBACK_CODE = {
invalid: "feedback.invalid",
tooLarge: "feedback.too_large",
rateLimited: "feedback.rate_limited",
busy: "feedback.busy",
imageMetadata: "feedback.image_metadata",
disabled: "feedback.disabled",
duplicate: "feedback.duplicate",
badToken: "feedback.bad_token",
replyLimit: "feedback.reply_limit",
notReplyable: "feedback.not_replyable",
challengeRequired: "feedback.challenge_required",
offline: "feedback.offline",
unavailable: "feedback.unavailable",
internal: "feedback.internal",
badBody: "request.bad_body",
} as const;
+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;
+3 -3
View File
@@ -2,7 +2,7 @@ import type { PlanAction } from "./session";
import { HttpError } from "./port";
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockBackup } from "./mock_backup";
import { MockFeedback } from "./mock_feedback";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
@@ -11,7 +11,7 @@ import { mockUsage } from "./mock_usage";
export class MockPort extends MockBackup implements AgentPort {
export class MockPort extends MockFeedback implements AgentPort {
private listeners = new Set<(ev: WireEvent) => void>();
private log: WireEvent[] = [];
// What the user has sent, so checkpoints() can mirror one per turn.
@@ -670,7 +670,6 @@ export class MockPort extends MockBackup implements AgentPort {
async retryQueued(itemId: string) {
void itemId;
}
async refreshQueued(itemId: string) {
void itemId;
}
@@ -691,6 +690,7 @@ export class MockPort extends MockBackup 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) {
@@ -0,0 +1,117 @@
import { HttpError } from "./http_error";
import { MockBackup } from "./mock_backup";
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
const ENV = {
version: "v2.24.0", commit: "7ee4bbb", surface: "studio", os: "windows", osVersion: "10.0.19045",
arch: "amd64", locale: "en-US", channel: "stable", providerKind: "deepseek",
};
const DAY = 86_400_000;
const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
const item = (over: Partial<FeedbackItem> & Pick<FeedbackItem, "receipt" | "category" | "titleSnippet" | "status" | "createdAt" | "updatedAt">): FeedbackItem => ({
needsInput: false, replies: [], unreadReplies: 0, ...over,
});
const msg = (id: number, author: FeedbackReply["author"], body: string, days: number): FeedbackReply => ({ id, author, body, createdAt: ago(days) });
// One row per status, so the list is never drawn from a fixture that skips one.
const SEEDED: FeedbackItem[] = [
item({ receipt: "FB-7K3M-9QX2", category: "bug", titleSnippet: "Sidebar loses its selection after a resize", status: "fixed", issueNumber: 11350, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11350", resolvedVersion: "v2.21.0", createdAt: ago(6), updatedAt: ago(1) }),
item({ receipt: "FB-2H8P-4WD7", category: "idea", titleSnippet: "Export a session as markdown", status: "recorded", issueNumber: 11302, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11302", createdAt: ago(4), updatedAt: ago(3) }),
item({ receipt: "FB-5N1C-8RT3", category: "bug", titleSnippet: "Paste of a long log freezes the composer", status: "in_progress", issueNumber: 11377, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11377", createdAt: ago(3), updatedAt: ago(1),
replies: [msg(21, "maintainer", "Thanks, reproduced. Does it also freeze with a 2 MB log, or only with the 40 MB one?", 2), msg(22, "user", "Only the 40 MB one. The 2 MB log pastes fine.", 1.5)] }),
item({ receipt: "FB-9B4D-1XM6", category: "question", titleSnippet: "Where do I change the default model?", status: "received", createdAt: ago(0.1), updatedAt: ago(0.1) }),
item({ receipt: "FB-3F6G-2KV9", category: "idea", titleSnippet: "Fixed in the next build: quieter update banner", status: "fixed", issueNumber: 11390, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11390", resolvedVersion: "next", createdAt: ago(8), updatedAt: ago(0.5) }),
item({ receipt: "FB-8Q2J-6PW4", category: "other", titleSnippet: "Please support a monorepo layout", status: "wontfix", issueNumber: 11288, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11288", createdAt: ago(12), updatedAt: ago(5) }),
item({ receipt: "FB-4T7V-3ZH1", category: "bug", titleSnippet: "Crash when opening settings on a small window", status: "duplicate", issueNumber: 11301, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11301", duplicateOf: 11299, createdAt: ago(15), updatedAt: ago(9) }),
item({ receipt: "FB-6M9R-5CE8", category: "question", titleSnippet: "How do I move my sessions to another disk?", status: "received", statusUnavailable: true, createdAt: ago(40), updatedAt: ago(40) }),
item({ receipt: "FB-1C5W-7NB2", category: "bug", titleSnippet: "Terminal output is cut off after a resize", status: "needs_info", needsInput: true, createdAt: ago(2), updatedAt: ago(0.2),
replies: [msg(31, "maintainer", "Thanks for the report. Which operating system and which shell are you using?\nAnd can you say what the window size was before you resized it?", 0.2)] }),
item({ receipt: "FB-8D3X-4LP6", category: "question", titleSnippet: "Can I use two providers at once?", status: "answered", createdAt: ago(5), updatedAt: ago(4),
replies: [msg(11, "maintainer", "Yes: add both under Settings > Model services and pick one per session from the model menu.", 4)] }),
item({ receipt: "FB-2Z6Q-8HK5", category: "other", titleSnippet: "Unrelated advertisement text", status: "closed", createdAt: ago(7), updatedAt: ago(7) }),
];
// A tab's own sessionStorage picks the refusal the next call answers with, so a
// dev page can show every failure without a kernel that will produce it.
const FAULT = "rx-mock-feedback-fault";
const REPLY_FAULT = "rx-mock-feedback-reply-fault";
function fault(key = FAULT): string {
try {
return sessionStorage.getItem(key) ?? "";
} catch {
return "";
}
}
const STATUS: Record<string, number> = {
[FEEDBACK_CODE.tooLarge]: 413, [FEEDBACK_CODE.rateLimited]: 429, [FEEDBACK_CODE.disabled]: 503,
[FEEDBACK_CODE.duplicate]: 409, [FEEDBACK_CODE.badToken]: 409, [FEEDBACK_CODE.offline]: 502, [FEEDBACK_CODE.unavailable]: 502, [FEEDBACK_CODE.internal]: 500, [FEEDBACK_CODE.busy]: 503, [FEEDBACK_CODE.imageMetadata]: 400,
[FEEDBACK_CODE.replyLimit]: 429, [FEEDBACK_CODE.notReplyable]: 409, [FEEDBACK_CODE.challengeRequired]: 403,
};
function refusal(code: string): HttpError {
return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params: code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {} });
}
export class MockFeedback extends MockBackup {
private filed: FeedbackItem[] = [];
private name = "";
private rows: FeedbackItem[] = SEEDED.map((i) => ({ ...i, replies: [...i.replies] }));
private seen = new Map<string, number>();
async feedbackEnv(): Promise<FeedbackEnv> {
return { env: ENV, displayName: this.name, limits: LIMITS };
}
async sendFeedback(req: FeedbackRequest): Promise<FeedbackReceipt> {
const code = fault();
if (code === "feedback.invalid") {
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "too_long" } });
}
if (code && STATUS[code]) throw refusal(code);
this.name = req.displayName;
const receipt = "FB-" + (1000 + this.filed.length * 7).toString(36).toUpperCase().padStart(4, "K") + "-9QX2";
const now = new Date().toISOString();
this.filed = [item({ receipt, category: req.category, titleSnippet: req.body.trim().slice(0, 80), status: "received", createdAt: now, updatedAt: now }), ...this.filed];
return { receipt, status: "received", createdAt: now, redacted: /sk-[A-Za-z0-9]{8,}/.test(req.body) };
}
async myFeedback(): Promise<FeedbackMine> {
const code = fault();
if (code === "mine_error") throw new HttpError(502, "unavailable", { code: FEEDBACK_CODE.unavailable, error: "unavailable" });
const items = [...this.filed, ...this.rows].map((i) => {
const unreadReplies = i.replies.filter((r) => r.author === "maintainer" && r.id > (this.seen.get(i.receipt) ?? 0)).length;
return { ...i, needsInput: i.statusUnavailable ? false : i.needsInput, unreadReplies };
});
const unread = items.filter((i) => i.unreadReplies > 0 || i.needsInput).length;
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0 };
}
async replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt> {
const code = fault(REPLY_FAULT);
if (code === "feedback.invalid") {
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "empty" } });
}
if (code) throw refusal(code);
const row = this.rows.find((i) => i.receipt === receipt);
if (!row) throw refusal(FEEDBACK_CODE.notReplyable);
this.seen.set(receipt, Math.max(0, ...row.replies.map((r) => r.id)));
const replyId = Math.max(0, ...row.replies.map((r) => r.id)) + 1;
const createdAt = new Date().toISOString();
row.replies.push({ id: replyId, author: "user", body, createdAt });
row.updatedAt = createdAt;
row.needsInput = false;
if (row.status === "needs_info") row.status = "received";
return { replyId, createdAt };
}
async feedbackSeen(receipt: string, upTo: number): Promise<void> {
if (this.rows.some((i) => i.receipt === receipt)) this.seen.set(receipt, Math.max(this.seen.get(receipt) ?? 0, upTo));
}
}
@@ -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");
}
@@ -1,5 +1,6 @@
import type {
ModelEffort,
ModelLimit,
Protocol,
ProviderCheck,
ProviderEdit,
@@ -45,6 +46,9 @@ export class MockProvider extends MockBoundary {
visionModels: ["gpt-4o"], canSetVision: true,
reasoningProtocol: "openai", supportedEfforts: ["low", "medium", "high"],
modelEfforts: { "claude-sonnet-4": { supportedEfforts: ["low", "high", "max"], defaultEffort: "high" } },
contextWindow: 131072,
modelLimits: { "claude-sonnet-4": { contextWindow: 200000, maxOutputTokens: 64000 } },
inheritedLimits: { "gpt-4o": { contextWindow: 131072 }, "claude-sonnet-4": { contextWindow: 131072 } },
inheritedEfforts: {
"gpt-4o": { supportedEfforts: ["low", "medium", "high"] },
"claude-sonnet-4": { supportedEfforts: ["low", "medium", "high"] },
@@ -146,6 +150,7 @@ export class MockProvider extends MockBoundary {
reasoningProtocol: edit.reasoningProtocol, supportedEfforts: edit.supportedEfforts,
defaultEffort: edit.defaultEffort,
modelEfforts: edit.modelEfforts ? mockModelEfforts(edit.modelEfforts) : p.modelEfforts,
modelLimits: edit.modelLimits ? mockModelLimits(edit.modelLimits) : p.modelLimits,
}
: p,
);
@@ -179,3 +184,8 @@ function mockModelEfforts(sent: Record<string, ModelEffort>): Record<string, Mod
const kept = Object.entries(sent).filter(([, v]) => v.supportedEfforts.length > 0);
return kept.length ? Object.fromEntries(kept) : undefined;
}
function mockModelLimits(sent: Record<string, ModelLimit>): Record<string, ModelLimit> | undefined {
const kept = Object.entries(sent).filter(([, v]) => v.contextWindow || v.maxOutputTokens);
return kept.length ? Object.fromEntries(kept) : undefined;
}
@@ -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");
});
+15 -1
View File
@@ -40,11 +40,13 @@ import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketP
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
export type { ModelEffort, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
export type { ModelEffort, ModelLimit, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { FeedbackEnv, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
export type * from "./plugin";
export type * from "./market";
export type * from "./feedback";
// GET /mcp. One external tool provider. state is ready | connecting | failed |
// disabled | idle: disabled is switched off and stays off across restarts, idle
@@ -113,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 }[];
@@ -348,6 +352,16 @@ export interface AgentPort {
deleteBackup(id: string): Promise<void>;
previewBackup(id: string, passphrase: string): Promise<BackupPlan>;
applyBackup(planId: string, items: string[], consented: string[]): Promise<BackupApplyResult>;
// A report becomes public only if the maintainers file it as an issue.
// Refusals carry feedback.* codes; only feedback.offline is safe to retry
// with the same idempotencyKey. A reply has no key and is never retried by
// the kernel.
feedbackEnv(locale?: string): Promise<FeedbackEnv>;
sendFeedback(req: FeedbackRequest): Promise<FeedbackReceipt>;
myFeedback(): Promise<FeedbackMine>;
replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt>;
// Records that the thread was shown up to reply upTo, so those replies stop counting as new.
feedbackSeen(receipt: string, upTo: number): Promise<void>;
workspaces(): Promise<WorkspaceInfo>;
// Rebuilds the whole runtime against another folder. The conversation does
// not come along, so the caller has to reload the transcript afterwards.
@@ -49,6 +49,10 @@ export interface ProviderEntry {
modelEfforts?: Record<string, ModelEffort>;
inheritedEfforts?: Record<string, ModelEffort>;
modelProtocols?: Record<string, string>;
// Per model: the window and output cap it declares for itself, and the ones
// it inherits when it declares none.
modelLimits?: Record<string, ModelLimit>;
inheritedLimits?: Record<string, ModelLimit>;
// Removing the one in use would leave the session on a model that no longer
// resolves, so the row offers no delete.
inUse: boolean;
@@ -176,6 +180,15 @@ export interface ProviderEdit {
// The whole per-model answer for the listed models: a model left out
// inherits the connection's levels again. Omitted leaves every model alone.
modelEfforts?: Record<string, ModelEffort>;
// The whole per-model answer for the listed models: a model left out, or sent
// empty, inherits the connection's limits again. Omitted leaves every model alone.
modelLimits?: Record<string, ModelLimit>;
}
// One model's own context window and output cap; an absent field inherits.
export interface ModelLimit {
contextWindow?: number;
maxOutputTokens?: number;
}
// One model's effort vocabulary and the level auto resolves to; no default is
+5 -6
View File
@@ -1,7 +1,7 @@
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
import { SseBackup } from "./sse_backup";
import { SseFeedback } from "./sse_feedback";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { host } from "./host";
@@ -16,7 +16,7 @@ import { openLiveStream } from "./livestream";
// a load. The read is one small JSON body and answers from memory.
const UPDATE_POLL_MS = 500;
export class SsePort extends SseBackup implements AgentPort {
export class SsePort extends SseFeedback implements AgentPort {
status() {
return this.get<SessionStatus>("/status");
}
@@ -69,7 +69,7 @@ export class SsePort extends SseBackup 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 SseBackup 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");
@@ -0,0 +1,20 @@
import { SseBackup } from "./sse_backup";
import type { FeedbackEnv, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
export class SseFeedback extends SseBackup {
feedbackEnv(locale?: string) {
return this.get<FeedbackEnv>("/feedback/env" + (locale ? "?locale=" + encodeURIComponent(locale) : ""));
}
sendFeedback(req: FeedbackRequest) {
return this.post0<FeedbackReceipt>("/feedback", req);
}
myFeedback() {
return this.get<FeedbackMine>("/feedback/mine");
}
replyFeedback(receipt: string, body: string) {
return this.post0<FeedbackReplyReceipt>("/feedback/" + encodeURIComponent(receipt) + "/reply", { body });
}
feedbackSeen(receipt: string, upTo: number) {
return this.post("/feedback/" + encodeURIComponent(receipt) + "/seen", { upTo });
}
}
+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" });
});
});
+10 -17
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:
@@ -652,7 +645,7 @@ function withReceipt(items: Item[], r?: Receipt): Item[] {
// eleven. The server strips these before /history now — this is what covers
// sessions already on disk.
const CONTROL =
/<(reasoning-language|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|project-instructions|capability-route|interrupted-turn-recovery|workspace)[\s\S]*?<\/\1>\s*/g;
/<(reasoning-language|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|project-instructions|capability-route|interrupted-turn-recovery|workspace|scheduled-run)[\s\S]*?<\/\1>\s*/g;
const stripControl = (s: string) => s.replace(CONTROL, "").trim();
// A plan that ran to the end is spent: struck through in the rail it reads as
@@ -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);
});
});
+56 -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,
@@ -935,6 +980,9 @@ what exists.
under — the second is the writer's taxonomy and means nothing to a reader. The
dot on the left is the one pixel on this page that matters: it joins a memory
to the behaviour just observed.
- The title is the one part of a row nobody bounds, so it wraps inside a share of
the row and the description gives way to it; the scope, date and action keep
their width and never break.
- Older versions are indented and one tier darker so they read as what is behind
the current one rather than as more memories beside it.
- The write card uses the memory colour rather than a tool colour: this step
+30 -4
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 }
@@ -2808,6 +2809,7 @@
}
.act:hover:not(:disabled) { background: var(--overlay); border-color: var(--muted) }
.act:disabled { color: var(--ghost); cursor: default }
.srv .act:disabled { opacity: .45 }
.act:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
.reload { display: inline-flex; align-items: center; gap: 6px }
@@ -2860,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) }
@@ -3098,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 }
@@ -3126,6 +3135,17 @@
.me-list { display: flex; flex-direction: column; margin: 4px 0 0; padding: 0; list-style: none; border-top: 1px solid var(--hair) }
.me-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px 10px; padding: 8px 0; border-bottom: 1px solid var(--hair) }
.addp .fields .me-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
.ml-group { display: flex; flex-direction: column; gap: 4px; margin-top: 14px }
.ml-list { max-height: 232px; overflow-y: auto }
.ml-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px }
.ml-head strong { font: var(--w-semi) var(--fs-ui) var(--ui); color: var(--text) }
.ml-head span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
.ml-row { display: grid; grid-template-columns: minmax(0, 1fr) 150px 150px; align-items: center; gap: 6px 10px; padding: 8px 0; border-bottom: 1px solid var(--hair) }
.addp .ml-row .unit-field input { padding: 6px 8px; font-size: var(--fs-meta) }
.ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
.ml-row input::placeholder { color: var(--ghost) }
.ml-row[data-own] .unit-field:has(input:not(:placeholder-shown)) { border-color: var(--accent) }
@media (max-width: 640px) { .ml-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) } .ml-row .me-name { grid-column: 1 / -1 } }
.me-row > .me-note, .me-own { grid-column: 1 / -1 }
.me-own { display: flex; flex-direction: column; gap: 8px }
.me-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px }
@@ -3133,11 +3153,11 @@
.addp .fields label.me-default { flex-direction: row; align-items: center; gap: 8px }
.addp .more { margin-top: 14px }
.addp .fields span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
.addp .fields input, .addp .fields select {
.addp .fields input, .addp .fields select, .addp .ml-row input {
padding: 6px 9px; border: 1px solid var(--border); border-radius: var(--r-xs);
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--mono);
}
.addp .fields input:focus, .addp .fields select:focus { outline: 2px solid var(--focus); outline-offset: 1px }
.addp .fields input:focus, .addp .fields select:focus, .addp .ml-row input:focus { outline: 2px solid var(--focus); outline-offset: 1px }
.addp .acts { display: flex; gap: 8px; margin-top: 12px }
.addp .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg) }
.addp .act[data-primary]:disabled { border-color: var(--border); background: transparent; color: var(--ghost) }
@@ -3190,6 +3210,7 @@
box-shadow: var(--lit), 0 1px 2px rgb(0 0 0 / .07);
}
.mkt { display: flex; flex-direction: column; gap: 10px; min-width: 0 }
.mkt[tabindex="-1"]:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
.mkt.addpkg { border-top: 0; padding: 0 }
.mkt-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px }
.mkt-q {
@@ -3260,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 }
@@ -3339,6 +3362,7 @@
padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--r-xs);
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--mono);
}
.rule select { width: 200px; max-width: 100%; min-width: 0; text-overflow: ellipsis }
.rule select:focus, .rule input:focus { outline: 2px solid var(--focus); outline-offset: 1px }
.rule .match { flex: 1; min-width: 140px }
.rule .cmd { display: block; width: 100%; margin-top: 8px }
@@ -3363,7 +3387,7 @@
.fromplugin { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--hair) }
.fromplugin .lb { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
.hookrow { display: flex; align-items: baseline; gap: 10px; margin-top: 6px }
.hookrow .ev { flex: none; min-width: 128px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted) }
.hookrow .ev { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted) }
.hookrow .cm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.hookrow .sc { flex: none; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--ghost) }
@@ -3614,7 +3638,8 @@
}
.memrow[data-used] .dot { background: var(--net); box-shadow: none }
.memrow .nm {
flex: none; min-width: 168px; padding: 0; border: 0; background: none; text-align: left;
flex: 0 1 auto; min-width: 168px; max-width: 60%; padding: 0; border: 0; background: none; text-align: left;
overflow-wrap: anywhere;
font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text); cursor: pointer;
}
.memrow .nm:hover { text-decoration: underline }
@@ -3625,6 +3650,7 @@
flex: none; padding: 1px 5px; border-radius: var(--r-pill); font: var(--w-med) var(--fs-micro) var(--ui); font-style: normal;
color: var(--warn-ink); background: color-mix(in srgb, var(--warn) 14%, transparent);
}
.memrow .act { flex: none; white-space: nowrap }
.memrow .sc { flex: none; min-width: 30px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.memrow .at { flex: none; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--ghost); font-variant-numeric: tabular-nums }
.memrow .why-used { margin: -2px 0 8px 26px; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--net) }
@@ -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,238 @@
.fbk-veil {
position: fixed; inset: var(--chrome-h, 39px) 0 0; z-index: 55;
display: grid; place-items: center; padding: 20px;
background: color-mix(in srgb, var(--page) 55%, transparent);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
.fbk {
width: min(720px, 100%); max-height: 100%;
display: flex; flex-direction: column; overflow: hidden;
border: 1px solid var(--border); border-radius: var(--r-lg);
background: var(--float); box-shadow: var(--shadow-float);
color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui);
}
.fbk-hd { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px 4px; }
.fbk-hd h2 { margin: 0; font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.fbk-hd .btn { display: inline-flex; align-items: center; gap: 6px; }
.fbk-hd .btn .studio-icon { width: 13px; height: 13px; }
.fbk-hd .esc { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost); }
.fbk-tabs { display: flex; gap: 2px; padding: 0 16px; border-bottom: 1px solid var(--hair); }
.fbk-tabs button {
position: relative; padding: 8px 12px; border: 0; background: transparent; color: var(--muted);
font: var(--w-med) var(--fs-ui) var(--ui); cursor: pointer;
}
.fbk-tabs button:hover { color: var(--text); }
.fbk-tabs button[aria-selected="true"] { color: var(--text); }
.fbk-tabs button[aria-selected="true"]::after {
content: ""; position: absolute; inset: auto 8px -1px; height: 2px; border-radius: 2px; background: var(--accent);
}
.fbk-tabs button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; border-radius: var(--r-xs); }
.fbk-body { min-height: 0; overflow: auto; padding: 14px 16px 16px; }
.fbk-body [hidden] { display: none; }
.fbk-form, .fbk-mine { display: flex; flex-direction: column; gap: 14px; }
.fbk-form[data-over] .fbk-drop { border-color: var(--net); background: var(--net-wash); }
.fbk-notice {
display: flex; gap: 10px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--border));
border-left: 3px solid var(--warn); border-radius: var(--r-sm);
background: color-mix(in srgb, var(--warn) 9%, var(--raised));
}
.fbk-notice > .studio-icon { flex: none; width: 16px; height: 16px; margin-top: 2px; color: var(--warn-ink); }
.fbk-notice h3 { margin: 0 0 3px; font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); }
.fbk-notice p { margin: 0; color: var(--muted); font-size: var(--fs-meta); line-height: 1.6; }
.fbk-notice p + p { margin-top: 4px; }
.fbk-notice p[data-tone="warn"] { color: var(--warn-ink); font-weight: var(--w-med); }
.fbk a { color: var(--net); text-decoration: underline; text-underline-offset: 2px; }
.fbk a:hover { color: var(--text); }
.fbk a:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; }
.fbk-fields { display: flex; flex-direction: column; gap: 14px; min-width: 0; margin: 0; padding: 0; border: 0; }
.fbk-fields:disabled { opacity: .7; }
.fbk-cats { display: flex; flex-wrap: wrap; gap: 6px; }
.fbk-cat { position: relative; }
.fbk-cat input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.fbk-cat span {
display: inline-block; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-pill);
color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui);
}
.fbk-cat:hover span { color: var(--text); border-color: var(--faint); }
.fbk-cat input:checked + span { border-color: var(--accent); background: var(--accent-wash); color: var(--accent-ink); }
.fbk-cat input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 2px; }
.fbk-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.fbk-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
.fbk-label { font: var(--w-med) var(--fs-meta) var(--ui); color: var(--text); }
.fbk-hint { font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--muted); }
.fbk-hint[data-tone="warn"] { color: var(--warn-ink); }
.fbk-field input:not([type="file"]), .fbk-field textarea {
width: 100%; box-sizing: border-box; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--r-xs);
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui);
}
.fbk-field textarea { resize: vertical; min-height: 110px; }
.fbk-field input:focus, .fbk-field textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
.fbk-field [aria-invalid="true"] { border-color: var(--err); }
.fbk-count { align-self: flex-end; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--muted); }
.fbk-count[data-over] { color: var(--err-ink); }
.fbk-drop {
display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px dashed var(--border);
border-radius: var(--r-sm); background: var(--surface);
}
.fbk-drop-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.fbk-drop-line .btn { display: inline-flex; align-items: center; gap: 5px; }
.fbk-drop-line .btn .studio-icon { width: 12px; height: 12px; }
.fbk-shots { display: flex; flex-wrap: wrap; gap: 10px; margin: 0; padding: 0; list-style: none; }
.fbk-shots li {
position: relative; width: 132px; display: flex; flex-direction: column; gap: 4px; padding: 6px;
border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised);
}
.fbk-shots img { display: block; width: 100%; height: 84px; object-fit: cover; border-radius: var(--r-xs); background: var(--overlay); }
.fbk-shot-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--muted); }
.fbk-shot-x {
position: absolute; top: 2px; right: 2px; width: 22px; height: 22px; display: grid; place-items: center; padding: 0;
border: 1px solid var(--border); border-radius: 50%; background: var(--float); color: var(--text); cursor: pointer;
}
.fbk-shot-x .studio-icon { width: 11px; height: 11px; }
.fbk-shot-x:hover { border-color: var(--err); color: var(--err-ink); }
.fbk-shot-x:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.fbk-refused { margin: 0; padding: 0; list-style: none; color: var(--err-ink); font-size: var(--fs-meta); }
.fbk-env { padding: 10px 12px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface); }
.fbk-env h3 { margin: 0 0 6px; font: var(--w-med) var(--fs-meta) var(--ui); color: var(--text); }
.fbk-env dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 14px; margin: 0 0 6px; }
.fbk-env dt { color: var(--muted); font-size: var(--fs-meta); }
.fbk-env dd { margin: 0; overflow-wrap: anywhere; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--text); }
.fbk-env .fbk-hint { margin: 0; }
.fbk-note {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 8px 12px; border: 1px solid var(--border);
border-radius: var(--r-sm); background: var(--surface); color: var(--text); font-size: var(--fs-ui);
}
.fbk-note > span { flex: 1 1 200px; min-width: 0; }
.fbk-note > .studio-icon { flex: none; width: 15px; height: 15px; }
.fbk-note[data-tone="error"] { border-color: color-mix(in srgb, var(--err) 50%, var(--border)); background: color-mix(in srgb, var(--err) 8%, var(--raised)); }
.fbk-note[data-tone="error"] > .studio-icon { color: var(--err-ink); }
.fbk-note[data-tone="warn"] { border-color: color-mix(in srgb, var(--warn) 45%, var(--border)); background: color-mix(in srgb, var(--warn) 8%, var(--raised)); }
.fbk-note[data-tone="warn"] > .studio-icon { color: var(--warn-ink); }
.fbk-note[data-tone="info"] > .studio-icon { color: var(--net); }
.fbk-foot {
position: sticky; bottom: -16px; z-index: 1; display: flex; flex-direction: column; gap: 10px;
margin: 0 -16px -16px; padding: 10px 16px 14px; border-top: 1px solid var(--hair); background: var(--float);
}
.fbk-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.fbk-acts .act { padding: 6px 14px; font: var(--w-med) var(--fs-ui) var(--ui); }
.fbk-acts .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.fbk-acts .act[data-primary]:hover:not(:disabled) { background: var(--accent); filter: brightness(1.08); }
.fbk-acts .act[data-primary]:disabled { border-color: var(--border); background: transparent; color: var(--ghost); }
.fbk-sent { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; padding: 8px 0; }
.fbk-sent > .studio-icon { width: 30px; height: 30px; padding: 6px; border-radius: 50%; background: color-mix(in srgb, var(--ok) 14%, var(--raised)); color: var(--ok-ink); }
.fbk-sent h3 { margin: 0; font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); }
.fbk-sent h3:focus { outline: none; }
.fbk-receipt { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.fbk-receipt code {
padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface);
font: var(--w-med) var(--fs-title) var(--mono); letter-spacing: .04em; user-select: all;
}
.fbk-mine-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
.fbk-mine-bar .btn { display: inline-flex; align-items: center; gap: 5px; }
.fbk-mine-bar .btn .studio-icon { width: 12px; height: 12px; }
.fbk-empty { margin: 8px 0; padding: 22px 12px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: var(--r-sm); }
.fbk-list { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--hair); }
.fbk-item { display: flex; flex-direction: column; gap: 6px; padding: 12px 0; border-bottom: 1px solid var(--hair); }
.fbk-item-hd { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.fbk-code { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); }
.fbk-item-hd .copy { padding: 2px; }
.fbk-receipt .copy {
padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--r-pill); background: transparent;
color: var(--text); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer;
}
.fbk-receipt .copy:hover { background: var(--overlay); }
.fbk-meta { flex: 1 1 auto; color: var(--muted); font-size: var(--fs-meta); }
.fbk-snippet { margin: 0; overflow-wrap: anywhere; color: var(--text); }
.fbk-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border: 1px solid var(--border);
border-radius: var(--r-pill); background: var(--surface); color: var(--text); font: var(--w-med) var(--fs-meta) var(--ui);
}
.fbk-chip .studio-icon { width: 11px; height: 11px; }
.fbk-chip[data-status="received"] .studio-icon { color: var(--muted); }
.fbk-chip[data-status="recorded"] { border-color: color-mix(in srgb, var(--net) 40%, var(--border)); }
.fbk-chip[data-status="recorded"] .studio-icon { color: var(--net); }
.fbk-chip[data-status="in_progress"] { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); background: var(--accent-wash); color: var(--accent-ink); }
.fbk-chip[data-status="fixed"] { border-color: color-mix(in srgb, var(--ok) 45%, var(--border)); background: color-mix(in srgb, var(--ok) 10%, var(--raised)); }
.fbk-chip[data-status="fixed"] .studio-icon { color: var(--ok-ink); }
.fbk-chip[data-status="wontfix"] .studio-icon, .fbk-chip[data-status="duplicate"] .studio-icon { color: var(--muted); }
.fbk-chip[data-status="needs_info"] { border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); background: color-mix(in srgb, var(--warn) 11%, var(--raised)); }
.fbk-chip[data-status="needs_info"] .studio-icon { color: var(--warn-ink); }
.fbk-chip[data-status="answered"] { border-color: color-mix(in srgb, var(--net) 45%, var(--border)); }
.fbk-chip[data-status="answered"] .studio-icon { color: var(--net); }
.fbk-chip[data-status="closed"] { color: var(--muted); background: transparent; }
.fbk-chip[data-status="closed"] .studio-icon { color: var(--muted); }
.fbk-tl { display: flex; flex-wrap: wrap; gap: 4px 0; margin: 0; padding: 0; list-style: none; }
.fbk-tl li { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--fs-meta); }
.fbk-note .btn { color: var(--text); }
.fbk-tl li:not(:last-child)::after { content: ""; width: 18px; height: 1px; margin: 0 6px; background: var(--border); }
.fbk-tl i { flex: none; width: 9px; height: 9px; border: 1.5px solid var(--ghost); border-radius: 50%; }
.fbk-tl li[data-state="done"] { color: var(--text); }
.fbk-tl li[data-state="done"] i { border-color: var(--ok); background: var(--ok); }
.fbk-tl li[data-state="current"] { color: var(--text); font-weight: var(--w-med); }
.fbk-tl li[data-state="current"] i { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); }
.fbk-tl li[data-state="todo"] { color: var(--ghost); }
.studio-feedback { margin-top: 0; }
.studio-feedback > span { color: var(--muted); }
.fbk-item[data-stale] .fbk-snippet, .fbk-item[data-stale] .fbk-meta { color: var(--muted); }
.fbk-chip[data-status="unavailable"] { border-style: dashed; color: var(--muted); background: transparent; }
@media (max-width: 800px) {
.fbk-tl { flex-direction: column; align-items: flex-start; }
.fbk-tl li:not(:last-child)::after { display: none; }
}
.fbk-thread { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
.fbk-thread ol { display: flex; flex-direction: column; gap: 8px; width: 100%; margin: 0; padding: 0; list-style: none; }
.fbk-thread li {
display: flex; flex-direction: column; gap: 3px; max-width: min(92%, 560px); padding: 8px 11px;
border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface);
}
.fbk-thread li[data-author="user"] {
align-self: flex-end; border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); background: var(--accent-wash);
}
.fbk-thread li[data-new] { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.fbk-msg-hd { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--fs-meta); }
.fbk-msg-hd b { color: var(--text); font-weight: var(--w-med); }
.fbk-msg-hd time { margin-left: auto; }
.fbk-new {
padding: 0 7px; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg);
font: var(--w-med) var(--fs-micro)/1.6 var(--ui);
}
.fbk-msg { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; color: var(--text); }
.fbk-thread .btn { display: inline-flex; align-items: center; }
.fbk-reply-open { align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; }
.fbk-reply-open .studio-icon { width: 12px; height: 12px; }
.fbk-reply {
display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--r-sm); background: var(--surface);
}
.fbk-reply[data-needs] { border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); }
.fbk-reply textarea {
width: 100%; box-sizing: border-box; min-height: 72px; padding: 7px 10px; border: 1px solid var(--border);
border-radius: var(--r-xs); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); resize: vertical;
}
.fbk-reply textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
.fbk-reply textarea[aria-invalid="true"] { border-color: var(--err); }
.fbk-reply-foot { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 4px 12px; }
.fbk-reply-foot .fbk-hint { flex: 1 1 240px; }
.fbk-item:focus { outline: none; }
.studio-feedback .fbk-badge {
margin-left: auto; min-width: 16px; height: 16px; padding: 0 5px; box-sizing: border-box; display: inline-grid; place-items: center;
border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) 10px/1 var(--ui);
}
.fbk-msg { unicode-bidi: isolate; }
.fbk-tl li[data-tone="neutral"] i { border-color: var(--ghost); background: var(--ghost); }
.palette-hit > small[data-alert] { color: var(--text); font: 600 11px/1 var(--ui); }
@@ -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,26 @@
import { describe, expect, it } from "vitest";
const CSS = Object.values(
import.meta.glob("./app.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>,
)[0];
function declarations(selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const m = CSS.match(new RegExp(String.raw`(?:^|\n)\s*${escaped}\s*\{([^}]*)\}`));
return m?.[1] ?? "";
}
describe("a memory row stays inside its container", () => {
const name = declarations(".memrow .nm");
it("lets the title shrink and wrap instead of pushing the row wider", () => {
expect(name, "no .memrow .nm rule found").not.toBe("");
expect(name).not.toMatch(/flex:\s*none/);
expect(name).toMatch(/overflow-wrap:\s*anywhere/);
expect(name).toMatch(/max-width:/);
});
it("keeps the trailing controls whole", () => {
expect(declarations(".memrow .act")).toMatch(/white-space:\s*nowrap/);
});
});
@@ -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>
</>
);
}
+74 -40
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useEscape } from "./dismiss";
import { t } from "../i18n";
import { current as language, plural, t } from "../i18n";
import { useFileDrop } from "./filedrop";
import type { AgentPort, PluginAction, PluginPackage, PluginPlan } from "../port/port";
import { reason } from "../i18n/kernel";
@@ -29,16 +29,20 @@ interface Props {
updating?: PluginPackage;
// A source someone already chose, such as a plugin named in a restored backup.
source?: string;
onApplying?: (applying: boolean) => void;
}
export function AddPlugin({ port, onClose, onInstalled, updating, source }: Props) {
// Mounted only while open, so this component is the layer Escape closes.
useEscape(true, onClose);
export function AddPlugin({ port, onClose, onInstalled, updating, source, onApplying }: Props) {
const [text, setText] = useState(updating?.source ?? source ?? "");
const [plan, setPlan] = useState<PluginPlan | null>(null);
const [done, setDone] = useState<PluginPlan | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const apply = useRef<HTMLButtonElement>(null);
useEscape(true, () => { if (!updating || !plan || !busy) onClose(); });
useEffect(() => {
if (updating?.name && !busy && (done || (error && plan))) apply.current?.focus();
}, [updating?.name, error, plan, done, busy]);
const request = (planId?: string) => ({
source: text.trim(),
@@ -48,6 +52,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
});
const look = async () => {
if (busy || !text.trim()) return;
setBusy(true);
setError("");
try {
@@ -71,6 +76,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
const install = async () => {
if (!plan) return;
setBusy(true);
onApplying?.(true);
setError("");
try {
const out = await port.installPlugin(request(plan.planId));
@@ -80,12 +86,22 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
setError(reason(e));
} finally {
setBusy(false);
onApplying?.(false);
}
};
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
@@ -100,14 +116,14 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
// 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 (
<div className="addpkg" data-stage="done">
<Outcome plan={done} />
<div className="acts">
<button className="act" data-action="extensions.finish" onClick={onClose}>
<button className="act" data-action="extensions.finish" ref={apply} onClick={onClose}>
{t("完成")}
</button>
</div>
@@ -122,7 +138,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
})).filter((g) => g.actions.length > 0);
const gained = updating ? newlyGained(updating, plan.actions ?? []) : [];
return (
<div className="addpkg" data-stage="confirm">
<div className="addpkg" data-stage="confirm" aria-busy={busy}>
{/* What this version brings that the installed one did not. A package
that was only skills last time and starts a process this time is
the case the whole confirmation exists for. */}
@@ -133,12 +149,12 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
? `${updating.name} ${updating.version} → ${version(plan.actions)}`
: t("重装 {name}", { name: updating.name })}
</span>
<span className="why">{gained.length ? t("本版本新增:{list}", { list: gained.join("、") }) : t("没有新增的可执行内容。")}</span>
<span className="why">{gained.length ? t("本版本新增:{list}", { list: gained.join(language() === "zh" ? "、" : " · ") }) : t("没有新增的可执行内容。")}</span>
</div>
)}
{groups.map((g) => (
<section className="rgrp" key={g.level} data-lvl={g.level}>
<h3>{RISK_TITLE[g.level] ?? g.level}</h3>
<h3>{t(RISK_TITLE[g.level] ?? g.level)}</h3>
{g.actions.map((a, i) => (
<Candidate a={a} key={`${a.kind}:${a.name}:${i}`} />
))}
@@ -154,6 +170,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
<button
className="act"
data-action={updating ? "extensions.cancel" : "extensions.back"}
disabled={busy}
onClick={() => (updating ? onClose() : setPlan(null))}
>
{t(updating ? "取消" : "返回")}
@@ -161,6 +178,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
<button
className="act"
data-action={updating ? "extensions.update" : "extensions.install"}
ref={apply}
data-primary
disabled={busy}
onClick={() => void install()}
@@ -168,7 +186,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
{t(busy ? (updating ? "更新中…" : "安装中…") : updating ? "更新" : "安装")}
</button>
</div>
{error && <div className="why">{error}</div>}
{error && <div className="why" role="alert">{error}</div>}
</div>
);
}
@@ -177,7 +195,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
// a failure there is about the recorded source, not about what was typed.
if (updating) {
return (
<div className="addpkg" data-stage="reading">
<div className="addpkg" data-stage="reading" aria-busy={busy}>
<div className="find" data-lvl={error ? "err" : undefined}>
<span className="t">{error ? t("无法读取 {name} 的来源", { name: updating.name }) : t("正在读取 {name} 的来源…", { name: updating.name })}</span>
<span className="why">{error || updating.source}</span>
@@ -192,22 +210,26 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
}
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}>
@@ -223,7 +245,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source }: Prop
{t(busy ? "读取中…" : "查看内容")}
</button>
</div>
{error && <div className="why">{error}</div>}
{error && <div className="why" role="alert">{error}</div>}
</div>
);
}
@@ -244,9 +266,9 @@ export function Candidate({ a }: { a: PluginAction }) {
</div>
{runs.map((r) => (
<div className="risk" data-kind="shell" key={r.label}>
<span className="lb">{r.label}</span>
<span className="lb">{t(r.label)}</span>
<span className="dt">{r.detail}</span>
{r.why && <span className="why">{r.why}</span>}
{r.why && <span className="why">{t(r.why)}</span>}
</div>
))}
{adds.length > 0 && (
@@ -293,13 +315,13 @@ function executes(a: PluginAction): { label: string; detail: string; why?: strin
});
}
if (a.runtime?.tools?.length) {
out.push({ label: "新增工具", detail: a.runtime.tools.join("、"), why: "agent 可以调用,每次调用照常经过权限确认" });
out.push({ label: "新增工具", detail: a.runtime.tools.join(language() === "zh" ? "、" : ", "), why: "agent 可以调用,每次调用照常经过权限确认" });
}
if (a.hookCount) {
out.push({ label: "自动化钩子", detail: `${a.hookCount} 条`, why: "在会话生命周期内自动执行" });
out.push({ label: "自动化钩子", detail: language() === "zh" ? t("{n} 条", { n: a.hookCount }) : plural(a.hookCount, "1 条钩子", "{n} 条钩子"), why: "在会话生命周期内自动执行" });
}
if (a.toolCount) {
out.push({ label: "外部服务", detail: `${a.toolCount} 个 MCP 服务`, why: "为 agent 提供的能力与内置工具等同" });
out.push({ label: "外部服务", detail: plural(a.toolCount, "1 个 MCP 服务", "{n} 个 MCP 服务"), why: "为 agent 提供的能力与内置工具等同" });
}
if (a.kind === "mcp" && a.command) {
out.push({ label: "将启动", detail: [a.command, ...(a.args ?? [])].join(" ") });
@@ -320,29 +342,29 @@ function version(actions?: PluginAction[]): string {
function newlyGained(current: PluginPackage, actions: PluginAction[]): string[] {
const plugin = actions.find((a) => a.kind === "plugin");
const out: string[] = [];
if (plugin?.runtime && !current.runtime) out.push("一个常驻进程");
if (plugin?.runtime && !current.runtime) out.push(t("一个常驻进程"));
const tools = (plugin?.runtime?.tools ?? []).filter((name) => !current.runtime?.tools?.includes(name));
if (tools.length > 0) out.push(`工具 ${tools.join("、")}`);
if (tools.length > 0) out.push(t("工具 {names}", { names: tools.join(language() === "zh" ? "、" : ", ") }));
const hooks = (plugin?.hookCount ?? 0) - (current.hooks?.length ?? 0);
if (hooks > 0) out.push(`${hooks} 条钩子`);
if (hooks > 0) out.push(plural(hooks, "一条钩子", "{n} 条钩子"));
const servers = (plugin?.toolCount ?? 0) - (current.mcpServers?.length ?? 0);
if (servers > 0) out.push(`${servers} 个外部服务`);
if (servers > 0) out.push(plural(servers, "一个外部服务", "{n} 个外部服务"));
// A separate MCP action installs a server of its own, outside the package.
const standalone = actions.filter((a) => a.kind === "mcp").length;
if (standalone > 0) out.push(`${standalone} 个独立的 MCP 服务`);
if (standalone > 0) out.push(plural(standalone, "一个独立的 MCP 服务", "{n} 个独立的 MCP 服务"));
return out;
}
function contributes(a: PluginAction): string[] {
const parts: string[] = [];
const add = (n: number | undefined, unit: string) => {
if (n) parts.push(`${n} ${unit}`);
const add = (n: number | undefined, one: string, many: string) => {
if (n) parts.push(plural(n, one, many));
};
add(a.skillCount, "个技能");
add(a.commandCount, "个命令");
add(a.agentCount, "个子代理");
add(a.promptCount, "个提示词");
add(a.themeCount, "套配色");
add(a.skillCount, "1 个技能", "{n} 个技能");
add(a.commandCount, "1 个命令", "{n} 个命令");
add(a.agentCount, "1 个子代理", "{n} 个子代理");
add(a.promptCount, "1 个提示词", "{n} 个提示词");
add(a.themeCount, "1 套配色", "{n} 套配色");
return parts;
}
@@ -350,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,
);
}
+26
View File
@@ -15,6 +15,7 @@ import { folded as roomGaveUp, onRoomWidth } from "./viewport";
import { useFoldAway } from "./foldaway";
import { useDrawerCloses } from "./drawer";
import { RemoteAsk } from "./RemoteAsk";
import { Feedback, type FeedbackTab } from "./Feedback";
import { BrowserLogin } from "./BrowserLogin";
import type { RemoteAsk as RemoteAskT, RemoteHost } from "../port/remote";
import { Boundary } from "./Boundary";
@@ -24,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";
@@ -104,6 +106,8 @@ export function App({ hub }: { hub: HubPort }) {
const [error, setError] = useState("");
// false = closed, true = open at its last section, a string = open there.
const [settings, setSettings] = useState<string | boolean>(false);
const [feedback, setFeedback] = useState<FeedbackTab | null>(null);
const [feedbackUnread, setFeedbackUnread] = useState(0);
const [browser, setBrowser] = useState(false);
const [setup, setSetup] = useState<ProviderSetup | null | undefined>(undefined);
// undefined until asked; false means the opening sequence is still owed.
@@ -254,6 +258,20 @@ export function App({ hub }: { hub: HubPort }) {
useLaunchHealth(activePort, setup, welcomed);
// Asked once when the app opens and again each time the page is opened,
// never on a timer: there is no push channel to keep honest.
const feedbackPort = networkPort ?? activePort;
const feedbackAsk = useRef(feedbackPort);
feedbackAsk.current = feedbackPort;
const feedbackReady = feedbackPort !== null;
useEffect(() => {
let alive = true;
feedbackAsk.current?.myFeedback().then((m) => alive && setFeedbackUnread(m.unread)).catch(() => {});
return () => {
alive = false;
};
}, [feedbackReady]);
useEffect(() => {
if (!activePort) return;
let alive = true;
@@ -638,6 +656,8 @@ export function App({ hub }: { hub: HubPort }) {
accountUnread={accountUnread}
wallet={report.wallet}
onSettings={showPrefs}
onFeedback={(tab) => setFeedback(tab ?? "send")}
feedbackUnread={feedbackUnread}
onFind={openFind}
onError={fail}
/>
@@ -735,6 +755,12 @@ 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} />
)}
{settings && activePort && (
<Boundary fallback={<SettingsUnavailable onClose={hidePrefs} />}>
<Suspense fallback={<div className="prefs" aria-busy="true" />}>
Loaded 100 of 759 files, more files were not shown because too many files have changed in this diff. Show more