Compare commits

...
Author SHA1 Message Date
YHH 1a4a8e2366 Merge pull request #14887 from esengine/c5-notes-2.56.1
docs(release): studio 2.56.1 notes
2026-10-31 19:22:53 -07:00
esengine eea2d3d2f1 docs(release): studio 2.56.1 notes 2026-10-31 19:10:52 -07:00
YHH ae7ff78e78 Merge pull request #14886 from esengine/fx/sec-pyv
fix(shellsafe): read-only only for a bare node or python version probe
2026-10-31 19:10:43 -07:00
esengine ca08f277f4 fix(shellsafe): read-only only for a bare node or python version probe 2026-10-31 18:29:40 -07:00
esengine aaa4f586b1 fix(shellsafe): treat only the exact version-probe spellings of node and python as reads 2026-10-31 09:56:54 -07:00
YHH b9daee966a Merge pull request #14877 from esengine/fx/queue-row-actions
fix(queue): draw edit and move only on lines the kernel can still change
2026-10-31 04:25:23 -07:00
esengine cda88398cc fix(queue): draw edit and move only on lines the kernel can still change 2026-10-31 04:03:12 -07:00
YHH 5f372e2229 Merge pull request #14873 from esengine/fx/phone-layout
fix(studio): phone-width spacing for the sidebar logo and the Settings header
2026-10-31 01:25:50 -07:00
esengine c0c5acb382 fix(studio): phone-width spacing for the sidebar logo and the Settings header
At phone width the sidebar menu button touched the logo, Settings put Close on
its own row under the search box, and each settings tab kept a 6.5em name
minimum so only three fit. The logo gets a left gap, the header grid holds
expand and Close side by side, and tab names size to their content.
2026-10-31 01:03:51 -07:00
YHH 9371541e1c Merge pull request #14871 from esengine/fx/primary-button-fill-2
fix(studio): a primary button in settings keeps its fill with its foreground
2026-10-30 23:25:54 -07:00
esengine 8981f49c6a fix(studio): a primary button in settings keeps its fill with its foreground
The settings type rule recoloured every enabled primary button's text to the
accent foreground and relied on each container to supply the fill. A container
with no fill rule of its own, such as the scheduled-run form, showed light text
on no fill, so Preview and Confirm looked blank. The rule now carries the fill
and border with the foreground.
2026-10-30 23:08:50 -07:00
YHH cec99a96d1 Merge pull request #14866 from esengine/fx/browser-tab-cap
fix(browser): bound the pages the agent can have open at once
2026-10-30 22:55:06 -07:00
esengine d1852af99f fix(browser): reserve the page count atomically so overlapping opens cannot exceed the limit
The check counted open pages and then created one, so concurrent agent opens at
the limit could all pass. A reservation counts pages still being created, and is
released once the page is in the list.
2026-10-30 22:28:49 -07:00
esengine 945568ce89 fix(browser): bound the pages the agent can have open at once
A model that opened a page per search result left dozens of renderer processes
running; one report shows 30 pages and about 2 GB held. Only a prompt hint asked
the model to close pages, and the shell's own sleep cap cannot keep up with a
burst of opens. The session now refuses a new agent page once 12 are open and
says how to make room; a page the person opens is never refused, though it
counts toward the total.
2026-10-30 21:30:12 -07:00
YHH a736f120bc Merge pull request #14851 from kevin9327/fix/capdiag-rune-boundary
fix(capdiag): cap sanitized diagnostic text on a rune boundary
2026-10-30 15:55:03 -07:00
kevin9327 d2a603eb9e fix(capdiag): keep collect.go under the file-size ceiling
Move the rune-boundary cut into a small helper in text.go so the fix
does not push collect.go over repolint's 800-line limit.
2026-10-31 06:58:57 +09:00
kevin9327 5b4909fcab fix(capdiag): cap sanitized diagnostic text on a rune boundary 2026-10-31 06:51:04 +09:00
YHH 6248f800a4 Merge pull request #14850 from esengine/fx/migrate-tests-skip-windows
test(migration): skip the unreadable-source cases where the platform cannot build one
2026-10-30 13:55:12 -07:00
esengine cdc749cca8 test(migration): skip the unreadable-source cases where the platform cannot build one
On Windows a regular file at the sessions path is reported as absent, which is
the normal fresh install, so the fixtures could not make an unreadable source
and the two tests failed on every Windows push run. The fixtures now skip there
and no longer claim the file fallback works on every platform.
2026-10-30 12:58:41 -07:00
YHH 837689dfa4 Merge pull request #14823 from chuiwenwei/fix/migrate-says-when-it-could-not-read
fix(migrate): an unreadable source is not an empty one
2026-10-30 11:56:22 -07:00
YHH b92e5affba Merge pull request #14848 from esengine/fx/ci-windows-full-timeout
ci(windows): give the full push suite room for a slow boot package
2026-10-30 11:25:45 -07:00
YHH 93105b148f Merge pull request #14849 from jf200s/fix/picker-clean-attribute-keys
fix(studio): clip attribute keys like their values
2026-10-30 11:25:08 -07:00
F dd8b40eb53 fix(studio): clip attribute keys like their values
clean() clipped attribute values to VALUE_CLIP but left keys unbounded, so a page could return thousands of long attribute names. Keys are now clipped with the same limit, with a unit assertion for a 600-character key.
2026-10-31 01:45:11 +08:00
esengine eccc45eca2 ci(windows): give the full push suite room for a slow boot package
The Windows full-suite step failed twice within a few hours on push runs, with
internal/assembly/boot at 1630s and then at 1680s, which is its 28m package
limit. The package alone can take over 25 minutes on a runner, so the limit
becomes 36m inside a 45m step budget.
2026-10-30 10:26:42 -07:00
YHH d7a11e2d27 Merge pull request #14795 from jf200s/feat/element-picker
feat(studio): pick an element in the built-in browser
2026-10-30 09:25:04 -07:00
YHH f16e5e3f04 Merge pull request #14824 from chuiwenwei/fix/an-unreadable-config-is-not-a-missing-one
fix(doctor): an unreadable config is not a missing one
2026-10-30 08:27:18 -07:00
F 3e7c42bc2b fix(studio): keep verify in the picker's world and cap attributes
verify() now asks the isolated world like lands() does, the attribute map is capped at 64 entries, and a held Ctrl+Shift+C no longer re-arms the picker on every repeat.
2026-10-30 22:53:54 +08:00
YHH 613165df93 Merge pull request #14843 from kevin9327/fix/completestep-cmd-rune-boundary
fix(complete_step): cut listed commands in the citation hint on a rune boundary
2026-10-30 07:26:26 -07:00
YHH 7d261d9edd Merge pull request #14842 from kevin9327/fix/evidence-quote-short-rune-boundary
fix(evidence): cut a cited command in a rejection message on a rune boundary
2026-10-30 07:25:08 -07:00
kevin9327 fc939ca7a6 fix(complete_step): cut listed commands in the citation hint on a rune boundary
Signed-off-by: kevin9327 <[email protected]>
2026-10-30 22:30:51 +09:00
kevin9327 c9a3131e77 fix(evidence): cut a cited command in a rejection message on a rune boundary
Signed-off-by: kevin9327 <[email protected]>
2026-10-30 22:29:40 +09:00
YHH af0e954f56 Merge pull request #14841 from ShryukGrandhi/fix/issue-14413-studio-feedback-decode-branch-meta-c-use
fix(sessionstore): decode the branch-meta sidecar as UTF-8 before transcoding
2026-10-30 05:55:25 -07:00
YHH 9976474a6c Merge pull request #14825 from chuiwenwei/fix/runtime-doctor-says-unknown
fix(doctor): a runtime nobody can ask is not clean
2026-10-30 00:29:39 -07:00
ShryukGrandhi b126505b4f fix(sessionstore): decode the branch-meta sidecar as UTF-8 before transcoding
The sidecar is JSON written by json.MarshalIndent. Reading it through the
GB18030 detection cascade meant a single stray non-UTF-8 byte re-decoded the
whole file as GB18030, where a lead byte before a Windows path separator
swallowed the backslash of an escaped "\\" and left an invalid \W escape.
Decode the bytes as written first; transcode only when that fails.

Signed-off-by: ShryukGrandhi <[email protected]>
2026-10-30 00:20:57 -07:00
Will 9b6a0f3327 docs(release-notes): record #14824 2026-10-30 14:16:24 +08:00
Will 5b4cfd0607 docs(release-notes): record #14823 2026-10-30 14:16:19 +08:00
Will 74ef86d84a docs(release-notes): record #14825 2026-10-30 14:15:48 +08:00
YHH c7a36b43b1 Merge pull request #14821 from esengine/fx/android-verification-lint
fix(android): verify the 24 artifacts the release lint task resolves on CI
2026-10-29 21:59:23 -07:00
esengine 8ce6939120 fix(android): verify the 24 artifacts the release lint task resolves on CI 2026-10-29 21:30:54 -07:00
YHH 1c147780ff Merge pull request #14676 from SivanCola/fix/environment-snapshot-precedence
fix(boot): preserve environment snapshot precedence / 保持环境探测快照前缀稳定
2026-10-29 21:26:18 -07:00
YHH 50661c33db Merge pull request #14820 from esengine/fx/android-verification-aapt2
fix(android): verify the Linux aapt2 jar the CI build resolves
2026-10-29 21:21:59 -07:00
esengine 6fda6bf868 fix(android): verify the Linux aapt2 jar the CI build resolves 2026-10-29 20:53:17 -07:00
YHH ccec12753c Merge pull request #14819 from esengine/fx/android-verification-metadata
fix(android): verify the two POMs the Gradle classpath needs on CI, and store the file with LF
2026-10-29 20:47:24 -07:00
esengine 524bf552fb fix(android): verify the two POMs the Gradle classpath needs on CI, and store the file with LF 2026-10-29 20:23:46 -07:00
YHH 34bac65194 Merge pull request #14817 from esengine/fx/android-verify-token2
ci(android): let the workflow build a branch as a trial run that publishes nothing
2026-10-29 20:17:37 -07:00
Will 383178d22c fix(doctor): a runtime nobody can ask is not clean
`doctor runtime` always passed a nil BuildResult, so it read the empty
process-local DefaultRuntimeOwner and printed clean=true/cleanRollback=true as
facts about a session it could not observe. The receipt ledger is in-process
only, so a fresh CLI cannot know whether a crash or mid-migration left
irreversible work.

Report recoverability and resume as nil (JSON null) when no runtime was
available to ask; the text says unknown and names why. The BuildResult seam
still computes and prints the observed verdicts exactly as before.
2026-10-30 11:06:59 +08:00
Will 41578aff84 fix(doctor): an unreadable config is not a missing one 2026-10-30 11:03:05 +08:00
esengine 38b253563c ci(android): let the workflow build a branch as a trial run that publishes nothing 2026-10-29 19:57:11 -07:00
YHH 154743d3b8 Merge pull request #14813 from esengine/fx/criteria-walk-parallel
perf(agent): read the criteria walk's directories and files concurrently
2026-10-29 19:52:51 -07:00
YHH d92e52b580 Merge pull request #14814 from esengine/fx/android-verify-token
fix(ci): the Android page check writes its token file owner-only
2026-10-29 19:52:15 -07:00
esengine 916f5943fa perf(agent): read the criteria walk's directories and files concurrently 2026-10-29 19:08:42 -07:00
esengine 7f259f2f96 fix(ci): the Android page check writes its token file owner-only 2026-10-29 18:55:28 -07:00
YHH f26fb981be Merge pull request #14812 from esengine/fx/android-script-modes
fix(ci): run the Android scripts through bash and build the kernel at the tag's version
2026-10-29 18:46:15 -07:00
esengine f93eb63929 fix(ci): run the Android scripts through bash and build the kernel at the tag's version 2026-10-29 18:25:53 -07:00
YHH a75ab7728b Merge pull request #14811 from esengine/fx/android-pnpm-order
fix(ci): set up pnpm before the Node cache that needs it in the Android workflow
2026-10-29 17:55:47 -07:00
esengine 23cb22d3cb fix(ci): set up pnpm before the Node cache that needs it in the Android workflow 2026-10-29 17:25:40 -07:00
YHH cbcc2ebb1e Merge pull request #14810 from esengine/release/studio-2.56.0
docs(release-notes): studio 2.56.0
2026-10-29 17:01:19 -07:00
F fb0b7396e3 fix(studio): bound what a pick may return
The page wrote the answer, so the shell now rebuilds it from an allow-list, clips every string to the page script's own limits, drops unknown fields and treats a shape it cannot read as gone. A rect that is not numbers is dropped; a negative origin is clamped by the screenshot.
2026-10-30 07:56:06 +08:00
esengine 674c2b45e7 docs(release-notes): studio 2.56.0 2026-10-29 16:25:53 -07:00
YHH ec6929c157 Merge pull request #14808 from esengine/fx/parity-scheduled
docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap
2026-10-29 14:09:01 -07:00
esengine dcd4135049 docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap 2026-10-29 13:56:57 -07:00
YHH 08ea935ea9 Merge pull request #14784 from lancy69/feat/14319-recent-sessions
feat(studio): add globally paged recent session discovery
2026-10-29 12:58:29 -07:00
YHH 2a3a3738db Merge pull request #14801 from lancy69/fix/12303-yielded-claim-evidence
fix(agent): preserve unknown scope across yielded workspace claims
2026-10-29 12:28:51 -07:00
YHH d1eb9c968e Merge pull request #14800 from lancy69/fix/12080-workspace-startup-read
fix(studio): share pane startup workspace metadata
2026-10-29 12:27:39 -07:00
YHH a2597fb17c Merge pull request #14805 from xxoingr/fix/worktree-commands-one-at-a-time
fix(worktree): run one repository's worktree commands one at a time
2026-10-29 12:26:40 -07:00
YHH 41ea14ccc9 Merge pull request #14806 from esengine/fx/import-orphan-index
fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed
2026-10-29 11:58:11 -07:00
esengine b3bea0d076 fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed 2026-10-29 11:29:54 -07:00
YHH efff0a825f Merge pull request #14803 from xxoingr/fix/earlier-version-delete-shows
fix(studio): an earlier version in the sidebar shows its delete button
2026-10-29 11:25:38 -07:00
xxoingr 44712c3aad fix(worktree): run one repository's worktree commands one at a time
`git worktree add` creates the new entry's directory under
<common>/worktrees before it writes the entry's files, and every worktree
command resolves each entry's HEAD, reading its commondir and dying on one
it finds empty: "failed to read .../commondir: Success". Two isolated
tasks started in parallel each create a candidate from the same
repository, and on a loaded Linux runner the second add read the first's
entry in that window (TestPreparationConcurrentCandidatesDoNotShareResultState,
run 40689344657).

Every `git worktree` command in the package now goes through runWorktree,
which holds one gate per repository, keyed by its common Git directory,
and gives up waiting when its context ends.
2026-10-30 02:09:35 +08:00
YHH d188af4207 Merge pull request #14783 from lancy69/feat/14699-scheduled-appearance
feat(studio): add a daily dark-mode schedule
2026-10-29 10:59:14 -07:00
xxoingr 8a89f0874b fix(studio): an earlier version in the sidebar shows its delete button
An earlier version or recovery copy has no row menu; its × is the only
way a pointer deletes it. `.rail .sessrow > .wsdel { display: none }`
came in with the Studio rail when session rows moved deletion into their
menu, and since then the copy row is the only one that renders .wsdel,
so the rule hid exactly that button. The hover, focus-within and
hover:none rules beside it were already there to show it.

perf/sessionrows.mjs now puts the button on its earlier-version row and
holds it shown and inside the row on hover and on keyboard focus.
2026-10-30 01:47:47 +08:00
Lancy 350379ffff feat(studio): add globally paged recent session discovery 2026-10-30 01:27:28 +08:00
Lancy 65ecbce661 feat(studio): add a daily dark-mode schedule 2026-10-30 01:19:11 +08:00
Lancy 2656f33f28 fix(agent): preserve unknown scope across yielded workspace claims 2026-10-30 01:13:00 +08:00
Lancy e31c2347b4 fix(studio): share pane startup workspace metadata 2026-10-30 01:05:03 +08:00
F 74c2f11209 feat(studio): pick an element in the built-in browser
The shell half: the in-page picker script, pick / cancelPick / verify on BrowserViews, the three IPC handlers and preload methods, and the unit and live suites. Inert until a control calls it.
2026-10-30 00:27:39 +08:00
YHH be65b117e0 Merge pull request #14493 from imcreazy/ci/android-release
ci(android): build the APK on a tag and attach it to the release
2026-10-29 08:52:55 -07:00
imcreazy 19f05e8509 Attach the APK only when it is signed, and wait long enough for the release
An unsigned package cannot be installed at all, so publishing one put an
unusable file on every release page. The wait was also shorter than the
release actually takes.
2026-10-29 23:11:20 +08:00
YHH 4fde96dccc Merge pull request #14781 from lancy69/fix/14759-dim-composer-placeholder
feat(studio): add a default-on composer hint visibility setting
2026-10-29 07:39:33 -07:00
YHH 9c38dd1129 Merge pull request #14771 from 1012905/fx/session-remove-many-kernel
feat(serve): 通过 POST /tree/sessions/remove-many 批量删除会话
2026-10-29 07:20:19 -07:00
Lancy 355e047c27 feat(studio): add a default-on composer hint visibility setting 2026-10-29 22:07:16 +08:00
1012905 0abc13d3c0 chore(release-notes): name the pull request in the batch-remove fragment
The fragment lost its issue reference when the lines were cut to one
sentence each, and the release-note lint asks every change to name the
issue or pull request it belongs to. Both lines now end with the
reference, and both stay under the 200-column limit.
2026-10-29 21:42:55 +08:00
YHH 4055d9e404 Merge pull request #14791 from 1012905/fx/settings-search-storage-race
test(desktop): the settings search is measured without the storage tally
2026-10-29 06:40:23 -07:00
1012905 ddd0db3ee7 feat(serve): batch session deletion via POST /tree/sessions/remove-many
Kernel half of session batch delete. The handler validates the whole batch up front (bad path -> 400, empty -> 400), refuses a running session (409), a leased transcript (409), and a path outside the adopted workspace (403), then erases all guarded sessions.

No desktop UI in this change; the management view lands in a follow-up PR.

Go tests cover the handler, including two new edge cases:
- a path inside the session dir but belonging to another project (refused wholesale, 403, the in-workspace file left untouched);
- a running session in the middle of the list (refused with 409, nothing erased before the refusal).

Cuts the release-note fragment to one sentence per line and drops the
self-reference, so each line stays within the 200-column limit.
2026-10-29 21:02:04 +08:00
1012905 ba95fc74f1 test(desktop): the settings search is measured without the storage tally
The settings pane schedules a tally of the disk usage a beat after it opens,
and that walk reads the port. The search test measures the port as it stood
before the search, and reads it again afterwards to prove the search read
nothing. When the two moments fell more than a beat apart — which the full
suite's load makes likely — the tally landed in between and the test failed
on a read the search never asked for.

Seeding the tally before the pane opens means no walk is scheduled at all, so
the search is measured against the port as it stood, with nothing else reading
it on the way past.

The failing assertion is unchanged: the search still has to read nothing.
2026-10-29 20:40:20 +08:00
YHH 1da925d0f9 Merge pull request #14790 from esengine/fx/shortcut-cards-order
fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page
2026-10-29 05:14:47 -07:00
esengine eaf5329101 fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page 2026-10-29 04:59:32 -07:00
YHH dc659c7509 Merge pull request #14788 from esengine/fx/full-installer-link
fix(studio): the full-installer link after a failed update opens the system browser
2026-10-29 04:48:06 -07:00
esengine 310567fd08 fix(studio): the full-installer link after a failed update opens the system browser 2026-10-29 04:31:49 -07:00
YHH 77059988f8 Merge pull request #14780 from lancy69/docs/14603-cross-project-dispatch
docs: explain cross-project task dispatch
2026-10-29 04:23:39 -07:00
YHH 4b87d87883 Merge pull request #14774 from kevin9327/fix/worktree-stderr-rune-boundary
fix(worktree): cut git stderr in error messages on a rune boundary
2026-10-29 03:54:40 -07:00
Lancy b8997b34eb docs: explain cross-project task dispatch 2026-10-29 18:38:40 +08:00
YHH 3c6ad78273 Merge pull request #14773 from kevin9327/fix/recovery-task-summary-runes
fix(control): bound the fallback recovery task summary by runes
2026-10-29 03:23:41 -07:00
kevin 2cbd60e326 fix(worktree): cut git stderr in error messages on a rune boundary
stderrSuffix capped git's stderr at 500 bytes with a plain slice, so a localized
(multi-byte) git message was split mid-character and the error ended in invalid
UTF-8. Back the cut up to a rune start.
2026-10-29 19:00:58 +09:00
YHH 7b41ec8a08 Merge pull request #14769 from chuiwenwei/test/every-aggregate-field-declares-its-provenance
test(stats): every aggregate field declares its provenance
2026-10-29 02:40:34 -07:00
kevin 9be610d41d fix(control): bound the fallback recovery task summary by runes 2026-10-29 18:31:57 +09:00
YHH d16063929c Merge pull request #14726 from SivanCola/codex/worktree-apply-ancestor-boundary-14025
fix(worktree): guard apply ancestors and confine deletions / 防止父目录链接重定向隔离结果删除
2026-10-29 01:53:20 -07:00
YHH 0ab4329305 Merge pull request #14748 from lancy69/feat/4262-line-code-references
feat(control): support precise @path:start-end file references
2026-10-29 01:51:26 -07:00
YHH 692a46d8d3 Merge pull request #14768 from jf200s/fix/fork-inherit-compaction
fix(control): a fork inherits its source's context projection
2026-10-29 01:50:05 -07:00
YHH 8066440bb7 Merge pull request #14749 from lancy69/fix/14738-stale-mcp-removal
fix(control): remove stale MCP entries without losing source authority
2026-10-29 01:48:35 -07:00
YHH aa09229ab2 Merge pull request #14750 from lancy69/feat/11915-running-message-default
feat(studio): choose steer or follow-up as the running-message default
2026-10-29 01:47:11 -07:00
Lancy 87d59a1816 feat(control): resolve precise line-range file references 2026-10-29 15:03:12 +08:00
Lancy f98edeec7b fix(control): reconcile MCP entries after their source disappears 2026-10-29 14:55:25 +08:00
Lancy cab93a8b02 feat(studio): choose default intent for messages sent mid-run 2026-10-29 14:46:40 +08:00
F 2b517203be docs(release-notes): name the fragment after #14768
Refs #14768
2026-10-29 14:39:31 +08:00
Will 08a1288603 test(stats): every aggregate field declares its provenance 2026-10-29 14:38:30 +08:00
F e5eb7cbee2 docs(release-notes): point the fragment at the rebased commit
Refs 02bf796dc
2026-10-29 14:35:54 +08:00
F d5db1cc446 docs(release-notes): a fork keeps its source's compaction
Refs 4566c19b8
2026-10-29 14:34:49 +08:00
F 02bf796dcc fix(control): a fork inherits its source's context projection
A conversation branched from a compacted session reopened on the full
history. createBranchPrefix copied the transcript, the branch metadata and
the checkpoints but never the projection sidecar, and resumeInto binds with
loadSidecar=true, so the fork found no sidecar and visibleByFullScan handed
the model the whole transcript. Recovery forks and 1.x continuations already
go through InheritCompactionState; the user-facing branch path never called it.

BranchAt now inherits inside the branch's own save window, next to the
checkpoint copy, so a branch is never published with a fold it cannot read.
Controller.Branch, which swaps panes in place, binds with loadSidecar=true so
the same-pane path loads what it just inherited.

The inherit helper keeps its fail-soft contract: no source sidecar, a corrupt
or unsupported one, a projection whose covered prefix reaches past the fork
point, and a failed write all leave the fork on the full history. Recall
budget, blocked fields and a non-applied receipt belong to the source
session's runtime generation, so the copy clears them; the projection,
generation, lineage key and an applied receipt still travel.
2026-10-29 14:34:49 +08:00
SivanCola 19c7b76de1 fix(worktree): reject redirected apply ancestors and confine deletion
Problem: An isolated candidate deletion could follow a source directory link
and remove a file outside the repository while reporting success.

Root cause: Leaf comparison followed ancestor links and publication deleted
through an ordinary pathname, without an ancestor precondition or held root.

Fix: Check ancestors before path-by-path leaf hashing and before publishing
deletions, and delete through an open repository root. Preserve whole-workspace
file-to-directory conversion and the Store's existing same-ID conflict retry.
Extend the existing Windows isolation inventory with junction regressions.

Verification: Final root inventory covers 240 packages once with 24324 named
passes and 215 existing skips. Both required package commands exit zero.
Complete worktree/isolation race has 242 passes and no skips. The final focused
13 names pass; the exact preceding owner fails 9 events with 4 controls.
Vet, pinned lint, check, format, workflow and release-note gates pass. Windows
and Linux amd64 compile checks pass; native hosted verification is pending.
2026-10-28 17:00:26 +08:00
Will d803f11506 fix(migrate): an unreadable source is not an empty one
The implicit legacy-session import passed no report, so a ReadDir failure
became a no-op skip and returned (0, nil). /migrate then told a user whose
legacy sessions directory existed but could not be read that there was
nothing to migrate.

- sessionstore: return a typed LegacySourceError (a SkippedSession plus the
  cause) from the implicit path; an absent directory stays the silent no-op.
  The explicit --from path keeps reporting through its LegacyReport.
- migration: /migrate names the directory and the read failure; the summary
  counts it as a warning instead of "no legacy data needed migration".
- boot: unchanged, stays quiet — the boot caller is non-verbose, so the new
  error is recorded but not emitted, exactly like every other failed import.
- markers: the failure returns before the marker write, so a source that was
  never read is never recorded as imported; a repaired source imports and
  stamps on the retry.
2026-10-28 14:55:17 +08:00
SivanColaandesengine ac956885c1 fix(deps): sync upstream x/net security update
Problem: The govulncheck job on #14676 reports GO-2026-6860 through
the HTML parser used by the price-checking tool.

Root cause: This branch still selects golang.org/x/net v0.61.0, although
upstream #14685 has already upgraded both Go modules to the fixed version.

Fix: Apply upstream commit 80ebd88bf7
unchanged, selecting x/net v0.62.0 in the root and desktop modules.
No workflow bypasses or suppression rules are introduced.

Verification: govulncheck v1.9.0 with Go 1.26.10 exits successfully for
both modules with zero reachable vulnerabilities. Desktop build, network
and environment package tests, full builtin/boot and pricecheck tests,
go vet, make lint, make check and whitespace checks pass. The staged
four-file patch is byte-identical to the upstream commit.

Co-authored-by: esengine <[email protected]>
2026-10-28 14:23:41 +08:00
SivanCola 286106c0e7 docs(boot): disclose environment snapshot prefix correction
Problem: Release notes did not describe the snapshot ownership fix or its
possible one-time cache-prefix change for previously affected sessions.
Root cause: The impact was documented only in the PR and user guide.
Fix: Add Chinese and English unreleased entries preserving the first-request
cache-reuse caveat requested during review.
Verification: Release-note rendering/reference tests (12) and make check
pass; the staged diff contains only the two-line documentation fragment.
2026-10-28 11:00:51 +08:00
SivanCola f181b32fcc fix(boot): preserve each data home's environment snapshot
Problem: A rebuild can render another data home's environment probe answer
instead of its own still-valid persisted prefix, invalidating compaction.

Root cause: Process-cache lookup preceded durable snapshot lookup, while
shared-result adoption left existing snapshots untouched and did not merge
transient failures against previous successful observations.

Fix: Prioritize fresh trusted snapshots and merge/persist shared refreshes
at the environment owner. Preserve the snapshot format and trust checks.
Keep this prefix change separate from the remote-project UI as requested
in the review of #14615.

Verification: All 219 root Go packages with tests passed on this independent
branch, including full boot; environment race, go vet, make lint, formatting
and diff checks passed. Public-probe and cross-process boot regressions fail
with the original production implementation and pass with this fix. The
split patches reconstruct the combined source with an identical Git tree.
2026-10-28 04:21:27 +08:00
imcreazy 4f915fe504 ci(android): build the APK on a tag and attach it to the release 2026-10-26 01:39:33 +08:00
201 changed files with 12673 additions and 3311 deletions

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
name: Android
# The Android shell under `android/` ships from the same tag the desktop line
# publishes, and lands on the same release page.
#
# It is a separate workflow rather than a job inside release-studio.yml for two
# reasons. The toolchains share nothing — Go, the Android SDK, a Gradle wrapper —
# so a runner carrying both would install three toolchains to build one artifact.
# And `signing-contract` pins which workflows may reach the SignPath credentials;
# this one never touches them, and keeping it out of that graph is what keeps the
# fingerprint stable.
#
# A failure here does not hold back the desktop release: the two are independent
# by construction, and an APK is worth less than the desktop builds it sits
# beside.
on:
push:
tags: ['studio-v*']
workflow_dispatch:
inputs:
tag:
description: "Existing studio tag to build for and attach to (for example studio-v2.51.0)"
required: false
type: string
ref:
description: "A branch to build as a trial run of this workflow; nothing is attached to any release"
required: false
type: string
permissions:
contents: write # upload the APK to the release the tag belongs to
concurrency:
group: android-${{ inputs.tag || inputs.ref || github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
name: resolve the tag
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
candidate: ${{ steps.release.outputs.candidate }}
publish: ${{ steps.release.outputs.publish }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
# The same tag grammar release-studio.yml enforces, spelled the same way:
# two workflows that disagree about which tags name a release would publish
# one artifact for a tag the other refused.
- name: Resolve tag and version
id: release
env:
IN_TAG: ${{ inputs.tag }}
IN_REF: ${{ inputs.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
# A trial run builds a branch as a throwaway version and publishes nothing.
if [ -n "$IN_REF" ] && [ -z "$IN_TAG" ]; then
sha="$(git rev-parse --verify "origin/$IN_REF^{commit}")"
{ echo "tag="; echo "version=v0.0.0-dev.${sha:0:7}"; echo "sha=$sha"
echo "candidate=false"; echo "publish=false"; } >> "$GITHUB_OUTPUT"
echo "trial build of $IN_REF at $sha"
exit 0
fi
tag="${IN_TAG:-$REF_NAME}"
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
exit 1
fi
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
if ! git merge-base --is-ancestor "$sha" origin/studio; then
echo "::error::$tag is not on studio history"
exit 1
fi
version="${tag#studio-}"
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
fi
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
echo "candidate=$candidate"; echo "publish=true"; } >> "$GITHUB_OUTPUT"
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
build:
name: build the APK
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: read
# `publish` decides whether to attach anything, and the only honest answer to
# "will this APK install" is whether a key was restored.
outputs:
signed: ${{ steps.key.outputs.signed }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
# pnpm first: setup-node's pnpm cache needs the executable to exist.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
with:
version: 10
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: "21"
cache: gradle
# The SDK is the one thing nothing sets for you, per android/README.md. The
# runner image carries one; this asserts the two pieces the build names
# rather than re-installing them, so a missing package fails here by name
# instead of surfacing later as an unresolved compileSdk.
- name: Provide the Android SDK
run: |
if [ -z "${ANDROID_HOME:-}" ]; then
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
exit 1
fi
for piece in "platforms/android-35" "build-tools/35.0.0"; do
if [ ! -d "$ANDROID_HOME/$piece" ]; then
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
exit 1
fi
done
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
# A key that exists signs; a key that does not leaves the artifact
# unsigned, which is what a release with no key has always produced. The
# decision is the repository's, not this workflow's: it reads the four
# secrets android/README.md already names.
- name: Restore the signing key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "no signing key configured: the APK will be unsigned"
exit 0
fi
mkdir -p android/keystore
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
{
echo "storeFile=keystore/release.jks"
echo "storePassword=${STORE_PASSWORD}"
echo "keyAlias=${KEY_ALIAS}"
# PKCS#12 keystores use one password for both; only write the second
# when it is genuinely a different fact.
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
} > android/keystore/keystore.properties
chmod 600 android/keystore/*
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "signing key restored"
- name: Build the kernel and the page
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
export REASONIX_APP_VERSION="${VERSION#v}"
bash ./scripts/build-kernel.sh
bash ./scripts/sync-page.sh
# The one end-to-end check that needs no phone: the kernel the shell starts,
# serving the page the APK carries. Running it here means an APK that would
# open blank fails the build instead of the install.
- name: Verify the packaged page
working-directory: android
run: |
(cd .. && go build -o reasonix-verify ./cmd/reasonix)
KERNEL_BIN="$PWD/../reasonix-verify" bash ./scripts/verify-page.sh
- name: Assemble
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
# The version comes from the tag, so the About screen and the update
# check name this release. versionCode keeps its existing shape, which
# is why the build digit stays 1: it is a single decimal place, and a
# number that grows past it would collide with the next patch.
./gradlew assembleRelease --no-daemon "-PreasonixVersion=${VERSION#v}"
- name: Name the artifact
id: apk
working-directory: android/app/build/outputs/apk/release
run: |
src="$(ls ./*.apk | head -n 1)"
if [ -z "$src" ]; then
echo "::error::assembleRelease produced no APK"
exit 1
fi
case "$src" in
*-unsigned.apk) name="ReasonixMobile-${{ needs.resolve.outputs.version }}-unsigned.apk" ;;
*) name="ReasonixMobile-${{ needs.resolve.outputs.version }}.apk" ;;
esac
mv "$src" "$name"
sha256sum "$name" > "$name.sha256"
{
echo "path=$PWD/$name"
echo "name=$name"
} >> "$GITHUB_OUTPUT"
ls -l "$name" "$name.sha256"
# An unsigned package is not a lesser APK, it is an unusable one: Android's
# package manager rejects it outright rather than reporting a signature
# mismatch. Say so where the run summary can be read, instead of letting a
# green build imply a shippable artifact.
- name: Say what an unsigned build means
if: steps.key.outputs.signed != 'true'
run: |
echo "::warning::no signing key configured, so this APK is unsigned. Android refuses to install an unsigned package, so the release will carry no APK; the file stays a workflow artifact for whoever signs it."
{
echo "### No APK on this release"
echo ""
echo "The build produced an unsigned APK, which Android refuses to install."
echo "It is attached to this run as a workflow artifact only. Add the four"
echo "signing secrets to publish one."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v8
with:
name: android-apk
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/apk/release/*.sha256
if-no-files-found: error
publish:
name: attach the APK
needs: [resolve, build]
# Nothing to attach unless the build was signed. Skipping is the honest
# outcome: a release page with an APK nobody can install is worse than a
# release page without one.
if: needs.build.outputs.signed == 'true' && needs.resolve.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: android-apk
path: dist
# The release itself belongs to release-studio.yml, which creates it and
# owns its notes. This waits for it rather than creating a second one: two
# jobs racing to define a release is how a page ends up with the wrong body.
- name: Attach to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
REPO: ${{ github.repository }}
run: |
# 90 minutes: on the last Studio release the page appeared about 40
# minutes after the tag run started, and the APK build spends part of
# that before this wait begins.
for _ in $(seq 1 360); do
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
gh release upload "$TAG" dist/*.apk dist/*.sha256 --repo "$REPO" --clobber
echo "attached to $TAG"
exit 0
fi
sleep 15
done
echo "::error::release $TAG did not appear within 90 minutes; the APK was built but not published"
exit 1
+32 -6
View File
@@ -318,6 +318,32 @@ jobs:
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
' "$RUNNER_TEMP/isolation-owners.jsonl"
jq -se '
. as $events |
{"reasonix/internal/platform/worktree": [
"TestApplyPathsRejectsRedirectedAncestors",
"TestApplyPathsRejectsRedirectedAncestors/outside_delete",
"TestApplyPathsRejectsRedirectedAncestors/inside_delete",
"TestApplyPathsRejectsRedirectedAncestors/dangling_delete",
"TestApplyPathsRejectsRedirectedAncestors/outside_write",
"TestApplyPathsRejectsRedirectedAncestors/inside_write",
"TestApplyPathsRejectsRedirectedAncestors/regular_parent",
"TestApplyPathsRejectsRedirectedAncestors/unchanged",
"TestApplyPathsRejectsRedirectedAncestors/missing_creation",
"TestWriteChangesRejectsRedirectedDeletion",
"TestWriteChangesUnlinksLeafDirectoryLink",
"TestWholeWorkspaceApplyPreservesFileToDirectoryTransition"],
"reasonix/internal/runtime/isolation": [
"TestApplyToolRetainsResultAfterAncestorReplacement"]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass") and
all(.value[]; . as $test |
[ $events[] | select(.Package == $package.key and .Test == $test and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass"))
' "$RUNNER_TEMP/isolation-owners.jsonl"
- name: upload Windows isolation test inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
@@ -983,12 +1009,12 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite uses the same 18m package timeout as Windows smoke and a
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
# the failed tests' output and the 15 slowest boot tests.
# Full push suite: a 36m package timeout inside a 45m step budget. -count=1
# as at the smoke step. A failure prints the failed tests' output and the 15
# slowest boot tests.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 35
timeout-minutes: 45
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -999,9 +1025,9 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
shell: bash
run: |
# internal/assembly/boot alone takes 14-15 minutes on a Windows runner;
# internal/assembly/boot alone can take over 25 minutes on a Windows runner;
# the package limit leaves headroom inside the step limit above.
go test -count=1 -p 4 -timeout=28m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
go test -count=1 -p 4 -timeout=36m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (full) failure detail
+13
View File
@@ -169,6 +169,19 @@ Every package ships with a `.minisig` signature, and the release carries
The same release also carries the 2.x `reasonix` CLI as archives for
`darwin|linux|windows × amd64|arm64`.
The Android build ships on that release as `ReasonixMobile-<version>.apk`
(`arm64`, Android 8.0 or newer). The kernel and the page run on the phone, and
the agent reaches your servers over SSH; pairing with a computer is done by
scanning the code it shows.
The APK is signed with the Android release key rather than minisign, so it
carries a `.sha256` and no `.minisig`.
When that key is absent the build is named
`ReasonixMobile-<version>-unsigned.apk`. Android refuses to install an unsigned
package, so those builds stay a workflow artifact for whoever wants to sign one,
and the release carries no APK at all.
### Reasonix 1.x (stable)
The 1.x CLI installs through npm on any supported platform, or Homebrew on
+9
View File
@@ -156,6 +156,15 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
安卓版在同一个发布页上,文件名是 `ReasonixMobile-<版本>.apk`(`arm64`,Android 8.0 及以上)。
这个 APK 用 Android 发布密钥签名,不走 minisign,因此只有一个 `.sha256`,没有 `.minisig`。
该密钥缺失时产物名为 `ReasonixMobile-<版本>-unsigned.apk`。Android 不接受未签名的包,
这类构建只留作 workflow 产物供自行签名,发布页上不会挂 APK。
内核与界面都跑在手机上,Agent 通过 SSH 连接你自己的服务器;与电脑配对时扫它显示的二维码。
### Reasonix 1.x(稳定版)
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
+21
View File
@@ -20,10 +20,25 @@ plugins {
}
// One version, read by the About screen and the update check.
//
// A published build takes its version from the tag it was cut for, so the About
// screen and the update check name that release rather than the number this file
// was last edited to. Without the property — a local build — the numbers below
// are the answer.
def publishedVersion = findProperty("reasonixVersion") as String
def versionMajor = 2
def versionMinor = 0
def versionPatch = 0
def versionBuild = 1
if (publishedVersion) {
def parsed = publishedVersion =~ /^(\d+)\.(\d+)\.(\d+)/
if (!parsed.find()) {
throw new GradleException("reasonixVersion must start with MAJOR.MINOR.PATCH, got: ${publishedVersion}")
}
versionMajor = parsed.group(1) as int
versionMinor = parsed.group(2) as int
versionPatch = parsed.group(3) as int
}
// Where the two build inputs are expected. Both are produced from the checkout
// beside this module, by the scripts named in each message.
@@ -53,6 +68,12 @@ android {
// what `MainActivity` feeds real inset values for, since a WebView does
// not implement `env(safe-area-inset-*)`.
targetSdk 35
// versionCode must strictly increase, because Android refuses an update
// whose versionCode is not higher. The build digit is a single decimal
// place, so the scheme collides once a patch reaches 10: 2.55.10 and
// 2.56.0 both come out as 25601. Patch releases above 9 are not expected
// on Studio, and widening the digit now would renumber every build, so
// the constraint is recorded rather than fixed.
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -47,7 +47,7 @@ app_gradle="$android/app/build.gradle"
version_field() {
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
}
app_version="$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)"
app_version="${REASONIX_APP_VERSION:-$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)}"
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ldflags="-s -w"
ldflags="$ldflags -X main.version=v$app_version"
+2 -1
View File
@@ -32,7 +32,8 @@ fi
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
printf '%s\n' "$token" > "$work/home/remote/launch.token"
# The kernel refuses a token file other users can read.
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
export REASONIX_HOME="$work/home"
export HOME="$work/home"
+19
View File
@@ -72,6 +72,25 @@ go run ./cmd/reasonix serve # from the repository root
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
```
## Messages sent during a run
**Settings → Appearance → Keyboard shortcuts → Messages sent while running** chooses the default:
- **Steer at the next tool boundary** (default) adds guidance to the current run.
- **Send after the whole run** queues a new turn until the active run finishes.
The choice is local to this device's browser profile, applies across projects,
and survives reloads. It does not change idle sends or messages already queued.
Skill invocations continue to use follow-up turns.
The queue still supports hold/resume, editing, and taking a message back into
the composer. **Send now** overrides the wait for a selected message by stopping
the current response and starting that message as a new turn; it is unavailable
while the queue is held.
Guidance not delivered before a run ends still becomes a follow-up through the
kernel's existing queue.
## Global show/hide shortcut
In the desktop shell, **Settings → Appearance → Global show / hide shortcut**
+2
View File
@@ -16,8 +16,10 @@
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"shortcut-live": "electron test/globalshortcut_live.js",
"theme-live": "electron test/theme_live.js",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_live.js",
"pick-live": "electron test/elementpick_live.js",
"browser-fit-pages": "electron test/browser_fit_pages.js",
"sleep-live": "electron test/sleep_live.js",
"image-live": "electron test/image_live.js",
+204 -1
View File
@@ -3,6 +3,7 @@ const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typed } = require("./browserguard");
const { ARM, STOP } = require("./elementpick");
const { InputWake } = require("./inputwake");
const { KeyboardKeeper } = require("./keyboard");
@@ -23,6 +24,26 @@ const PAGE_RADIUS = 8;
// How many pages may stay asleep at once; past this the oldest is buried.
const SLEPT_LIMIT = 18;
// How long a pick waits for the person. Past it the page is left as it was
// found: a picker left armed swallows every click on the page, so the wait is
// a minute and a half, not an afternoon.
const PICK_LIMIT_MS = 90_000;
// A page's own scripts live in world 0; the picker answers from its own.
const PICK_WORLD = 999;
// The page wrote the answer; these repeat the page script's own limits because
// the shell cannot trust it to have run them.
const TEXT_CLIP = 4000;
const NEARBY_CLIP = 300;
const MARKUP_CLIP = 6000;
const VALUE_CLIP = 500;
const ATTR_LIMIT = 64;
const STYLE_CLIP = 200;
const ELEMENT_STRINGS = ["pageUrl", "pageTitle", "tagName", "role", "accessibleName", "selector", "xpath", "text", "nearbyText", "htmlExcerpt"];
const STYLE_KEYS = ["backgroundColor", "color", "display", "fontFamily", "fontSize", "fontWeight"];
const SELECTOR_BASIS = ["testid", "aria-label", "id", "class"];
const CANCEL_REASONS = ["escape", "dismissed", "timeout", "hidden", "navigation", "gone"];
const clip = (value, limit) => (value.length > limit ? value.slice(0, limit) : value);
// A command that switches a CDP domain on. The kernel sends these once at
// attach, and a sleep throws away the session they were said on, so they are
// kept per page and said again to every session a wake builds.
@@ -222,6 +243,17 @@ class BrowserViews {
entry.onEvent(method, params);
});
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
// The picker's key belongs to the page while the page holds the keyboard:
// a press that went there never reaches the window's own page, so it is
// taken here and the picker is started where the person is looking.
contents.on("before-input-event", (event, input) => {
if (!input || input.type !== "keyDown" || input.isAutoRepeat || !input.key || !input.shift) return;
if (String(input.key).toLowerCase() !== "c") return;
if (process.platform === "darwin" ? !input.meta : !input.control) return;
if (entry.targetId !== this.shown) return;
event.preventDefault();
this.send("browser:pick-shortcut", { targetId: entry.targetId });
});
this.watchLoads(entry.targetId, entry);
const gone = () => {
// A sleep closes the contents itself, so a death under it is the sleep
@@ -374,7 +406,7 @@ class BrowserViews {
// at it, a navigation or a settle is pending, a certificate or a dialog waits,
// it is sounding, or DevTools is open. Downloads need no guard here.
maySleep(entry) {
if (entry.targetId === this.shown || entry.returning || entry.certificate || entry.dialog) return false;
if (entry.targetId === this.shown || entry.picking || entry.returning || entry.certificate || entry.dialog) return false;
const contents = entry.view && entry.view.webContents;
if (typeof contents?.isCurrentlyAudible === "function" && contents.isCurrentlyAudible()) return false;
if (typeof contents?.isDevToolsOpened === "function" && contents.isDevToolsOpened()) return false;
@@ -569,6 +601,13 @@ class BrowserViews {
for (const entry of this.entries.values()) {
if (entry.view) this.putAway(entry.view, entry);
}
// A pick behind a page nobody can see cannot be used: the person is looking
// at a picture, so the pick is settled instead of swallowing clicks unseen.
for (const entry of this.entries.values()) {
if (!entry.picking) continue;
entry.picking.reason = "hidden";
void this.cancelPick(entry.targetId);
}
}
putAway(view, entry) {
@@ -608,6 +647,170 @@ class BrowserViews {
}
}
// pick arms one page's element picker and answers what the person pointed
// at, or { cancelled: true } when they pressed Escape, navigated away, or
// left it longer than a pick may wait. The highlight and the hover card are
// drawn inside the page: a native view sits above anything this window's own
// page could draw over it.
// The page wrote the answer, so the shell decides what an answer is: fields
// from an allow-list, strings clipped to the page script's own limits, and a
// shape it cannot read treated as gone.
clean(answer) {
const gone = { cancelled: true, reason: "gone" };
if (!answer || typeof answer !== "object") return gone;
if (answer.cancelled) {
const reason = CANCEL_REASONS.includes(answer.reason) ? answer.reason : "gone";
return { cancelled: true, reason };
}
const el = answer.element;
if (!el || typeof el !== "object") return gone;
const out = {};
for (const field of ELEMENT_STRINGS) {
const value = el[field];
if (typeof value !== "string") continue;
out[field] = clip(value, field === "htmlExcerpt" ? MARKUP_CLIP : field === "nearbyText" ? NEARBY_CLIP : TEXT_CLIP);
}
if (SELECTOR_BASIS.includes(el.selectorBasis)) out.selectorBasis = el.selectorBasis;
if (el.attributes && typeof el.attributes === "object") {
const attributes = {};
for (const [key, value] of Object.entries(el.attributes)) {
if (Object.keys(attributes).length >= ATTR_LIMIT) break;
if (typeof value === "string") attributes[clip(key, VALUE_CLIP)] = clip(value, VALUE_CLIP);
}
out.attributes = attributes;
}
if (Number.isFinite(el.capturedAt)) out.capturedAt = el.capturedAt;
if (el.rect && typeof el.rect === "object") {
const { x, y, width, height } = el.rect;
if ([x, y, width, height].every(Number.isFinite)) out.rect = { x, y, width, height };
}
if (el.style && typeof el.style === "object") {
out.style = {};
for (const key of STYLE_KEYS) if (typeof el.style[key] === "string") out.style[key] = clip(el.style[key], STYLE_CLIP);
}
if (!out.selector || !out.tagName) return gone;
return { cancelled: false, element: out };
}
async pick(targetId, labels) {
const entry = this.entries.get(targetId);
if (!entry) return { cancelled: true, reason: "gone" };
// A pick draws inside the page that is on screen; a page that is not shown
// would pick behind the one the person is looking at.
if (entry.targetId !== this.shown) return { cancelled: true, reason: "hidden" };
if (entry.slept) await this.wake(entry).catch(() => {});
const contents = entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed()) return { cancelled: true, reason: "gone" };
// A second pick on the same page replaces the first: one picker, one
// overlay, and the older call answers as cancelled.
if (entry.picking) {
entry.picking.reason = "dismissed";
await this.cancelPick(targetId).catch(() => {});
}
// Why the pick ends when it ends without an element: Escape and a dismissal
// come from the page, the other three from here. The person is told which,
// so "nothing happened" is never the answer.
const picking = (entry.picking = { reason: "" });
const timer = setTimeout(() => {
picking.reason = "timeout";
void this.cancelPick(targetId);
}, PICK_LIMIT_MS);
if (typeof timer.unref === "function") timer.unref();
try {
const answer = this.clean(await contents.executeJavaScript(ARM(labels), false));
if (answer.cancelled) return picking.reason ? { cancelled: true, reason: picking.reason } : answer;
// The page cannot invent an element: the shell re-finds the selector in
// its own world before the answer is trusted.
if (!(await this.lands(contents, answer.element))) return { cancelled: true, reason: "gone" };
// The picture is what the person saw: a complaint about how something
// looks is answered by the element, not only by its markup.
const shot = await this.shot(entry, answer.element.rect).catch(() => "");
if (shot) answer.shot = shot;
return answer;
} catch {
// The page navigated, reloaded, slept, or was closed under the pick.
return { cancelled: true, reason: picking.reason || "navigation" };
} finally {
if (entry.picking === picking) entry.picking = null;
clearTimeout(timer);
}
}
// lands asks the DOM from the shell's own world whether a selector still
// names an element of the tag the pick reported. A page script cannot answer
// this one in the page's place.
async lands(contents, element) {
if (!element || !element.selector || !element.tagName) return false;
const tag = await contents
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
{
code: `(() => {
const el = document.querySelector(${JSON.stringify(element.selector)});
return el ? el.tagName.toLowerCase() : "";
})()`,
},
])
.catch(() => "");
return tag === String(element.tagName).toLowerCase();
}
// shot captures the element as the view drew it. The page's rect is in CSS
// pixels and the view may be fitted, so the scale is applied and the result
// is kept inside the view; an element scrolled out of sight has no picture.
async shot(entry, rect) {
const contents = entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed() || !rect) return "";
const scale = entry.scale || 1;
const bounds = entry.view.getBounds();
const at = {
x: Math.max(0, Math.round(rect.x * scale)),
y: Math.max(0, Math.round(rect.y * scale)),
width: Math.round(rect.width * scale),
height: Math.round(rect.height * scale),
};
at.width = Math.min(at.width, bounds.width - at.x);
at.height = Math.min(at.height, bounds.height - at.y);
if (at.width < 1 || at.height < 1) return "";
const image = await contents.capturePage(at);
return image.isEmpty() ? "" : image.toDataURL();
}
// verify re-finds a selector after the page changed, so a person can see
// whether an edit landed where the pick pointed.
async verify(targetId, selector) {
const entry = this.entries.get(targetId);
if (!entry || !selector) return { found: false };
const contents = entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed()) return { found: false };
const at = await contents
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
{
code: `(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return { found: false };
const r = el.getBoundingClientRect();
return { found: true, tag: el.tagName.toLowerCase(), rect: { x: r.x, y: r.y, width: r.width, height: r.height } };
})()`,
},
])
.catch(() => ({ found: false }));
if (!at || !at.found || !at.rect) return { found: false };
const shot = await this.shot(entry, at.rect).catch(() => "");
return { found: true, tag: at.tag, rect: at.rect, shot };
}
// cancelPick disarms the picker, which settles a pick still waiting on the
// page. A page that has gone answers nothing, which reads as cancelled.
cancelPick(targetId) {
const entry = this.entries.get(targetId);
const contents = entry && entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed()) return Promise.resolve(false);
return contents.executeJavaScript(STOP, false).then(
() => true,
() => false,
);
}
// navigate starts loading what the person typed and answers "", or the code
// of why the window will not: "scheme" or "network_file".
navigate(targetId, address) {
+572
View File
@@ -0,0 +1,572 @@
"use strict";
// The page half of the element picker, kept as a function so it can be
// stringified into the page. The highlight and the hover card are drawn there
// because a native view sits above anything this window's own page draws.
//
// Nothing here may reach out of the function: the page runs it alone, with no
// module and no binding of this process to refer to.
function pagePicker(labels = {}) {
// The window owns the words and sends them in: the page only draws them.
const L = {
color: "Color",
background: "Background",
font: "Font",
frame: "Element inside an embedded frame — not supported",
shadow: "Element inside a shadow root — not supported",
...labels,
};
const KEY = "__reasonixElementPicker";
const CLIP = 4000;
// Any words of its own already say what the element is, in any language;
// only one that cannot speak — an icon, an image — is given the words
// around it. A Chinese heading is a sentence in a dozen characters, so the
// test is whether it has text at all, not how much.
const NEARBY_OWN = 2;
const NEARBY_LIMIT = 300;
let settlePick = null;
// The promise is the pick: it settles on the click, on Escape, or when the
// page takes the picker away from under it.
const answer = new Promise((done) => {
settlePick = done;
});
const previous = window[KEY];
if (previous) previous.stop();
const TAG_ROLES = {
A: "link",
BUTTON: "button",
IMG: "img",
NAV: "navigation",
MAIN: "main",
FORM: "form",
SELECT: "combobox",
TEXTAREA: "textbox",
H1: "heading",
H2: "heading",
H3: "heading",
H4: "heading",
H5: "heading",
H6: "heading",
};
const INPUT_ROLES = {
checkbox: "checkbox",
radio: "radio",
range: "slider",
button: "button",
submit: "button",
reset: "button",
};
const KEPT = new Set(["id", "class", "href", "src", "alt", "title", "name", "type", "placeholder", "data-testid"]);
// The roles an element can have while saying nothing to a reader by itself:
// an icon button, an image, a field. Context is for these. A decorative
// span has no role, and the page around it does not give it one.
const SPEAKING = new Set(["button", "link", "textbox", "checkbox", "radio", "slider", "combobox", "img"]);
const clip = (text, limit) => (text.length > limit ? text.slice(0, limit) : text);
const textOf = (node) => (node.innerText || node.textContent || "").trim();
// rgb()/rgba() as the page computed them, written the way a person reads a
// colour; a translucent one is left as computed so its alpha is not lost.
const hex = (css) => {
const match = /^rgba?\(([^)]+)\)$/.exec(css || "");
if (!match) return css || "";
const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number);
if (parts.length > 3 && parts[3] < 1) return css;
const [r, g, b] = parts;
return ("#" + [r, g, b].map((one) => Math.round(one).toString(16).padStart(2, "0")).join("")).toUpperCase();
};
const roleOf = (el) => {
const stated = el.getAttribute("role");
if (stated) return stated;
if (el.tagName === "INPUT") return INPUT_ROLES[el.type] || "textbox";
return TAG_ROLES[el.tagName] || "";
};
const nameOf = (el) => {
const ids = (el.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
if (ids.length) {
const text = ids
.map((id) => {
const node = document.getElementById(id);
return node ? textOf(node) : "";
})
.join(" ")
.trim();
if (text) return clip(text, 500);
}
for (const attr of ["aria-label", "alt", "title", "placeholder"]) {
const value = el.getAttribute(attr);
if (value && value.trim()) return clip(value.trim(), 500);
}
return clip(textOf(el), 500);
};
// What the page declares as an identity outlives what it builds for styling:
// a hash class is gone after the next deploy, a data-testid is not.
const attrValue = (value) => '"' + value.replace(/["\\]/g, "\\$&") + '"';
// A CSS path a person can paste, preferring the page's own identity over its
// styling. Falls back to tag.class with :nth-of-type where a sibling would
// answer the same, up to eight levels of parent.
const selectorOf = (el) => {
const testId = el.getAttribute("data-testid");
if (testId) return { selector: `[data-testid=${attrValue(testId)}]`, basis: "testid" };
const label = el.getAttribute("aria-label");
if (label) return { selector: `${el.tagName.toLowerCase()}[aria-label=${attrValue(label)}]`, basis: "aria-label" };
const own = el.getAttribute("id");
if (own) return { selector: "#" + CSS.escape(own), basis: "id" };
const parts = [];
let node = el;
for (let depth = 0; node && node.nodeType === 1 && depth < 8; depth++) {
const id = node.getAttribute("id");
if (id) {
parts.unshift("#" + CSS.escape(id));
break;
}
let part = node.tagName.toLowerCase();
for (const name of [...node.classList].filter(Boolean).slice(0, 3)) part += "." + CSS.escape(name);
const parent = node.parentElement;
if (parent) {
const same = [...parent.children].filter((child) => child.tagName === node.tagName);
if (same.length > 1) part += `:nth-of-type(${same.indexOf(node) + 1})`;
}
parts.unshift(part);
node = node.parentElement;
}
return { selector: parts.join(" > "), basis: "class" };
};
const xpathOf = (el) => {
const parts = [];
let node = el;
for (let depth = 0; node && node.nodeType === 1 && depth < 12; depth++) {
let index = 1;
for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
if (sib.tagName === node.tagName) index++;
}
parts.unshift(`${node.tagName.toLowerCase()}[${index}]`);
node = node.parentElement;
}
return "/" + parts.join("/");
};
// What the element says. A password field never hands over what was typed in
// it: the pick leaves with the fact that it was one, and nothing else.
const textFor = (el) => {
// An editable region holds what the person is writing right now.
if (el.isContentEditable) return "[masked editable text]";
if (el.tagName === "INPUT" && el.type === "password") return "[masked password input]";
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
return clip([el.getAttribute("aria-label"), el.getAttribute("placeholder"), el.getAttribute("name"), el.type].filter(Boolean).join(" "), CLIP);
}
return clip(textOf(el), CLIP);
};
// What surrounds an element, for the ones that cannot speak for themselves.
// An element with text of its own asks for nothing: the nearest article
// around a title is the whole page, which is not context but noise.
// What surrounds an element, for the ones that cannot speak for themselves.
// An element with text of its own asks for nothing, and neither does one
// with no role at all: a decorative mark is not made meaningful by the page
// around it, so the words there would be noise rather than context.
const nearbyFor = (el) => {
if (textOf(el).trim().length >= NEARBY_OWN) return "";
if (!SPEAKING.has(roleOf(el))) return "";
const near = el.closest("article, section, main, form, li, tr, dialog") || el.parentElement;
return near ? clip(textOf(near), NEARBY_LIMIT) : "";
};
// The element's own markup with its scripts and the values a person typed
// taken out, so what goes to the chat is structure, not their input.
const excerptFor = (el) => {
const clone = el.cloneNode(true);
for (const drop of clone.querySelectorAll("script, style, noscript, template")) drop.remove();
for (const field of clone.querySelectorAll("input")) field.removeAttribute("value");
if (clone.tagName === "INPUT") clone.removeAttribute("value");
for (const area of clone.querySelectorAll("textarea")) area.textContent = "";
if (clone.isContentEditable) clone.textContent = "";
return clip(clone.outerHTML, 6000);
};
const describe = (el) => {
const rect = el.getBoundingClientRect();
const computed = getComputedStyle(el);
const attributes = {};
for (const attr of el.attributes) {
const name = attr.name.toLowerCase();
if (name === "value") continue;
if (!KEPT.has(name) && !name.startsWith("aria-")) continue;
attributes[name] = clip(attr.value, 500);
}
const style = {
color: hex(computed.color),
display: computed.display,
fontFamily: computed.fontFamily,
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
};
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
style.backgroundColor = hex(computed.backgroundColor);
}
const path = selectorOf(el);
const info = {
pageUrl: location.href,
pageTitle: document.title,
tagName: el.tagName.toLowerCase(),
selector: path.selector,
xpath: xpathOf(el),
htmlExcerpt: excerptFor(el),
attributes,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style,
capturedAt: Date.now(),
};
// Which of the four the selector came from, so a reader can tell a stable
// identity from one that a deploy will change.
info.selectorBasis = path.basis;
// A field with no answer is left out rather than sent empty: "there is no
// role" and "the role is an empty string" are different answers, and the
// second is not one this page can give.
const role = roleOf(el);
if (role) info.role = role;
const name = nameOf(el);
if (name) info.accessibleName = name;
const own = textFor(el);
if (own) info.text = own;
const nearby = nearbyFor(el);
if (nearby) info.nearbyText = nearby;
return info;
};
const box = document.createElement("div");
box.dataset.reasonixPick = "box";
box.setAttribute("aria-hidden", "true");
box.style.cssText =
"position:fixed;z-index:2147483646;pointer-events:none;border:2px solid #3b82f6;" +
"border-radius:3px;background:rgba(59,130,246,.12);box-sizing:border-box";
const card = document.createElement("div");
card.dataset.reasonixPick = "card";
card.setAttribute("aria-hidden", "true");
card.style.cssText =
"position:fixed;z-index:2147483647;pointer-events:none;background:#1f2328;color:#e6edf3;" +
"border-radius:8px;padding:9px 12px;font:12px/1.6 ui-sans-serif,system-ui,sans-serif;" +
"box-shadow:0 8px 24px rgba(0,0,0,.28);min-width:190px;max-width:340px";
// One card line. The label is the quiet side, the value the loud one; both
// are set as text, never as markup, because the page chose both.
const line = (label, value, loud) => {
const row = document.createElement("div");
row.style.cssText = "display:flex;gap:12px;justify-content:space-between;align-items:baseline";
const key = document.createElement("span");
key.textContent = label;
key.style.cssText = `flex:none;color:${loud ? "#e6edf3" : "#8b949e"};font-weight:${loud ? 600 : 400}`;
const val = document.createElement("span");
val.textContent = value;
val.style.cssText =
"color:#e6edf3;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden;" +
"text-overflow:ellipsis;white-space:nowrap" + (loud ? ";font-weight:600" : "");
row.append(key, val);
card.append(row);
};
let target = null;
// The chain of children walked up from, so the down keys can walk back.
let descended = [];
// Shift-click keeps a set: the person is naming a region, not one node.
const chosen = [];
const marks = [];
let armed = true;
let listeners = [];
const clear = () => {
for (const [type, handler, capture] of listeners) document.removeEventListener(type, handler, capture);
listeners = [];
for (const { cover } of frameCovers) cover.remove();
frameCovers.length = 0;
for (const at of marks) at.remove();
marks.length = 0;
box.remove();
card.remove();
if (window[KEY] && window[KEY].stop === stop) delete window[KEY];
};
const settle = (result) => {
if (!armed) return;
armed = false;
clear();
settlePick(result);
};
const stop = (reason = "dismissed") => settle({ cancelled: true, reason });
// Where the card sits: under the element when the viewport has room for it,
// above otherwise, and never past either edge.
// A frame's own document takes the pointer, so the top document never hears a
// move over it. A cover over each frame keeps the picker's events coming and
// says why that area is not pickable instead of going quiet.
const frameCovers = [];
const positionCovers = () => {
for (const { cover, frame } of frameCovers) {
const rect = frame.getBoundingClientRect();
cover.style.left = rect.left + "px";
cover.style.top = rect.top + "px";
cover.style.width = rect.width + "px";
cover.style.height = rect.height + "px";
cover.style.display = rect.width > 0 && rect.height > 0 ? "block" : "none";
}
};
const coverFrames = () => {
for (const { cover } of frameCovers) cover.remove();
frameCovers.length = 0;
for (const frame of document.querySelectorAll("iframe, frame")) {
const cover = document.createElement("div");
cover.dataset.reasonixFrame = "1";
cover.style.cssText =
"position:fixed;z-index:2147483646;pointer-events:auto;background:rgba(59,130,246,0.08);border:1px dashed rgba(59,130,246,0.55);box-sizing:border-box";
cover.addEventListener("pointermove", () => show(frame), true);
cover.addEventListener("click", (event) => {
swallow(event);
show(frame);
}, true);
document.documentElement.append(cover);
frameCovers.push({ cover, frame });
}
positionCovers();
};
const place = () => {
if (!target) return;
const rect = target.getBoundingClientRect();
const width = Math.max(0, rect.width);
const height = Math.max(0, rect.height);
box.style.left = rect.left + "px";
box.style.top = rect.top + "px";
box.style.width = width + "px";
box.style.height = height + "px";
card.style.visibility = "hidden";
card.style.left = "0px";
card.style.top = "0px";
const size = card.getBoundingClientRect();
const below = rect.bottom + 8;
const top = below + size.height <= innerHeight ? below : Math.max(8, rect.top - size.height - 8);
const left = Math.min(Math.max(8, rect.left), Math.max(8, innerWidth - size.width - 8));
card.style.left = left + "px";
card.style.top = top + "px";
card.style.visibility = "visible";
};
// The chain from the document down to the element, short enough to read in
// the card: a person walking up with the arrow keys needs to see where they
// are, not the whole tree.
const ancestorsOf = (el) => {
const chain = [];
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
chain.unshift(node);
if (chain.length > 8) break;
}
return chain;
};
const crumbLabel = (el) => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "");
// A frame or a shadow host cannot be read from here: the picker only ever
// sees the host element, so it says so instead of handing over a useless
// answer that looks like the real thing.
const blockedOf = (el) => {
if (!el) return "";
if (el.tagName === "IFRAME" || el.tagName === "FRAME") return "iframe";
if (el.shadowRoot) return "shadow";
return "";
};
const show = (el) => {
target = el;
const rect = el.getBoundingClientRect();
const computed = getComputedStyle(el);
card.textContent = "";
const blocked = blockedOf(el);
if (blocked) {
const note = document.createElement("div");
note.textContent = blocked === "iframe" ? L.frame : L.shadow;
note.style.cssText = "max-width:260px;color:#e6edf3;font-size:12px;line-height:1.5";
card.append(note);
box.style.display = "block";
card.style.display = "block";
place();
return;
}
const crumb = document.createElement("div");
crumb.style.cssText = "display:flex;gap:4px;overflow:hidden;white-space:nowrap;margin-bottom:4px;color:#8b949e;font-size:11px";
const chain = ancestorsOf(el).slice(-5);
chain.forEach((node, index) => {
if (index) {
const sep = document.createElement("span");
sep.textContent = "›";
sep.style.opacity = "0.6";
crumb.append(sep);
}
const part = document.createElement("span");
part.textContent = crumbLabel(node);
if (node === el) {
part.style.color = "#e6edf3";
part.style.fontWeight = "600";
}
crumb.append(part);
});
card.append(crumb);
line(el.tagName.toLowerCase(), `${Math.round(rect.width)}×${Math.round(rect.height)}`, true);
line(L.color, hex(computed.color), false);
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
line(L.background, hex(computed.backgroundColor), false);
}
line(L.font, `${computed.fontSize} ${computed.fontFamily}`, false);
box.style.display = "block";
card.style.display = "block";
place();
};
document.documentElement.append(box, card);
coverFrames();
box.style.display = "none";
card.style.display = "none";
// Every gesture is taken from the page while the pick is armed: a click
// chooses an element instead of pressing a button, and no page handler hears
// it either. Escape is the way out that changes nothing.
const swallow = (event) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
let moveAt = null;
let moveFrame = 0;
const onMove = (event) => {
// One hit test per frame: a page with a heavy layout is not asked to answer
// a move it will not draw.
moveAt = event;
if (moveFrame) return;
moveFrame = requestAnimationFrame(() => {
moveFrame = 0;
if (!armed) return;
const at = moveAt;
moveAt = null;
if (!at) return;
const el = document.elementFromPoint(at.clientX, at.clientY);
if (!el || el === box || el === card) return;
if (el.dataset && el.dataset.reasonixFrame) return;
if (el === target) return;
descended = [];
show(el);
});
};
// A picked element keeps a mark with its order, so a set stays visible while
// the person keeps adding to it.
const mark = (el) => {
const rect = el.getBoundingClientRect();
const at = document.createElement("div");
at.dataset.reasonixPicked = "1";
at.setAttribute("aria-hidden", "true");
at.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;border:1px solid #22c55e;background:rgba(34,197,94,0.12);box-sizing:border-box;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px`;
const order = document.createElement("span");
order.textContent = String(marks.length + 1);
order.style.cssText = "position:absolute;top:-17px;left:-1px;background:#22c55e;color:#04170a;font:600 10px ui-sans-serif,system-ui,sans-serif;padding:1px 4px;border-radius:3px";
at.append(order);
document.documentElement.append(at);
marks.push(at);
};
const onPick = (event) => {
if (!armed) return;
swallow(event);
// The pointer may have climbed with the arrow keys: what is picked is the
// level the person is looking at, not what happens to be under the cursor.
try {
// A frame or shadow host is not an answer: the click leaves the pick
// armed while the card already says why.
if (target) {
if (blockedOf(target)) return;
if (event.shiftKey) {
chosen.push(target);
mark(target);
return;
}
return settle(chosen.length ? { cancelled: false, elements: [...chosen, target].map(describe) } : { cancelled: false, element: describe(target) });
}
const el = document.elementFromPoint(event.clientX, event.clientY);
if (!el) return settle({ cancelled: true });
if (blockedOf(el)) return;
if (event.shiftKey) {
chosen.push(el);
mark(el);
return;
}
settle(chosen.length ? { cancelled: false, elements: [...chosen, el].map(describe) } : { cancelled: false, element: describe(el) });
} catch (error) {
console.error("reasonix element pick failed", error);
settle({ cancelled: true, reason: "gone" });
}
};
const onKey = (event) => {
if (event.key === "Escape") {
swallow(event);
stop("escape");
return;
}
// Up walks to the parent and down back to the child it left, with the chain
// drawn in the card so neither move is a guess.
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
swallow(event);
const parent = target && target.parentElement;
if (parent && parent !== document.documentElement) {
descended.push(target);
show(parent);
}
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
swallow(event);
const child = descended.pop();
if (child && target && child.parentElement === target) show(child);
return;
}
// Every other key belongs to the picker as well: Enter on a focused button
// would run the page action a click is not allowed to run, and the pick is
// not using the page.
swallow(event);
};
const onScroll = () => {
place();
positionCovers();
};
for (const [type, handler, capture] of [
["pointerdown", swallow, true],
["pointerup", swallow, true],
["mousedown", swallow, true],
["mouseup", swallow, true],
["dblclick", swallow, true],
["contextmenu", swallow, true],
["click", onPick, true],
["pointermove", onMove, true],
["mousemove", onMove, true],
["keydown", onKey, true],
["scroll", onScroll, true],
["resize", onScroll, false],
]) {
document.addEventListener(type, handler, capture);
listeners.push([type, handler, capture]);
}
window[KEY] = { stop };
return answer;
}
// ARM runs the picker in the page's own world; the promise it answers with is
// what executeJavaScript returns, so a pick takes as long as the person does.
const ARM = (labels) => `(${pagePicker.toString()})(${JSON.stringify(labels || {})})`;
// STOP disarms whatever picker is armed, settling that pick as cancelled.
const STOP = `(() => { const picker = window.__reasonixElementPicker; if (picker) picker.stop(); })()`;
module.exports = { ARM, STOP };
+9 -6
View File
@@ -5,17 +5,20 @@
// compositor leaves uncovered at a fractional scale matches the app.
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
// "auto" and anything unrecognised follow the system, as the page does.
function groundFor(prefs, systemDark) {
const theme = prefs?.["rx-theme"];
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
function groundFor(prefs, systemDark, painted) {
const source = themeSourceFor(prefs, painted);
const dark = source === "system" ? systemDark : source === "dark";
return dark ? GROUND.dark : GROUND.light;
}
// The scheme the window frame takes. Windows draws a frameless window's border
// from it, so a dark page on a light system needs it set to stay borderless.
function themeSourceFor(prefs) {
const theme = prefs?.["rx-theme"];
function themeSourceFor(prefs, painted) {
const theme = painted?.mode ?? prefs?.["rx-theme"];
if (theme === "scheduled") {
const scheme = painted?.scheme ?? prefs?.["rx-theme-resolved"];
return scheme === "dark" || scheme === "light" ? scheme : "system";
}
return theme === "dark" || theme === "light" ? theme : "system";
}
+17 -3
View File
@@ -337,9 +337,12 @@ function fitted() {
}
const storedPrefs = () => loadPrefs(prefsFile(app.getPath("userData")));
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors);
let paintedTheme = null;
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors, paintedTheme);
const followTheme = () => {
nativeTheme.themeSource = themeSourceFor(storedPrefs());
const source = themeSourceFor(storedPrefs(), paintedTheme);
if (nativeTheme.themeSource !== source) nativeTheme.themeSource = source;
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
};
function createWindow() {
@@ -400,9 +403,11 @@ const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), a
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
installApplicationMenu(uiLang);
followTheme();
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
void powerGuard?.refresh();
browserViews?.setSleepAfterMs(pageSleepAfterMs(storedPrefs()));
}, paint => {
paintedTheme = paint;
followTheme();
});
nativeTheme.on("updated", () => {
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
@@ -446,6 +451,15 @@ ipcMain.handle("browser:control", (event, targetId, action) => {
ipcMain.handle("browser:navigate", (event, targetId, address) =>
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? "scheme") : "scheme",
);
ipcMain.handle("browser:pick", (event, targetId, labels) =>
fromWindow(event) ? (browserViews?.pick(String(targetId), labels) ?? { cancelled: true }) : { cancelled: true },
);
ipcMain.handle("browser:pick-cancel", (event, targetId) =>
fromWindow(event) ? (browserViews?.cancelPick(String(targetId)) ?? false) : false,
);
ipcMain.handle("browser:verify", (event, targetId, selector) =>
fromWindow(event) ? (browserViews?.verify(String(targetId), String(selector)) ?? { found: false }) : { found: false },
);
ipcMain.handle("browser:trust-certificate", (event, targetId) =>
fromWindow(event) ? (browserViews?.trustCertificate(String(targetId)) ?? false) : false,
);
+10 -1
View File
@@ -52,7 +52,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, onSave = () => {}) {
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}, onTheme = () => {}) {
ipcMain.on("prefs:load", (event) => {
event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {};
});
@@ -71,6 +71,15 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
}
event.returnValue = true;
});
ipcMain.on("prefs:theme", (event, paint) => {
if (!fromWindow(event) || !event.senderFrame || event.senderFrame !== event.sender.mainFrame) return;
if (!paint || typeof paint !== "object" || Array.isArray(paint)) return;
const { mode, scheme } = paint;
if (mode !== "auto" && mode !== "scheduled" && mode !== "light" && mode !== "dark") return;
if (scheme !== "light" && scheme !== "dark") return;
if ((mode === "light" || mode === "dark") && mode !== scheme) return;
onTheme({ mode, scheme });
});
}
function pickPagePrefs(prefs) {
+8
View File
@@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
platform: process.platform,
titleBar,
setUnreadBadge: (unread) => ipcRenderer.invoke("window:unread-badge", unread),
syncTheme: (mode, scheme) => ipcRenderer.send("prefs:theme", { mode, scheme }),
minimiseWindow: () => ipcRenderer.invoke("window:minimise"),
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
@@ -64,10 +65,17 @@ contextBridge.exposeInMainWorld("reasonixHost", {
freezeBrowserView: () => ipcRenderer.invoke("browser:freeze"),
controlBrowserView: (targetId, action) => ipcRenderer.invoke("browser:control", String(targetId), String(action)),
navigateBrowserView: (targetId, address) => ipcRenderer.invoke("browser:navigate", String(targetId), String(address)),
// Arm this page's element picker, and let a person put it away again. The
// answer is the element they pointed at, or that they picked nothing.
pickBrowserElement: (targetId, labels) => ipcRenderer.invoke("browser:pick", String(targetId), labels),
cancelBrowserPick: (targetId) => ipcRenderer.invoke("browser:pick-cancel", String(targetId)),
verifyBrowserElement: (targetId, selector) => ipcRenderer.invoke("browser:verify", String(targetId), String(selector)),
// Why a page did not load, and a login a page or proxy asks for. The password
// goes back to this window's main process only; the kernel never sees it.
onBrowserLoadState: (listener) => subscribe("browser:load-state", listener),
onBrowserLogin: (listener) => subscribe("browser:login", listener),
// The picker's key, pressed in the page rather than in this one.
onBrowserPickShortcut: (listener) => subscribe("browser:pick-shortcut", listener),
trustBrowserCertificate: (targetId) => ipcRenderer.invoke("browser:trust-certificate", String(targetId)),
answerBrowserLogin: (id, username, password) =>
ipcRenderer.invoke("browser:login-answer", String(id), String(username || ""), String(password || "")),
+319
View File
@@ -0,0 +1,319 @@
"use strict";
// Drives the element picker in the real shell: a person's own mouse and keys
// over a page of our own, in a user-data directory of our own. Run with
// `pnpm pick-live`; it needs no kernel and no network.
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
// Nothing this test writes may land in the Studio's own profile: a run that
// picked a password field must not leave that page cached in anyone's session.
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-pick-live-")));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const fails = [];
const check = (name, ok, got) => {
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
if (!ok) fails.push(name);
};
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Pick lab</title>
<style>
body { margin: 0; font: 16px sans-serif; }
#hero { position: absolute; left: 40px; top: 60px; width: 300px; height: 120px; background: #c0392b; color: #fff; }
#logo { width: 40px; height: 20px; margin: 10px; }
#go { position: absolute; left: 40px; top: 220px; }
#pw { position: absolute; left: 40px; top: 270px; }
</style></head><body>
<div id="hero">Hero <img id="logo" alt="logo" src="${GIF}"></div>
<button id="go" class="btn__a1b2c3" data-testid="go-button" aria-label="投放" onclick="window.__clicked = true">Go</button>
<label>Password <input id="pw" type="password" value="secret-value"></label>
<script>window.__enter = false; addEventListener("keydown", (event) => { if (event.key === "Enter") window.__enter = true; });</script>
<h1 id="head" style="position:absolute;left:40px;top:340px">A heading long enough to carry its own words and ask for no page context around it</h1>
<span id="dot" style="position:absolute;left:40px;top:420px;width:7px;height:7px;background:#2ecc71"></span>
<div id="badge" class="badge__x9y8z7" aria-label="状态徽标" style="position:absolute;left:40px;top:470px">ok</div>
<em class="em__a1b2c3" style="position:absolute;left:40px;top:520px">hash</em>
<iframe id="frame" srcdoc="&lt;p&gt;inside&lt;/p&gt;" style="position:absolute;left:40px;top:570px;width:120px;height:60px"></iframe>
<div id="shadow" style="position:absolute;left:200px;top:570px;width:80px;height:40px"></div>
<script>document.getElementById("shadow").attachShadow({ mode: "open" }).innerHTML = "<span>inside shadow</span>";</script>
</body></html>`;
app.whenReady().then(async () => {
const server = http.createServer((_q, s) => {
s.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
s.end(PAGE);
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const origin = `http://127.0.0.1:${server.address().port}/`;
const win = new BrowserWindow({ width: 1400, height: 1000, show: true, webPreferences: { sandbox: true } });
await win.loadURL("data:text/html,<body style='margin:0'><p>studio page</p></body>");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
let loaded;
const ready = new Promise((r) => (loaded = r));
const page = views.create({
partition: "pick-live",
url: origin,
onEvent: (method) => method === "Page.loadEventFired" && loaded(),
onClosed() {},
onPopup() {},
onDownload() {},
});
await page.send("Page.enable");
await Promise.race([ready, wait(4000)]);
const view = views.entries.get(page.targetId).view;
view.webContents.on("console-message", (_event, _level, message) => console.log("page console:", message));
views.show(page.targetId, { x: 0, y: 0, width: 1280, height: 900 });
await wait(600);
// Input only reaches a window the platform considers live; a background
// window drops the pressed half of a click while still delivering moves.
win.show();
win.focus();
await wait(300);
view.webContents.focus();
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
const at = (id) =>
evalIn(`(() => { const b = document.getElementById(${JSON.stringify(id)}).getBoundingClientRect();
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
const atSel = (selector) =>
evalIn(`(() => { const b = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
const move = (point) => page.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, button: "none", clickCount: 0 });
const click = async (point) => {
await move(point);
// Let the hover settle before the press: a new pick arms a frame later.
await wait(80);
// A native input event rather than a CDP press: the page half is what this
// guards, and the window's own input path is what a person's click takes.
const x = Math.round(point.x);
const y = Math.round(point.y);
view.webContents.sendInputEvent({ type: "mouseDown", x, y, button: "left", clickCount: 1 });
view.webContents.sendInputEvent({ type: "mouseUp", x, y, button: "left", clickCount: 1 });
};
const overlays = () =>
evalIn(`[...document.querySelectorAll('[data-reasonix-pick="box"], [data-reasonix-pick="card"]')].map((el) => ({
kind: el.dataset.reasonixPick, text: el.innerText, shown: getComputedStyle(el).display !== "none" }))`);
await evalIn("window.__clicked = false");
// 1. A person points at the image: the highlight and the card are drawn in
// the page, and what comes back is the element and where it sits.
const picking = views.pick(page.targetId);
await wait(200);
const logo = await at("logo");
await move(logo);
await wait(150);
const drawn = await overlays();
check("hovering draws the highlight and the card inside the page", drawn.length === 2 && drawn.every((one) => one.shown), drawn);
check(
"the card names the colour and the font, and adds no background for a transparent one",
/Color/.test(drawn[1].text) && /Font/.test(drawn[1].text) && !/Background/.test(drawn[1].text),
drawn[1].text,
);
await click(logo);
const picked = await picking;
check("a click answers with the element under the pointer", picked.cancelled === false && picked.element.tagName === "img", picked);
check("the element comes with a picture of itself", /^data:image\/png/.test(picked.shot || ""), (picked.shot || "").slice(0, 24));
check("the selector names it again", picked.element.selector === "#logo", picked.element.selector);
check(
"the box is the one the page laid out",
Math.abs(picked.element.rect.width - logo.width) <= 1 && Math.abs(picked.element.rect.height - logo.height) <= 1,
{ rect: picked.element.rect, logo },
);
check("it carries the page it came from", picked.element.pageUrl === origin, picked.element.pageUrl);
check("an element with little text of its own gets its container's", picked.element.nearbyText === "Hero", picked.element.nearbyText);
check("the card is put away once the pick settles", (await overlays()).length === 0, await overlays());
// 2. A pick is not a click for the page: the button's own handler never runs.
const buttonPick = views.pick(page.targetId);
await wait(200);
await click(await at("go"));
const button = await buttonPick;
check("the page's own click handler does not run", (await evalIn("window.__clicked")) === false, await evalIn("window.__clicked"));
check(
"a button says what it is and what it is called",
button.element.tagName === "button" && button.element.role === "button" && button.element.accessibleName === "投放",
button.element,
);
check("a button with its own word asks for no context", button.element.nearbyText === undefined, button.element.nearbyText);
// 3. What a person typed into a password field never leaves the page.
const passwordPick = views.pick(page.targetId);
await wait(200);
await click(await at("pw"));
const password = await passwordPick;
check("a password field answers with a placeholder, not its value", password.element.text === "[masked password input]", password.element.text);
check("neither the markup nor the attributes carry what was typed", !JSON.stringify(password.element).includes("secret-value"), password.element.htmlExcerpt);
// 4. A heading says what it is already: the nearest article around it is the
// whole page, and that must not be handed over as context.
const headingPick = views.pick(page.targetId);
await wait(200);
await click(await at("head"));
const heading = await headingPick;
check("a heading carries no page-wide context", heading.element.nearbyText === undefined, heading.element.nearbyText);
// 5. A decorative mark has no words and no role: nothing around it makes it
// meaningful, so no context comes along either, and the fields that have no
// answer are left out instead of sent empty.
const dotPick = views.pick(page.targetId);
await wait(200);
await click(await at("dot"));
const dot = await dotPick;
check(
"a decorative mark carries no context and no empty fields",
dot.element.nearbyText === undefined && dot.element.role === undefined && dot.element.text === undefined,
dot.element,
);
// 6. The page's own identity wins over its styling: a hash class is gone
// after the next deploy, a data-testid is not.
const identity = views.pick(page.targetId);
await wait(200);
await click(await at("go"));
const byTestId = await identity;
check(
"a data-testid is the selector when the page has one",
byTestId.element.selector === '[data-testid="go-button"]' && byTestId.element.selectorBasis === "testid",
byTestId.element,
);
check("the hash class behind it is not used", !byTestId.element.selector.includes("btn__a1b2c3"), byTestId.element.selector);
const labelled = views.pick(page.targetId);
await wait(200);
await click(await at("badge"));
const byLabel = await labelled;
check(
"an aria-label is the next choice",
byLabel.element.selector === 'div[aria-label="状态徽标"]' && byLabel.element.selectorBasis === "aria-label",
byLabel.element,
);
const styled = views.pick(page.targetId);
await wait(200);
await click(await atSel(".em__a1b2c3"));
const byClass = await styled;
check(
"with neither, the styling class is used and labelled as such",
byClass.element.selectorBasis === "class" && byClass.element.selector.includes("em__a1b2c3"),
byClass.element,
);
check(
"and it still finds the element",
(await evalIn(`document.querySelector(${JSON.stringify(byClass.element.selector)})?.tagName`)) === "EM",
byClass.element.selector,
);
// 7. Stepping up with the arrow key: what the card shows and what comes back
// are the same level.
const climbing = views.pick(page.targetId);
await wait(200);
const logoPoint = await at("logo");
await move(logoPoint);
await wait(100);
const boxWidth = () => evalIn(`parseFloat(document.querySelector('[data-reasonix-pick="box"]').style.width)`);
check("the pointer starts on the image", (await boxWidth()) === 40, await boxWidth());
view.webContents.sendInputEvent({ type: "keyDown", keyCode: "Up" });
await wait(120);
view.webContents.sendInputEvent({ type: "keyUp", keyCode: "Up" });
check("one step up is the hero", (await boxWidth()) === 300, await boxWidth());
const chainText = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
check("the card shows the chain it climbed", /div#hero/.test(chainText) && !/img/.test(chainText), chainText);
// Pressed without moving: a mousemove would put the pointer back on the image.
const climbX = Math.round(logoPoint.x);
const climbY = Math.round(logoPoint.y);
view.webContents.sendInputEvent({ type: "mouseDown", x: climbX, y: climbY, button: "left", clickCount: 1 });
view.webContents.sendInputEvent({ type: "mouseUp", x: climbX, y: climbY, button: "left", clickCount: 1 });
const climbed = await climbing;
check("the answer is the level stepped up to", climbed.element.tagName === "div" && climbed.element.selector === "#hero", climbed.element);
// 9. A frame or a shadow host is named, not guessed at.
const framePick = views.pick(page.targetId);
await wait(200);
await move(await at("frame"));
await wait(100);
const frameNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
check("an embedded frame says why it cannot be picked", /embedded frame/.test(frameNote), frameNote);
await click(await at("frame"));
await wait(300);
const stillArmed = await evalIn(`!!document.querySelector('[data-reasonix-pick="box"]')`);
check("clicking the frame leaves the pick armed", stillArmed === true, stillArmed);
void views.cancelPick(page.targetId);
const frameAnswer = await framePick;
check("and the frame is not reported as an element", frameAnswer.cancelled === true, frameAnswer);
const shadowPick = views.pick(page.targetId);
await wait(200);
await move(await at("shadow"));
await wait(100);
const shadowNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
check("a shadow host says why it cannot be picked", /shadow root/.test(shadowNote), shadowNote);
void views.cancelPick(page.targetId);
await shadowPick;
// 10. A second pick on the same page replaces the first instead of stacking.
const firstPick = views.pick(page.targetId);
await wait(200);
const secondPick = views.pick(page.targetId);
await wait(300);
const draws = await evalIn(
`document.querySelectorAll('[data-reasonix-pick="box"]').length + "/" + document.querySelectorAll('[data-reasonix-pick="card"]').length`,
);
check("a second pick leaves one picker on the page", draws === "1/1", draws);
const firstAnswer = await firstPick;
check("the first pick answers as cancelled", firstAnswer.cancelled === true, firstAnswer);
void views.cancelPick(page.targetId);
await secondPick;
// 11. Shift-click keeps a set: the click that ends it carries them all.
const multi = views.pick(page.targetId);
await wait(200);
const goAt = await at("go");
await move(goAt);
await wait(100);
view.webContents.sendInputEvent({ type: "mouseDown", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
view.webContents.sendInputEvent({ type: "mouseUp", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
await wait(150);
const marked = await evalIn(`document.querySelectorAll("[data-reasonix-picked]").length`);
check("shift-click keeps the element marked", marked === 1, marked);
await click(await at("badge"));
const many = await multi;
check("the set comes back as several elements", many.elements?.length === 2, many.elements?.map((e) => e.tagName));
// 12. Verifying the selector after the page changed.
const still = await views.verify(page.targetId, "#logo");
check("a selector that still exists comes back with a fresh picture", still.found === true && /^data:image\/png/.test(still.shot || ""), still);
const gone = await views.verify(page.targetId, "#nope");
check("a selector that is gone says so", gone.found === false, gone);
// 8. The keyboard is the picker's too while it is armed: Enter must not press
// a focused button, and Escape is the one key that ends the pick — saying so.
const escaping = views.pick(page.targetId);
await wait(200);
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
await page.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
check("Enter does not reach the page", (await evalIn("window.__enter")) === false, await evalIn("window.__enter"));
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 });
const escaped = await escaping;
check("Escape cancels the pick, and says why", escaped.cancelled === true && escaped.reason === "escape", escaped);
check("and leaves nothing drawn", (await overlays()).length === 0);
// 7. A page put away under the picker ends the pick instead of swallowing
// the person's clicks on a picture they cannot reach.
const hiding = views.pick(page.targetId);
await wait(200);
views.hide();
const hidden = await hiding;
check("a page put away cancels the pick, and says why", hidden.cancelled === true && hidden.reason === "hidden", hidden);
views.hide();
page.close();
server.close();
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
app.exit(fails.length ? 1 : 0);
});
+1 -1
View File
@@ -84,7 +84,7 @@ async function run() {
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
}))()`);
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "syncTheme", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
+169
View File
@@ -0,0 +1,169 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow, nativeTheme } = require("electron");
const { GROUND } = require("../src/ground");
const { loadPrefs, prefsFile } = require("../src/prefs");
const { until, wait } = require("./livekit");
const home = process.env.REASONIX_HOME;
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native theme smoke");
fs.mkdirSync(home, { recursive: true });
if (!fs.existsSync(path.join(home, "config.toml"))) {
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
}
process.chdir(home);
fs.mkdirSync(path.join(home, "electron-profile"), { recursive: true });
app.setPath("userData", path.join(home, "electron-profile"));
const startups = new Map();
app.on("browser-window-created", (_event, win) => {
// Native options settle after creation; load-start still precedes page paint.
win.webContents.once("did-start-loading", () => {
startups.set(win, { source: nativeTheme.themeSource, background: win.getBackgroundColor(), saved: loadPrefs(prefsFile(app.getPath("userData"))) });
});
});
const { current } = require("../src/main");
let untrusted;
async function run() {
const win = await until("Studio window", () => current().win && !current().win.webContents.isLoading() && current().win);
const js = source => win.webContents.executeJavaScript(source);
await until("real Studio workbench", () => js('!!document.querySelector(".chrome") && !document.getElementById("boot")'));
const saved = () => loadPrefs(prefsFile(app.getPath("userData")));
async function matches(scheme, source = scheme) {
await until(`native ${scheme}/${source} repaint`, async () => nativeTheme.themeSource === source &&
win.getBackgroundColor().toLowerCase().endsWith(GROUND[scheme].slice(1)) &&
await js(`document.documentElement.dataset.theme === ${JSON.stringify(scheme)}`));
console.log(JSON.stringify({ page: scheme, source: nativeTheme.themeSource, background: win.getBackgroundColor() }));
}
if (process.env.REASONIX_THEME_RESTART === "1") {
const startup = startups.get(win);
assert.equal(startup.saved["rx-theme"], "scheduled");
assert.equal(startup.source, startup.saved["rx-theme-resolved"]);
assert.ok(startup.background.toLowerCase().endsWith(GROUND[startup.source].slice(1)), JSON.stringify(startup));
const clock = new Date();
const actual = clock.getHours() >= 20 || clock.getHours() < 7 ? "dark" : "light";
assert.notEqual(actual, startup.source);
await matches(actual);
assert.equal(await js('localStorage.getItem("rx-theme")'), "scheduled");
console.log("ok restart: cached native startup tint, then current local-clock page/native repaint on a new kernel origin");
return;
}
nativeTheme.themeSource = "system";
const systemDark = nativeTheme.shouldUseDarkColors;
console.log(`host OS dark=${systemDark}; platform=${process.platform}`);
await js(`(() => {
const RealDate = Date;
window.__themeClock = { at: RealDate.now(), began: performance.now() };
window.Date = class extends RealDate {
constructor(...args) { super(...(args.length ? args : [window.__themeClock.at + performance.now() - window.__themeClock.began])); }
static now() { return window.__themeClock.at + performance.now() - window.__themeClock.began; }
};
})()`);
async function clock(hour, minute = 0, second = 0) {
const at = new Date(2026, 9, 29, hour, minute, second).getTime();
await js(`window.__themeClock = { at: ${at}, began: performance.now() }; window.dispatchEvent(new Event("focus"))`);
}
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
await until("settings navigation", () => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'));
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
await until("appearance modes", () => js('!!document.querySelector(\'[data-action="appearance.scheme"][data-value="scheduled"]\')'));
const mode = value => js(`document.querySelector('[data-action="appearance.scheme"][data-value="${value}"]').click()`);
async function change(field, value) {
await js(`(() => {
const input = document.querySelector('[data-action="appearance.schedule"][data-target="${field}"]');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(value)});
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
})()`);
}
await clock(19, 59, 57);
await mode("scheduled");
await matches("light");
await matches("dark");
console.log("ok real boundary timeout changes both nativeTheme.themeSource and BrowserWindow background");
await clock(6, 59, 58);
await matches("dark");
await matches("light");
await clock(21);
await matches("dark");
await change("start", "22:00");
await matches("light");
await change("start", "20:00");
await matches("dark");
await change("end", "21:00");
await matches("light");
await change("end", "07:00");
await matches("dark");
await js('document.querySelector(\'[data-action="theme.activate"][data-value="official-clear-blue"]\').click()');
await until("active theme pack", () => js('document.documentElement.dataset.pack === "official-clear-blue"'));
await matches("dark");
await js('document.querySelector(".appearance-advanced").open = true; document.querySelector(\'[data-action="appearance.contrast"][data-value="strong"]\').click()');
await matches("dark");
await clock(8);
await matches("light");
assert.equal(await js('document.documentElement.dataset.pack'), "official-clear-blue");
assert.equal(await js('document.documentElement.dataset.contrast'), "strong");
console.log("ok start/end edits, focus resume, active pack and contrast keep the native scheme aligned");
await clock(systemDark ? 8 : 21);
await matches(systemDark ? "light" : "dark");
await mode("auto");
await until("Auto native system following", () => nativeTheme.themeSource === "system");
const systemScheme = systemDark ? "dark" : "light";
await matches(systemScheme, "system");
await js('localStorage.setItem("rx-theme", "scheduled"); localStorage.setItem("rx-weight", "heavy"); window.dispatchEvent(new Event("pagehide"))');
assert.equal(saved()["rx-theme"], "scheduled");
await matches(systemScheme, "system");
await js('localStorage.setItem("rx-theme", "auto")');
console.log("ok simulated stale prefs:save cannot override the latest Auto paint or pin system following");
await clock(8);
await mode("scheduled");
await matches("light");
await mode("dark");
await matches("dark");
await js('window.reasonixHost.syncTheme("scheduled", "auto"); window.reasonixHost.syncTheme("light", "dark"); window.reasonixHost.syncTheme("invalid", "light")');
await wait(200);
await matches("dark");
untrusted = new BrowserWindow({ show: false, webPreferences: { preload: path.join(__dirname, "../src/preload.js"), sandbox: true, contextIsolation: true, nodeIntegration: false, partition: "theme-smoke-untrusted" } });
await untrusted.loadURL("data:text/html,<title>Untrusted theme reporter</title>");
await untrusted.webContents.executeJavaScript('window.reasonixHost.syncTheme("scheduled", "light")');
await wait(200);
await matches("dark");
untrusted.destroy(); untrusted = null;
await js('const iframe = document.createElement("iframe"); iframe.id = "theme-subframe"; iframe.srcdoc = "<p>Untrusted subframe</p>"; document.body.append(iframe)');
await until("isolated subframe", () => js('!!document.getElementById("theme-subframe").contentDocument?.querySelector("p")'));
assert.equal(await js('typeof document.getElementById("theme-subframe").contentWindow.reasonixHost'), "undefined");
await js('document.getElementById("theme-subframe").remove()');
console.log("ok real untrusted renderer and invalid payloads cannot repaint native chrome; subframes have no bridge");
await mode("scheduled");
const real = new Date();
const restartScheme = real.getHours() >= 20 || real.getHours() < 7 ? "light" : "dark";
await clock(restartScheme === "dark" ? 21 : 8);
await matches(restartScheme);
await js('window.dispatchEvent(new Event("pagehide"))');
assert.equal(saved()["rx-theme"], "scheduled");
assert.equal(saved()["rx-theme-resolved"], restartScheme);
const out = process.env.REASONIX_SCREENSHOTS;
if (out) {
fs.mkdirSync(out, { recursive: true });
await js('document.getElementById("set-mode").scrollIntoView({block:"start"})');
await wait(350);
fs.writeFileSync(path.join(out, "native-scheduled.png"), (await win.webContents.capturePage()).toPNG());
}
console.log("ok saved opposite-clock native tint; run again with REASONIX_THEME_RESTART=1 to check a complete restart");
console.log("note Windows border rendering and changing the host OS preference are not exercised on this host");
}
let exitCode = 0;
app.once("will-quit", () => app.exit(exitCode));
run().then(() => app.quit()).catch(error => {
untrusted?.destroy();
console.error(error.stack || error);
exitCode = 1;
app.quit();
});
+165
View File
@@ -1152,6 +1152,30 @@ test("only the Studio window reads or writes the preferences", () => {
fs.rmSync(dir, { recursive: true, force: true });
});
test("only the Studio main frame reports a validated resolved theme", () => {
const handlers = {};
const studio = { mainFrame: {} };
const browser = { mainFrame: {} };
const paints = [];
registerPrefs({ on: (name, fn) => (handlers[name] = fn) }, () => "", event => event.sender === studio,
() => {}, paint => paints.push(paint));
const send = (sender, senderFrame, paint) => handlers["prefs:theme"]({ sender, senderFrame }, paint);
send(browser, browser.mainFrame, { mode: "scheduled", scheme: "dark" });
send(studio, {}, { mode: "scheduled", scheme: "dark" });
send(studio, null, { mode: "scheduled", scheme: "dark" });
for (const paint of [null, [], "dark", { mode: "scheduled" }, { mode: "scheduled", scheme: "auto" },
{ mode: "other", scheme: "dark" }, { mode: "light", scheme: "dark" }, { mode: "dark", scheme: "light" }]) {
send(studio, studio.mainFrame, paint);
}
assert.deepEqual(paints, []);
for (const mode of ["scheduled", "auto", "dark"]) send(studio, studio.mainFrame, { mode, scheme: "dark", extra: "discarded" });
send(studio, studio.mainFrame, { mode: "light", scheme: "light" });
assert.deepEqual(paints, [
{ mode: "scheduled", scheme: "dark" }, { mode: "auto", scheme: "dark" },
{ mode: "dark", scheme: "dark" }, { mode: "light", scheme: "light" },
]);
});
function elf64(interpreter, machine = 62) {
const phoff = 64, phentsize = 56, phnum = interpreter ? 2 : 1;
const dataOffset = phoff + phnum * phentsize;
@@ -2183,6 +2207,31 @@ test("the window frame takes the stored theme, and auto follows the system", ()
assert.equal(themeSourceFor(undefined), "system");
});
test("scheduled grounds and frames follow the painted scheme rather than the opposite OS", () => {
const prefs = { "rx-theme": "scheduled" };
for (const [scheme, systemDark] of [["dark", false], ["light", true]]) {
const painted = { mode: "scheduled", scheme };
assert.equal(themeSourceFor(prefs, painted), scheme);
assert.equal(groundFor(prefs, systemDark, painted), GROUND[scheme]);
const saved = { ...prefs, "rx-theme-resolved": scheme };
assert.equal(themeSourceFor(saved), scheme);
assert.equal(groundFor(saved, systemDark), GROUND[scheme]);
}
assert.equal(themeSourceFor({ ...prefs, "rx-theme-resolved": "invalid" }), "system");
});
test("the latest painted mode wins over delayed preference saves without pinning Auto", () => {
const stale = { "rx-theme": "scheduled", "rx-theme-resolved": "dark" };
const auto = { mode: "auto", scheme: "dark" };
assert.equal(themeSourceFor(stale, auto), "system");
assert.equal(groundFor(stale, false, auto), GROUND.light);
assert.equal(groundFor(stale, true, auto), GROUND.dark);
const light = { mode: "light", scheme: "light" };
assert.equal(themeSourceFor(stale, light), "light");
assert.equal(groundFor(stale, true, light), GROUND.light);
assert.equal(themeSourceFor({ "rx-theme": "light" }, { mode: "scheduled", scheme: "dark" }), "dark");
});
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
test("hardware acceleration is off only when the saved preference says exactly that", () => {
@@ -3756,3 +3805,119 @@ test("only the app's own window is answered, and nothing is read from the asker"
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
});
test("a pick arms the page, answers what it reports, and a page that cannot answer is cancelled", async () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
try {
({ BrowserViews } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView() {}, removeChildView() {} },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const element = { tagName: "img", selector: "main > img", rect: { x: 1, y: 2, width: 3, height: 4 } };
const armed = [];
const recheck = [];
const contents = {
isDestroyed: () => false,
executeJavaScript: async (code) => {
armed.push(code);
return { cancelled: false, element };
},
executeJavaScriptInIsolatedWorld: async (_worldId, scripts) => {
recheck.push(scripts[0].code);
return "img";
},
};
const shots = [];
contents.capturePage = async (at) => {
shots.push(at);
return { isEmpty: () => true, toDataURL: () => "data:image/png;base64,x" };
};
views.entries.set("t1", { targetId: "t1", view: { webContents: contents, getBounds: () => ({ width: 200, height: 200 }) } });
// Picking only reaches the page that is on screen.
views.shown = "t1";
assert.deepEqual(await views.pick("t1"), { cancelled: false, element });
assert.match(armed[0], /__reasonixElementPicker/, "the page is armed with the picker's own script");
assert.match(recheck[0], /main > img/, "the shell re-finds the selector in its own world");
assert.equal(await views.cancelPick("t1"), true);
assert.match(armed[1], /picker\.stop\(\)/, "cancelling disarms the page");
assert.deepEqual(await views.pick("gone"), { cancelled: true, reason: "gone" }, "a page that is not there picks nothing");
assert.equal(await views.cancelPick("gone"), false);
// A page that navigated out from under the pick, and one that answered
// something that is not an element, both say why rather than "nothing".
contents.executeJavaScript = async () => {
throw new Error("the page went away");
};
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "navigation" });
contents.executeJavaScript = async () => "not an answer";
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" });
// The page wrote the answer: the shell keeps known fields, clips them, and
// treats a shape it cannot read as gone.
contents.executeJavaScript = async () => ({
cancelled: false,
element: {
tagName: "img",
selector: "main > img",
text: "x".repeat(10_000),
extra: "drop me",
rect: { x: "nope", y: 2, width: 3, height: 4 },
attributes: { id: "a", bad: 5 },
},
});
contents.executeJavaScriptInIsolatedWorld = async () => "img";
const bounded = await views.pick("t1");
assert.equal(bounded.element.text.length, 4000, "an oversized string is clipped");
assert.equal("extra" in bounded.element, false, "an unknown field is dropped");
assert.equal("rect" in bounded.element, false, "a rect that is not numbers is dropped");
assert.deepEqual(bounded.element.attributes, { id: "a" }, "a non-string attribute is dropped");
contents.executeJavaScript = async () => ({
cancelled: false,
element: { tagName: "img", selector: "main > img", attributes: { ["k".repeat(600)]: "v" } },
});
const longKey = await views.pick("t1");
assert.equal(Object.keys(longKey.element.attributes)[0].length, 500, "an oversized attribute key is clipped like its value");
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img" } });
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" }, "an element without a selector is gone");
// A label with a quote and a newline still produces a script that parses.
contents.executeJavaScript = async (code) => {
armed.push(code);
return { cancelled: true, reason: "escape" };
};
await views.pick("t1", { color: 'a"b\nc' });
assert.doesNotThrow(() => new Function(armed[armed.length - 1]), "the label is embedded as JSON");
// A rect the page wrote is clamped to the view or dropped entirely.
for (const [bad, why] of [
[{ x: -1, y: 0, width: 10, height: 10 }, "a negative origin is kept and clamped"],
[{ x: NaN, y: 0, width: 10, height: 10 }, "a NaN rect is dropped"],
[{ x: Infinity, y: 0, width: 10, height: 10 }, "an infinite rect is dropped"],
]) {
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img", selector: "main > img", rect: bad } });
const picked = await views.pick("t1");
if (Number.isFinite(bad.x) && bad.x < 0) {
assert.equal(picked.element.rect.x, -1, why);
assert.deepEqual(shots[shots.length - 1], { x: 0, y: 0, width: 10, height: 10 }, "the origin is clamped to the view");
} else {
assert.equal("rect" in picked.element, false, why);
assert.equal("shot" in picked, false, "no picture is taken for a rect that is not numbers");
}
}
});
+11
View File
@@ -16,6 +16,17 @@ reviewed: 2026-09-22
- An unsent draft takes precedence while the page is open.
- No feedback body or screenshots are persisted by this preference.
## Pane workspace metadata
- Each pane owns one workspace-metadata read after its initial status settles.
Chrome and the composer's workspace chip consume that same result.
- A failed initial status still allows the read using the runtime's known root;
history loads independently of both status and workspace metadata.
- Changing the pane's port or directory reads again and discards superseded
responses. Focusing an already-open pane reuses its own result.
- Opening the workspace chooser still refreshes its branches and worktree
sources; it does not issue another workspace-metadata read.
## Tests
`pnpm test` runs one worker per test file on the threads pool. Threads rather
+18
View File
@@ -39,10 +39,12 @@ node perf/rail.mjs # 左栏会话树:2×8 / 10×50 / 20×200 / 40×500
node perf/profile.mjs # CPU 采样,按自耗时列热点(流式那一段)
node perf/switchprof.mjs # CPU 采样,量点开一份长会话的那一下
node perf/panes.mjs # 并行窗格的代价:两个会话同时流式
node perf/rail-toggle-panes.mjs # Rail-fold frame cost with 1/3/6 panes; existing-pane scroll retention and background-output follow
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
node perf/measure.mjs # Custom conversation percentage, preset retention, narrow composer controls and populated phone layout
node perf/theme-schedule.mjs # Scheduled appearance: daily boundaries, midnight, reload/resume, modes, active packs and DST; THEME_SCHEDULE_SHOTS writes wide/phone light/dark screenshots
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
@@ -84,9 +86,18 @@ node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/recent-sessions.mjs # Recent: global history paging, finished-session discovery and project location (light/dark × wide/390px)
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
`theme-schedule.mjs` is a manual smoke, not a default guard. For real native
effects, build the normal Studio page and host, then run `pnpm theme-live` from
`desktop/electron` with an isolated `REASONIX_HOME`.
Run it again with the same home and `REASONIX_THEME_RESTART=1` to check restart
tint and current-clock reconciliation. It reads actual `nativeTheme.themeSource`
and `BrowserWindow` background effects, including rejected IPC reporters.
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
哪些中文不必翻)。`PERF_URL` 可以指向别的地址;`bench.mjs` 和 `scale.mjs` 另收 `PERF_SCALES`、
`PERF_DELTAS`,`rail.mjs` 收 `PERF_TREE`(`区数x每区会话数`,逗号分隔),
@@ -100,6 +111,13 @@ node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出
1440 → 960 → 640 → 420 → 1440px at each zoom.
- The native-scrollbar case requires a space-consuming gutter, not an exact
pixel width; overlay-scrollbar browsers explicitly skip that case.
- `?panes=N` seeds the mock hub's shared runtime/session book. Tree rows and open
panes must agree on both transcript path and runtime ID.
- `rail-toggle-panes.mjs` selects two existing sessions by path, verifies that the
original transcript DOM node is hidden and restored, and checks scroll retention
while new output arrives in the background.
- The same guard checks that a neighbouring pane following the latest output
continues to do so. `RAIL_TOGGLE_SHOTS` saves its before/hidden/returned views.
## 判据
@@ -0,0 +1,99 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shots = process.env.SHOTS ?? "/tmp/reasonix-14759-visibility";
mkdirSync(shots, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ locale: "zh-CN" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const box = page.locator(".compose textarea");
function check(ok, message) {
if (!ok) throw new Error(message);
}
async function stable() {
await box.waitFor();
await page.addStyleTag({ content: "*, *::before, *::after { transition: none !important; animation: none !important; }" });
await page.evaluate(() => document.fonts.ready);
}
async function hint(on, shot) {
const settings = page.locator('[data-action="chrome.settings"]');
const menu = page.locator("button.studio-menu");
if (await menu.isVisible() && await menu.getAttribute("aria-pressed") !== "true") await menu.click();
await settings.click();
await page.locator('.prefs-sheet input[data-action-keydown="settings.search"]').fill("placeholder");
await page.locator('[role="option"][data-target="composer-hint"]').click();
const section = page.locator('[data-setting="composer-hint"]');
await section.locator(`[data-value="${on ? "on" : "off"}"]`).click();
check(await section.locator(`[data-value="${on ? "on" : "off"}"]`).getAttribute("aria-pressed") === "true", "Setting must reflect the selected visibility");
check(await section.locator('[data-apply="immediate"]').count() === 1, "Visibility must be immediate");
if (shot) {
await section.evaluate((el) => el.scrollIntoView({ block: "center" }));
await page.screenshot({ path: `${shots}/${shot}.png` });
}
await page.locator('button[data-action="settings.close"]').click();
}
async function visible(on) {
await page.waitForFunction((on) => Boolean(document.querySelector(".compose textarea")?.placeholder) === on, on);
check(Boolean(await box.getAttribute("aria-label")), "Hidden hint must retain the accessible input label");
check(Boolean(await box.getAttribute("aria-describedby")), "Input instructions must remain associated");
}
try {
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ["light", "dark"]) {
await page.goto(url, { waitUntil: "networkidle" });
await stable();
if (await page.locator("html").getAttribute("data-theme") !== theme) await page.locator('[data-action="appearance.theme"]').click();
await visible(true);
check(await box.evaluate((el) => getComputedStyle(el, "::placeholder").opacity) === "1", "Visible guidance must retain full theme opacity");
await page.screenshot({ path: `${shots}/${theme}-${width}-on.png` });
await hint(false, `${theme}-${width}-settings-off`);
await visible(false);
await page.screenshot({ path: `${shots}/${theme}-${width}-off.png` });
await page.reload({ waitUntil: "networkidle" });
await stable();
await visible(false);
await page.screenshot({ path: `${shots}/${theme}-${width}-off-reloaded.png` });
await box.fill("preserved draft");
const ink = await box.evaluate((el) => ({ color: getComputedStyle(el).color, opacity: getComputedStyle(el).opacity }));
check(ink.opacity === "1", "Entered text must retain full opacity");
await hint(true);
await visible(true);
check(await box.inputValue() === "preserved draft", "Changing hint must not alter the draft");
check(await box.evaluate((el) => getComputedStyle(el).color) === ink.color, "Changing hint must not alter entered ink");
await box.fill("");
await page.reload({ waitUntil: "networkidle" });
await stable();
await visible(true);
console.log(`PASS: ${theme}/${width} default on, Appearance off/on, draft and reload persistence`);
}
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.reload({ waitUntil: "networkidle" });
await stable();
await hint(false);
await box.fill("visibility smoke input");
check(await box.evaluate((el) => document.activeElement === el), "Typing must focus the composer");
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));
await box.press("Enter");
check(await page.evaluate(() => !window.__sent?.length), "IME Enter must not submit");
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })));
await page.waitForTimeout(120);
await box.press("Enter");
await page.waitForFunction(() => window.__sent?.includes("visibility smoke input"));
await page.goto(`${url}?queue=3&running=1`, { waitUntil: "networkidle" });
await stable();
await page.locator(".queue .qi").first().waitFor();
await visible(false);
check(await page.locator(".queue .qi").count() === 3, "Hiding hint must retain queued content");
await hint(true);
await visible(true);
check(await page.locator(".queue .qi").count() === 3, "Showing hint must retain queued content");
await page.screenshot({ path: `${shots}/queue-hint-on.png` });
check(errors.length === 0, errors.join("\n"));
console.log(`PASS: accessible label, entered ink, focus, IME, submit and queue; screenshots: ${shots}`);
} finally {
await browser.close();
}
+14 -31
View File
@@ -103,6 +103,7 @@ function benchPack(): ThemePack | null {
}
class BenchPort extends MockPort {
private driverRunning: boolean | undefined;
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
async account() {
@@ -344,6 +345,8 @@ class BenchPort extends MockPort {
}
feed(ev: WireEvent) {
if (ev.kind === "turn_started") this.driverRunning = true;
if (ev.kind === "turn_done") this.driverRunning = false;
// The driver's frames are the fixture's own frames: a fed approval has to
// move /status the way a scripted one does, or a guard reads a card sitting
// over a status that still says the turn is running.
@@ -421,6 +424,7 @@ class BenchPort extends MockPort {
? { turnsUsed: 4, tokensUsed: 128_400, requestsUsed: 9, workDurationMs: 12 * 60_000, noProgressTurns: usage === "blocked" ? 2 : 0, ...(usage === "blocked" ? { stopCause: "goal_stuck", lastReason: "测试一直没有通过,改动没有让失败的数量减少" } : {}) }
: undefined;
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("fullaccess") ? { toolApprovalMode: "yolo" as const } : {}), ...(query.has("effort") ? { effort: query.get("effort") ?? "" } : {}), ...(query.has("modelref") ? { modelRef: query.get("modelref") ?? "" } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
st.running = this.driverRunning ?? st.running;
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
if (!JOBS) return st;
const jobs = Array.from({ length: JOBS }, (_, i) => ({
@@ -509,11 +513,14 @@ const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
// ?tree=none answers every tree read with no folders.
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
constructor() {
super(PANES);
}
// ?search=1 answers every query with two conversations that say it.
async searchHistory(q: string) {
if (!query.has("search")) return super.searchHistory(q);
@@ -524,12 +531,6 @@ class BenchHub extends MockHub {
}
private readonly settingsPort = new BenchPort();
private opened = false;
async open(req: Parameters<MockHub["open"]>[0]) {
this.opened = true;
return super.open(req);
}
machinePort(): AgentPort {
return this.settingsPort;
@@ -541,20 +542,6 @@ class BenchHub extends MockHub {
return port;
}
runtimes(): Promise<RuntimeView[]> {
if (NO_PANES && !this.opened) return Promise.resolve([]);
if (PANES <= 1) return super.runtimes();
return Promise.resolve(
Array.from({ length: PANES }, (_, i) => ({
id: `r${i + 1}`,
base: i === 0 ? "" : `/rt/r${i + 1}`,
root: "~/projects/DeepSeek-Reasonix",
name: "DeepSeek-Reasonix",
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
})),
);
}
async moveSessionTo(path: string, target: string) {
(window.__moves ??= []).push({ path, target });
return super.moveSessionTo(path, target);
@@ -574,16 +561,11 @@ class BenchHub extends MockHub {
return new Promise((resolve) => setTimeout(() => resolve(answer), archived ? slow : 0));
}
const wait = TREE_AT - performance.now();
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree(window)), wait));
if (query.get("tree") === "none") return Promise.resolve([]);
if (!WORKSPACES && query.has("trustpending") && PANES > 1) {
return super.tree().then((all) => all.map((ws, i) => i > 0 ? ws : { ...ws, sessions: Array.from({ length: PANES }, (_, k) => ({
path: `/sessions/pane-${k + 1}.jsonl`, name: `pane-${k + 1}`, title: `并行会话 ${k + 1}`, turns: 3, runtimeId: `r${k + 1}`,
})) }));
}
if (!WORKSPACES) return super.tree();
return Promise.resolve(
Array.from({ length: WORKSPACES }, (_, w) => ({
if (!WORKSPACES) return super.tree(window);
return super.runtimes().then((runtimes) =>
this.recentTree(Array.from({ length: WORKSPACES }, (_, w) => ({
root: `~/projects/workspace-${w}`,
name: `workspace-${w}`,
remembered: true,
@@ -593,12 +575,13 @@ class BenchHub extends MockHub {
name: `w${w}-s${s}`,
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
turns: (s % 40) + 1,
runtimeId: runtimes.find((runtime) => runtime.sessionPath === `/sessions/w${w}-s${s}.jsonl`)?.id,
...(query.has("rows") ? {
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
} : {}),
}))),
})),
})), window),
);
}
}
+28 -9
View File
@@ -15,7 +15,8 @@ try {
page.setDefaultTimeout(8000);
page.on("pageerror", (error) => failures.push(error.message));
await page.goto(`${BASE}?pref=zh&turns=3`, { waitUntil: "networkidle" });
const add = page.locator(".projects-heading .projects-add");
const add = page.locator('.projects-heading [data-action="workspace.add"]');
const recent = page.locator('.projects-heading [data-action="session.recent"]');
const more = page.locator(".projects-heading .wsmore");
await add.waitFor();
await page.addStyleTag({ content: "*{transition:none!important;animation:none!important}" });
@@ -29,11 +30,14 @@ try {
const addHover = await paint(add);
await more.hover();
const moreHover = await paint(more);
check(`${scheme}: both header actions visibly highlight on hover`,
addHover.fill !== rest.fill && moreHover.fill !== rest.fill);
await recent.hover();
const recentHover = await paint(recent);
check(`${scheme}: all header actions visibly highlight on hover`,
addHover.fill !== rest.fill && moreHover.fill !== rest.fill && recentHover.fill !== rest.fill);
check(`${scheme}: project header actions have the same hover fill and ink`,
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink,
JSON.stringify({ add: addHover, more: moreHover }));
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink
&& recentHover.fill === addHover.fill && recentHover.ink === addHover.ink,
JSON.stringify({ add: addHover, more: moreHover, recent: recentHover }));
await more.click();
const menu = page.getByRole("menu", { name: "项目列表的更多操作", exact: true });
await menu.waitFor();
@@ -50,15 +54,22 @@ try {
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
await page.keyboard.press("Shift+Tab");
const recentFocus = await recent.evaluate((button) => ({
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
const moreFocus = await more.evaluate((button) => ({
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
check(`${scheme}: both header actions retain visible keyboard focus`,
addFocus.focused && moreFocus.focused
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none"),
JSON.stringify({ add: addFocus, more: moreFocus }));
check(`${scheme}: all header actions retain visible keyboard focus`,
addFocus.focused && moreFocus.focused && recentFocus.focused
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none")
&& !recentFocus.outline.includes("none"),
JSON.stringify({ add: addFocus, more: moreFocus, recent: recentFocus }));
await more.click();
await menu.waitFor();
await page.locator(".projects-heading-title").click();
@@ -73,6 +84,14 @@ try {
await page.mouse.move(900, 500);
const after = await paint(more);
check(`${scheme}: dismissing the menu restores the resting background`, after.fill === rest.fill);
await recent.click();
const recentDialog = page.getByRole("dialog", { name: "最近的会话", exact: true });
await recentDialog.locator(".recent-session").first().waitFor();
check(`${scheme}: Recent opens saved session discovery`, await recentDialog.isVisible());
await page.keyboard.press("Escape");
await recentDialog.waitFor({ state: "hidden" });
check(`${scheme}: closing Recent returns focus to its own header entry`,
await recent.evaluate((button) => button === document.activeElement));
await page.goto(`${BASE}?pref=zh&ws=20&sess=8&turns=3`, { waitUntil: "networkidle" });
await add.waitFor();
const headerTop = await add.evaluate((button) => button.getBoundingClientRect().top);
@@ -3,6 +3,8 @@
// median frame with PERF_GUARD panes stays within FRAME_RATIO_MAX of the one-pane median, a pane that
// was hidden comes back at the scroll position it left, and the transcript does not inherit the animated
// column widths: with a long transcript the fold costs style recalculation of every element under it.
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const CPU = Number(process.env.PERF_CPU ?? 4);
@@ -12,6 +14,8 @@ const COUNTS = (process.env.PERF_N ?? "1,3,6").split(",").map(Number);
const GUARD = Number(process.env.PERF_GUARD ?? 3);
const FRAME_RATIO_MAX = Number(process.env.PERF_FRAME_RATIO ?? 2.5);
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.RAIL_TOGGLE_SHOTS;
if (SHOTS) await mkdir(SHOTS, { recursive: true });
const pct = (a, p) => [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * p))];
const browser = await chromium.launch();
const rows = [];
@@ -62,13 +66,41 @@ for (const n of COUNTS) {
}
rows.push({ panes: n, frames: frames.length, p50: pct(frames, 0.5).toFixed(1), p95: pct(frames, 0.95).toFixed(1), worst: Math.max(...frames).toFixed(1), over50: frames.filter((f) => f > 50).length });
if (n === GUARD) {
await page.evaluate(() => { document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop = 400; });
const first = page.locator('.sessrow[data-target="/sessions/pane-1.jsonl"]');
const second = page.locator('.sessrow[data-target="/sessions/pane-2.jsonl"]');
assert.equal(await first.getAttribute("data-live"), "", "the first row must identify an existing pane");
assert.equal(await second.getAttribute("data-live"), "", "the second row must identify another existing pane");
assert.equal(await first.getAttribute("aria-selected"), "true", "the measured pane must be the first session");
const scroller = await page.locator('.pane:not([data-off]) .scroll[data-pane="flow"]').elementHandle();
assert.ok(scroller);
await scroller.evaluate((root) => { root.scrollTop = 400; });
await page.waitForTimeout(200);
await page.locator(".sessrow").nth(1).click();
await page.waitForTimeout(500);
await page.locator(".sessrow").nth(0).click();
await page.waitForTimeout(500);
kept = await page.evaluate(() => document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop);
assert.equal(await scroller.evaluate((root) => root.scrollTop), 400, "the initial reading position must be reachable");
if (SHOTS) await page.screenshot({ path: `${SHOTS}/before-hidden.png` });
await second.click();
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.ok(await scroller.evaluate((root) => root.closest(".pane").hasAttribute("data-off")), "the original pane must actually be hidden");
assert.equal(await page.locator(".pane").count(), n, "switching existing conversations must not open a new pane");
await page.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "text", text: "Background scroll preservation probe." });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
});
await page.waitForFunction(() => document.querySelector(".pane:not([data-off]) .flow")?.textContent.includes("Background scroll preservation probe."));
if (SHOTS) await page.screenshot({ path: `${SHOTS}/while-hidden.png` });
await first.click();
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-1.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.ok(await scroller.evaluate((root) => root === document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]')), "scroll-retention precondition: inspect the originally hidden transcript");
kept = await scroller.evaluate((root) => root.scrollTop);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-return.png` });
await second.click();
await page.waitForFunction(() => {
if (document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") !== "true") return false;
const root = document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]');
return root && root.scrollHeight - root.clientHeight - root.scrollTop <= 48;
});
console.log(`existing pane round trip: scrollTop 400 -> hidden with new output -> ${kept}; following neighbour remains at latest`);
}
await ctx.close();
}
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const before = process.env.RECENT_BEFORE_URL;
const shots = process.env.RECENT_SHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
const errors = [];
async function settle(page, selector) {
await page.locator(selector).evaluateAll((elements) => Promise.all(elements.flatMap((element) => {
element.getBoundingClientRect();
return element.getAnimations().map((animation) => animation.finished.catch(() => {}));
})));
}
async function showRail(page) {
if (await page.locator(".rail[inert]").count()) {
await page.locator('[data-action="chrome.rail"]:not(.railveil)').filter({ visible: true }).first().click();
}
await page.locator(".rail:not([inert]) .projects-header").waitFor();
await settle(page, ".app, .rail");
}
try {
for (const scheme of ["light", "dark"]) {
for (const [size, width] of [["wide", 1440], ["phone", 390]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
const tag = `${scheme}-${size}`;
if (before && shots) {
await page.goto(`${before}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
await page.screenshot({ path: `${shots}/before-${tag}.png` });
}
await page.goto(`${base}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
if (shots) await page.screenshot({ path: `${shots}/after-heading-${tag}.png` });
await page.locator('.wsrow[data-target="~/projects/workspace-1"]').click();
await page.locator('.studio-session-segments [data-value="live"]').click();
const recent = page.getByRole("button", { name: "Recent sessions", exact: true });
const hit = await recent.evaluate((element) => {
const r = element.getBoundingClientRect();
return r.left >= 0 && r.right <= innerWidth && element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
});
assert.ok(hit, `${tag}: Recent is reachable in the project heading`);
await recent.click();
const dialog = page.getByRole("dialog", { name: "Recent sessions", exact: true });
await dialog.locator(".recent-session").first().waitFor();
await settle(page, ".pickveil");
assert.equal(await dialog.evaluate((element) => getComputedStyle(element).opacity), "1", "the dialog is actually painted");
assert.equal(await dialog.locator(".recent-session").count(), 50);
const bounds = await dialog.locator(".pickcard").boundingBox();
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width && bounds.y >= 0 && bounds.y + bounds.height <= 900, `${tag}: dialog stays inside the viewport`);
if (shots) await page.screenshot({ path: `${shots}/after-recent-${tag}.png` });
for (let i = 1; i <= 6; i++) {
await dialog.getByRole("button", { name: "Next page", exact: true }).click();
await dialog.getByText(`Page ${i + 1}`, { exact: true }).waitFor();
await dialog.locator('.picklist[aria-busy="false"] .recent-session').first().waitFor();
}
assert.equal(await dialog.locator(".recent-session").count(), 50, "history past 300 remains paged rather than hidden");
const selected = dialog.locator('.recent-session[data-target="/sessions/w1-s150.jsonl"]');
await selected.click();
await dialog.waitFor({ state: "detached" });
const located = page.locator('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]');
await located.waitFor();
await page.waitForFunction(() => document.querySelector('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.equal(await page.locator('.studio-session-segments [data-value="all"]').getAttribute("aria-pressed"), "true");
assert.equal(await page.locator('.wsrow[data-target="~/projects/workspace-1"]').getAttribute("aria-expanded"), "true");
if (size === "wide") {
assert.ok(await located.evaluate((element) => element === document.activeElement), `${tag}: selection focuses the session in its project`);
} else {
assert.equal(await page.locator(".app").getAttribute("data-rail"), "off", "phone navigation reveals the conversation");
await showRail(page);
assert.ok(await located.isVisible(), "reopening the phone rail reveals the selected session in its expanded project");
const selectedBounds = await located.boundingBox();
assert.ok(selectedBounds && selectedBounds.x >= 0 && selectedBounds.x + selectedBounds.width <= width, "the selected phone row is on screen, not covered by a closing rail");
}
if (shots) await page.screenshot({ path: `${shots}/after-located-${tag}.png` });
console.log(`${tag}: heading hit target, 50-row global pages beyond 300, viewport containment, finished-session project location`);
await page.close();
}
}
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(`${base}?pref=en&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
await page.locator('.studio-session-segments [data-value="live"]').click();
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
await page.evaluate(() => window.__feed({ kind: "turn_done" }));
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor({ state: "detached" });
await page.getByRole("button", { name: "Recent sessions", exact: true }).click();
await page.locator('.recent-session[data-target="/sessions/mock.jsonl"]').click();
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
console.log("running -> finished -> Recent -> existing pane: passed");
await page.close();
assert.deepEqual(errors, [], "no page errors");
} finally {
await browser.close();
}
+22 -2
View File
@@ -36,8 +36,9 @@ try {
<time class="sessmeta sessage">9 天前</time>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sesscopy">
<div class="sessrow sesscopy" tabindex="0">
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
<button class="wsdel" data-action="session.delete" aria-label="删除该会话"><svg viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" /></svg></button>
</div>
</div>
</div>`);
@@ -96,7 +97,26 @@ try {
assert.ok(contained, "quick actions fit inside the compact row");
}
}
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
// An earlier version has no row menu: its × is the only way a pointer deletes it.
for (const how of ["hover", "focus"]) {
await page.mouse.move(width + 200, 400);
if (how === "hover") await page.locator(".sesscopy").hover();
else await page.locator(".sesscopy").focus();
await page.waitForFunction(() => getComputedStyle(document.querySelector(".sesscopy .wsdel")).opacity === "1", null, { timeout: 1000 }).catch(() => {});
const del = await page.evaluate(() => {
const row = document.querySelector(".sesscopy");
const button = row.querySelector(".wsdel");
const box = button.getBoundingClientRect();
const edge = row.getBoundingClientRect();
return {
shown: box.width > 0 && box.height > 0 && getComputedStyle(button).opacity === "1",
inside: box.top >= edge.top && box.bottom <= edge.bottom && box.left >= edge.left && box.right <= edge.right,
};
});
assert.ok(del.shown, `${scheme}/${width}/${how}: an earlier version shows its delete button`);
assert.ok(del.inside, `${scheme}/${width}/${how}: the delete button sits inside its row`);
}
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable, an earlier version's × shows`);
await page.close();
}
}
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.THEME_SCHEDULE_SHOTS;
if (SHOTS) await mkdir(SHOTS, { recursive: true });
const browser = await chromium.launch();
const errors = [];
try {
for (const width of [1440, 390]) {
const page = await browser.newPage({ viewport: { width, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
await page.clock.install({ time: new Date("2026-10-29T19:58:00-04:00") });
await page.goto(`${BASE}?pref=en&turns=0`);
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /Appearance/ }).click();
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
const start = page.getByLabel("Dark mode starts", { exact: true });
const end = page.getByLabel("Light mode resumes", { exact: true });
assert.equal(await start.inputValue(), "20:00");
assert.equal(await end.inputValue(), "07:00");
const boxes = await page.locator('#set-mode input[type="time"]').evaluateAll((inputs) => inputs.map((input) => {
const box = input.getBoundingClientRect();
return { left: box.left, right: box.right, width: box.width };
}));
assert.ok(boxes.every((box) => box.width > 0 && box.left >= 0 && box.right <= width));
await page.clock.pauseAt(new Date("2026-10-29T19:59:59-04:00"));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "light");
if (SHOTS) {
await page.locator("#set-mode").scrollIntoViewIfNeeded();
await page.screenshot({ path: `${SHOTS}/after-light-${width}.png`, animations: "disabled" });
}
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "dark");
await page.clock.runFor(1000);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-dark-${width}.png`, animations: "disabled" });
await page.clock.fastForward("03:59:58");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("07:00:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await start.fill("21:30");
await end.fill("06:15");
await page.clock.resume();
await page.reload();
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /Appearance/ }).click();
assert.equal(await start.inputValue(), "21:30");
assert.equal(await end.inputValue(), "06:15");
assert.equal(await page.locator('[data-value="scheduled"]').getAttribute("aria-pressed"), "true");
await page.clock.setSystemTime(new Date("2026-10-30T23:00:00-04:00"));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.setSystemTime(new Date("2026-10-31T09:00:00-04:00"));
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.locator('[data-action="appearance.scheme"][data-value="dark"]').click();
assert.equal(await start.count(), 0);
await page.emulateMedia({ colorScheme: "light" });
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.locator('[data-action="appearance.scheme"][data-value="auto"]').click();
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.emulateMedia({ colorScheme: "dark" });
await page.waitForFunction(() => document.documentElement.dataset.theme === "dark");
console.log(`ok ${width}px: exact boundaries, midnight, saved controls/reload, focus/visibility resume, explicit/system modes`);
await page.close();
}
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
await page.clock.install({ time: new Date("2026-11-01T00:59:00-04:00") });
await page.goto(`${BASE}?pref=zh&turns=0&pack=sky`);
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /外观/ }).click();
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
await page.getByLabel("深色开始时间", { exact: true }).fill("01:30");
await page.getByLabel("恢复浅色时间", { exact: true }).fill("01:45");
await page.clock.pauseAt(new Date("2026-11-01T01:29:59-04:00"));
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
assert.equal(await page.locator("html").getAttribute("data-pack"), "bench-sky");
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#080D10");
await page.clock.fastForward("15:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.clock.fastForward("45:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("15:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#F2F6F8");
await page.getByLabel("深色开始时间", { exact: true }).fill("02:30");
await page.getByLabel("恢复浅色时间", { exact: true }).fill("04:00");
await page.clock.setSystemTime(new Date("2026-03-08T01:59:59-05:00"));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.clock.fastForward(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("01:00:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
console.log("ok Chinese controls, active pack repaint, skipped and repeated DST hour transitions");
await page.close();
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
+9 -5
View File
@@ -109,6 +109,7 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
@@ -125,6 +126,10 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-environment", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-page", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -149,6 +154,7 @@ export const ACTIONS: UIAction[] = [
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
{ id: "composer.running-message-intent", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
@@ -511,6 +517,7 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.full-installer", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -562,11 +569,7 @@ export const ACTIONS: UIAction[] = [
// different capability from changing its opacity, so the four sliders share
// one id and say which parameter on data-value.
//
// These stay apart on purpose. theme.activate installs a palette pack;
// appearance.scheme is light, dark or follow-the-system. wallpaper.change
// and wallpaper.remove decide which image there is; appearance.background is
// how the image that is already there is drawn. And weight and contrast are
// two different things a reader wants, however alike they look in CSS.
// A pack supplies colours; the scheme decides when its light or dark set is used.
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -591,6 +594,7 @@ export const ACTIONS: UIAction[] = [
// Kept where the window keeps them: these are this browser's, and no kernel
// state moves.
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.schedule", kind: "view", target: "entity", proof: "browser" },
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
+6 -1
View File
@@ -790,7 +790,12 @@ export const EN: Record<string, string> = {
"内置": "Built in",
"第三方": "Third party",
"{n} 个已装": "{n} installed",
"跟随系统时会随系统主题切换;手动选择后将保持固定。": "Following the system switches with it; once you choose a theme yourself, it stays fixed.",
"跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。": "Follow the system switches with your OS; Light and Dark stay fixed; Scheduled switches daily using this device's clock.",
"定时": "Scheduled",
"深色开始时间": "Dark mode starts",
"恢复浅色时间": "Light mode resumes",
"每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。": "Switches daily in local time, including overnight: dark at the start, light at the end.",
"开始和结束时间相同时全天使用浅色。": "Equal start and end times keep light mode all day.",
// ── 连接与来源 ───────────────────────────────────────────────────
"添加模型来源": "Add a model source",
@@ -104,6 +104,10 @@ export const EN_COMPOSER: Record<string, string> = {
"停下": "Stop",
"发送": "Send",
"插话": "Steer",
"排队发送": "Queue",
"点按排队 · 回车换行": "Tap Queue · Enter for a new line",
"{key} 排队 · Enter 换行": "{key} to queue · Enter for a new line",
"Enter 排队 · Shift+Enter 换行": "Enter to queue · Shift+Enter for a new line",
"正在停止…": "Stopping…",
"正在发送…": "Sending…",
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
@@ -102,6 +102,11 @@ export const EN_SETTINGS: Record<string, string> = {
"开启(15 分钟后)": "On (after 15 minutes)",
"保持打开": "Keep open",
"发送快捷键": "Send shortcut",
"运行中发送消息": "Messages sent while running",
"选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。": "Choose the default for messages sent during a run. Saved on this device only; messages already queued are unchanged.",
"下个工具边界插话": "Steer at the next tool boundary",
"整轮结束后发送": "Send after the whole run",
"可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。": "Use Send now in the queue to stop the current response and start a new turn. Hold, edit, and take back remain available.",
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
"Enter 发送": "Enter to send",
"{key} 发送": "{key} to send",
@@ -1153,6 +1158,8 @@ export const EN_SETTINGS: Record<string, string> = {
"会话中各部分默认展开还是收起。失败的步骤与正在运行的操作可以单独处理;已手动展开或收起的块保持原样。": "Whether each part of a conversation starts open or folded. Failed steps and work still running can be handled on their own; a block you have already opened or closed stays as it is.",
"收起时显示计划进度": "Show collapsed plan progress",
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
"输入框提示": "Composer hint",
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "Show task and queue guidance in the empty composer. On by default; saved only on this machine. Does not affect typing or sending.",
"计划进度": "Plan progress",
"折叠预设": "Folding preset",
"精简": "Compact",
@@ -6,6 +6,13 @@ export const EN_WINDOW: Record<string, string> = {
"打开项目": "Open project",
"项目列表的更多操作": "More project list actions",
"项目与会话": "Projects and conversations",
"最近的会话": "Recent sessions",
"按最近活动排列,包含已完成的会话。选择后定位到所属项目。": "Ordered by last activity, including finished sessions. Select one to locate its project.",
"搜索最近会话 / 项目": "Search recent sessions or projects",
"此运行环境不支持最近会话,请更新 Reasonix": "This environment does not support recent sessions. Update Reasonix.",
"正在读取会话…": "Reading sessions…",
"最近": "Recent",
"第 {n} 页": "Page {n}",
"选择本机文件夹": "Choose a local folder",
"打开第一个项目": "Open your first project",
"展开所有项目": "Expand all projects",
+6
View File
@@ -53,6 +53,7 @@ export interface HostPort {
toggleMaximiseWindow(): void;
isWindowMaximised(): Promise<boolean>;
closeWindow(): void;
syncTheme(mode: string, scheme: "light" | "dark"): void;
/** Whether this shell can hand back a picture of its own window. */
capturesWindow(): boolean;
/** A PNG of this window as drawn now; null when the shell cannot or the
@@ -189,6 +190,7 @@ interface ElectronBridge {
toggleMaximiseWindow(): Promise<void>;
isWindowMaximised(): Promise<boolean>;
closeWindow(): Promise<void>;
syncTheme?(mode: string, scheme: "light" | "dark"): void;
captureWindow?(): Promise<Uint8Array | null>;
graphics?(): Promise<GraphicsInfo | null>;
globalShortcut?(): Promise<GlobalShortcutInfo | null>;
@@ -250,6 +252,9 @@ class ElectronHost implements HostPort {
closeWindow() {
void this.api.closeWindow();
}
syncTheme(mode: string, scheme: "light" | "dark") {
this.api.syncTheme?.(mode, scheme);
}
capturesWindow() {
return typeof this.api.captureWindow === "function";
}
@@ -370,6 +375,7 @@ class BrowserHost implements HostPort {
captureWindow() {
return Promise.resolve(null);
}
syncTheme() {}
showTextContextMenu() {
return Promise.resolve(null);
}
@@ -49,6 +49,23 @@ it("keeps local project moves and host-qualified pin/archive calls on their owni
} finally { vi.unstubAllGlobals(); }
});
it("carries the same global recent-page query to local and remote tree owners", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal("fetch", fetch);
try {
const hub = new SseHub();
const window = { order: "recent" as const, scope: "active" as const, offset: 350, limit: 50, hideLegacy: true, q: "release plan" };
await hub.tree(window);
await hub.remoteTree("gpu box", window);
const paths = fetch.mock.calls.map(([path]) => new URL(path, "http://localhost"));
expect(paths[0].pathname).toBe("/tree");
expect(paths[1].pathname).toBe("/remotes/gpu%20box/tree");
for (const path of paths) {
expect(Object.fromEntries(path.searchParams)).toMatchObject({ order: "recent", scope: "active", offset: "350", limit: "50", hideLegacy: "1", q: "release plan" });
}
} finally { vi.unstubAllGlobals(); }
});
// The same defect SseHttp was fixed for, still standing in the hub port: asking
// for JSON first threw away every answer that was not an envelope, and what
// reached the window was "this request did not reach the kernel (HTTP 502)" —
+17 -9
View File
@@ -111,6 +111,8 @@ export interface TreeWorkspace {
countsComplete?: boolean;
pendingMetadata?: number;
failedMetadata?: number;
// Echoed only by kernels that serve globally ordered recent pages.
recentOffset?: number;
root: string;
name: string;
workspaceRoots?: string[];
@@ -125,8 +127,7 @@ export interface TreeWorkspace {
// paging sends neither, and then `sessions` is all there is.
total?: number;
archived?: number;
// How many rows the request's scope and word match that `sessions` does not
// carry. Absent on a kernel that predates it, and then only `total` says.
// Matching rows after this page/window. Absent on kernels predating paging.
remaining?: number;
sessions: TreeSession[];
}
@@ -135,14 +136,18 @@ export function workspaceContains(workspace: TreeWorkspace, root: string | undef
return root !== undefined && (workspace.root === root || !!workspace.workspaceRoots?.includes(root));
}
// How much of each folder tree() builds: `limit` for all of them (0 is every
// row), `limits` overriding it per folder root.
// The sessions a tree request counts, independently of its ordering.
export type TreeScope = "" | "active" | "archived";
// `scope` says which rows the window counts (active ones, only the archived, or
// every row) and `q` keeps them to those whose title or name contains it.
// Ordinary windows bound each project; recent windows page across all projects.
// `q` filters titles/names and project names before either window is selected.
export interface TreeWindow {
retryMetadata?: boolean;
order?: "recent";
// Recent pages are global across projects, unlike each project's tree window.
offset?: number;
hideLegacy?: boolean;
// 0 means all rows; `limits` overrides this per project in ordinary windows.
limit?: number;
limits?: Record<string, number>;
scope?: TreeScope;
@@ -152,6 +157,9 @@ export interface TreeWindow {
export function treeQuery(window: TreeWindow | undefined): string {
const q = new URLSearchParams();
if (window?.retryMetadata) q.set("retryMetadata", "1");
if (window?.order) q.set("order", window.order);
if (window?.offset !== undefined) q.set("offset", String(window.offset));
if (window?.hideLegacy) q.set("hideLegacy", "1");
if (window?.limit !== undefined) q.set("limit", String(window.limit));
if (window?.limits && Object.keys(window.limits).length > 0) q.set("limits", JSON.stringify(window.limits));
if (window?.scope) q.set("scope", window.scope);
@@ -222,7 +230,7 @@ export interface HubPort extends SharePort {
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }): Promise<RuntimeView>;
// The far machine's own workspaces, or null while nothing is open on it:
// there is no kernel over there to ask until a pane puts one there.
remoteTree(host: string): Promise<TreeWorkspace[] | null>;
remoteTree(host: string, window?: TreeWindow): Promise<TreeWorkspace[] | null>;
// Erases a conversation on that machine; its kernel decides whether it may.
removeRemoteSession(host: string, path: string): Promise<void>;
// Folders on the far machine, read over the link alone — no kernel has to be
@@ -475,8 +483,8 @@ export class SseHub implements HubPort {
await this.post<void>(`/remotes/${encodeURIComponent(host)}/sessions/remove`, { path });
}
async remoteTree(host: string) {
const path = `/remotes/${encodeURIComponent(host)}/tree`;
async remoteTree(host: string, window?: TreeWindow) {
const path = `/remotes/${encodeURIComponent(host)}/tree` + treeQuery(window);
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) await SseHub.fail(path, res);
return (await res.json()) as TreeWorkspace[];
@@ -16,3 +16,16 @@ it("keeps session placement and pin/archive flags in the same mock tree", async
expect(archived.map((s) => s.path)).toEqual([second.path, first.path]);
expect(archived[0]).toMatchObject({ archived: true, pinned: false });
});
it("retains seeded history while closing and reopening its pane with a fresh identity", async () => {
const hub = new MockHub(3);
const original = (await hub.runtimes())[1];
await hub.close(original.id);
const closed = (await hub.tree()).flatMap((workspace) => workspace.sessions).find((session) => session.path === original.sessionPath);
expect(closed).toBeDefined();
expect(closed?.runtimeId).toBeUndefined();
const reopened = await hub.open({ root: original.root, sessionPath: original.sessionPath });
expect(reopened.id).not.toBe(original.id);
expect((await hub.runtimes()).find((runtime) => runtime.id === reopened.id)?.sessionPath).toBe(original.sessionPath);
expect((await hub.tree()).flatMap((workspace) => workspace.sessions).find((session) => session.path === original.sessionPath)?.runtimeId).toBe(reopened.id);
});
+38 -11
View File
@@ -12,13 +12,24 @@ export class MockHub implements HubPort {
private readonly machine = { configured: false };
private readonly settings = new MockPort();
private readonly remoteSettings = new Map<string, MockPort>();
private readonly views: RuntimeView[] = [
{ id: "r1", base: "", root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", sessionPath: "/sessions/mock.jsonl" },
];
private readonly views: RuntimeView[];
private readonly seeded: TreeSession[];
private readonly roots = ["~/projects/DeepSeek-Reasonix", "~/projects/my-website"];
private seq = 1;
private seq: number;
private readonly flags = new Map<string, Pick<TreeSession, "pinned" | "archived" | "unread">>();
constructor(panes = 1) {
this.seeded = panes > 1 ? Array.from({ length: panes }, (_, i) => ({
path: `/sessions/pane-${i + 1}.jsonl`, name: `pane-${i + 1}`, title: `并行会话 ${i + 1}`, turns: 3,
})) : [];
const initial = panes === 1 ? [{ path: "/sessions/mock.jsonl" }] : this.seeded;
this.views = initial.map((session, i) => ({
id: `r${i + 1}`, base: i === 0 ? "" : `/rt/r${i + 1}`, root: this.roots[0],
name: "DeepSeek-Reasonix", sessionPath: session.path,
}));
this.seq = this.views.length;
}
private decorate(tree: TreeWorkspace[], host = ""): TreeWorkspace[] {
return tree.map((ws) => ({ ...ws, sessions: ws.sessions.map((session) => ({ ...session,
...this.flags.get(JSON.stringify([host, session.path])) })) }));
@@ -68,9 +79,9 @@ export class MockHub implements HubPort {
// The fixture's own far machine: one workspace with a conversation in it, so
// the tree under a connected host can be designed without one.
remoteTree(host: string) {
remoteTree(host: string, window?: TreeWindow) {
if (host !== "gpu-box") return Promise.resolve(null);
return Promise.resolve<TreeWorkspace[]>(this.decorate([
return Promise.resolve<TreeWorkspace[]>(this.recentTree(this.decorate([
{
root: "/srv/training",
name: "training",
@@ -80,7 +91,7 @@ export class MockHub implements HubPort {
],
},
{ root: "/srv/scratch", name: "scratch", sessions: [] },
], host));
], host), window));
}
async openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?: string }) {
@@ -183,25 +194,41 @@ export class MockHub implements HubPort {
onTreeChanged(_listener: () => void) { return () => {}; }
tree(_window?: TreeWindow) {
tree(window?: TreeWindow) {
const open = new Map(this.views.filter((v) => v.sessionPath).map((v) => [v.sessionPath!, v.id]));
return Promise.resolve(
this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
this.recentTree(this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
root,
name: root.split("/").pop() ?? root,
open: this.views.some((v) => v.root === root),
remembered: true,
sessions: this.placed(root,
i === 0
? [
? this.seeded.length ? this.seeded.map((session) => ({ ...session, runtimeId: open.get(session.path) })) : [
{ path: "/sessions/mock.jsonl", name: "mock", title: "并行会话演示", turns: 3, runtimeId: open.get("/sessions/mock.jsonl") },
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl"), legacy: true },
]
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }]),
}))),
}))), window),
);
}
protected recentTree(tree: TreeWorkspace[], window?: TreeWindow): TreeWorkspace[] {
if (window?.order !== "recent") return tree;
const q = window.q?.trim().toLowerCase() || "";
const rows = tree.flatMap((ws, workspace) => ws.sessions
.filter((session) => !session.archived && !(window.hideLegacy && !q && session.legacy) &&
(!q || ws.name.toLowerCase().includes(q) || (session.title || session.name).toLowerCase().includes(q)))
.map((session) => ({ workspace, session })))
.sort((a, b) => (b.session.lastActiveAt ?? 0) - (a.session.lastActiveAt ?? 0) || (a.session.path < b.session.path ? -1 : a.session.path > b.session.path ? 1 : 0));
const offset = window.offset ?? 0;
const end = window.limit === 0 ? rows.length : offset + (window.limit ?? 50);
return tree.map((ws, workspace) => ({ ...ws, recentOffset: offset,
remaining: rows.slice(end).filter((row) => row.workspace === workspace).length,
sessions: rows.slice(offset, end).filter((row) => row.workspace === workspace).map((row) => row.session),
}));
}
addWorkspace(path: string) {
if (!this.roots.includes(path)) this.roots.push(path);
return Promise.resolve<TreeWorkspace>({ root: path, name: path.split("/").pop() ?? path, remembered: true, sessions: [] });
+22 -2
View File
@@ -1,8 +1,28 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, setLiveGraceMinutes, setOpenSessionLimit, onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "./prefs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, runningMessageIntent, setLiveGraceMinutes, setOpenSessionLimit, setRunningMessageIntent, onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "./prefs";
beforeEach(() => localStorage.clear());
afterEach(() => { vi.restoreAllMocks(); setRunningMessageIntent("steer"); localStorage.clear(); });
describe("default intent for messages sent during a run", () => {
it("defaults to steering and reads only the offered follow-up choice", () => {
expect(runningMessageIntent()).toBe("steer");
for (const raw of ["", "queued", "invalid"]) {
localStorage.setItem("rx-running-message-intent", raw);
expect(runningMessageIntent()).toBe("steer");
}
setRunningMessageIntent("followup");
expect(runningMessageIntent()).toBe("followup");
expect(localStorage.getItem("rx-running-message-intent")).toBe("followup");
});
it("holds the choice for this window if storage cannot be written", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("unavailable"); });
setRunningMessageIntent("followup");
expect(runningMessageIntent()).toBe("followup");
});
});
describe("collapsed plan progress on this machine", () => {
it("is off by default and only accepts an explicit on preference", () => {
+61
View File
@@ -160,6 +160,33 @@ export function setNavRailMode(mode: NavRailMode): void {
navRailListeners.forEach((fn) => fn());
}
const COMPOSER_HINT_KEY = "rx-composer-hint";
const composerHintListeners = new Set<() => void>();
export function showsComposerHint(): boolean {
try {
return localStorage.getItem(COMPOSER_HINT_KEY) !== "off";
} catch {
return true;
}
}
export function onComposerHintChange(fn: () => void): () => void {
composerHintListeners.add(fn);
return () => {
composerHintListeners.delete(fn);
};
}
export function setShowsComposerHint(on: boolean): void {
try {
localStorage.setItem(COMPOSER_HINT_KEY, on ? "on" : "off");
} catch {
/* unavailable storage keeps the default */
}
composerHintListeners.forEach((fn) => fn());
}
const PLAN_PROGRESS_KEY = "rx-plan-progress";
const planProgressListeners = new Set<() => void>();
@@ -515,6 +542,40 @@ export function useSendShortcut(): SendShortcut {
return useSyncExternalStore(subscribeSendShortcut, sendShortcut, sendShortcut);
}
export type RunningMessageIntent = "steer" | "followup";
const RUNNING_MESSAGE_KEY = "rx-running-message-intent";
const runningMessageListeners = new Set<() => void>();
let transientRunningMessage: RunningMessageIntent = "steer";
let runningMessageStorageFailed = false;
export function runningMessageIntent(): RunningMessageIntent {
if (runningMessageStorageFailed) return transientRunningMessage;
try {
return localStorage.getItem(RUNNING_MESSAGE_KEY) === "followup" ? "followup" : "steer";
} catch {
return transientRunningMessage;
}
}
export function setRunningMessageIntent(next: RunningMessageIntent): void {
transientRunningMessage = next;
try { localStorage.setItem(RUNNING_MESSAGE_KEY, next); runningMessageStorageFailed = false; } catch { runningMessageStorageFailed = true; }
runningMessageListeners.forEach((fn) => fn());
}
function subscribeRunningMessage(fn: () => void): () => void {
runningMessageListeners.add(fn);
const storage = (event: StorageEvent) => {
if (event.key === RUNNING_MESSAGE_KEY || event.key === null) fn();
};
window.addEventListener("storage", storage);
return () => { runningMessageListeners.delete(fn); window.removeEventListener("storage", storage); };
}
export function useRunningMessageIntent(): RunningMessageIntent {
return useSyncExternalStore(subscribeRunningMessage, runningMessageIntent, runningMessageIntent);
}
// The sidebar remains the default session switcher. Tabs are a local display
// choice and do not change which sessions stay open in the kernel.
const SESSION_TABS_KEY = "rx-show-session-tabs";
@@ -0,0 +1,51 @@
export interface ThemeSchedule { start: string; end: string }
const KEY = "rx-theme-schedule";
const DEFAULT: ThemeSchedule = { start: "20:00", end: "07:00" };
const listeners = new Set<() => void>();
let cached = DEFAULT;
let lastRaw: string | null | undefined;
let volatile: ThemeSchedule | null = null;
function valid(value: unknown): value is ThemeSchedule {
if (!value || typeof value !== "object") return false;
const { start, end } = value as ThemeSchedule;
return typeof start === "string" && typeof end === "string" &&
/^([01]\d|2[0-3]):[0-5]\d$/.test(start) && /^([01]\d|2[0-3]):[0-5]\d$/.test(end);
}
export function themeSchedule(): ThemeSchedule {
if (volatile) return volatile;
try {
const raw = localStorage.getItem(KEY);
if (raw !== lastRaw) {
lastRaw = raw;
cached = DEFAULT;
try {
const value: unknown = JSON.parse(raw ?? "null");
if (valid(value)) cached = { start: value.start, end: value.end };
} catch { /* unreadable preferences keep the default */ }
}
return cached;
} catch {
return cached;
}
}
export function onThemeScheduleChange(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
export function setThemeSchedule(next: ThemeSchedule): void {
if (!valid(next)) return;
cached = { start: next.start, end: next.end };
try {
lastRaw = JSON.stringify(cached);
localStorage.setItem(KEY, lastRaw);
volatile = null;
} catch {
volatile = cached;
}
listeners.forEach((fn) => fn());
}
@@ -1690,6 +1690,7 @@ A sub-agent row, by the list's own width:
- Project headings have no separate chevron. Clicking a heading or using the tree arrow keys toggles its conversations; known empty projects do not advertise expansion. The heading toolbar switches all projects together, while search reveals matching conversations independently of saved folds.
- The project toolbar keeps collapse/expand beside the label and open-project plus and overflow at the right. The plus opens the common local/SSH location picker; project-row chat icons create conversations. Empty lists retain a text entry.
- Each project previews five recent sessions and keeps an older active/opening session visible. Search and explicit scopes show their matches. Show more reveals loaded rows and uses the existing bounded kernel paging path.
- Recent stays in the project toolbar rather than adding a fifth session segment. Its modal shows session identity, owning project and activity age. A separate page-status line keeps the three footer buttons on one row at phone widths.
- Time metadata comes only from canonical last-active timestamps. Loaded/total session counts remain available to assistive technology without visible badges.
- The sidebar defaults to 316px, respects saved widths and retains its existing 232px floor. Folder glyphs, titles, action slots and conversation indents align across environments. Hover actions reserve their space.
- The diagonal toolbar glyphs reuse Tabler's MIT-licensed [collapse][tabler-collapse] and [expand][tabler-expand] paths through the existing Studio icon component.
+12 -2
View File
@@ -59,6 +59,15 @@ and at every contrast step, so nothing paints differently until a pack sets one.
- The running compose ring, call lines, pips, carets and the delegated nest keep
`net` and `deleg`.
## Composer guidance
- Composer guidance keeps its theme-owned `--ghost` ink at full opacity. The
Appearance visibility preference suppresses the placeholder text at its
choice owner, not by lowering contrast or changing shared neutral inks.
- `perf/composer-placeholder.mjs` checks the default-on preference, live
visibility changes and reload persistence through Appearance, alongside
entered text, focus, IME, submission and queued guidance.
## Elevation
| Pack key | Variable | Default | Job |
@@ -337,8 +346,9 @@ pack states a shape, never a number the layout has to agree with.
colour cannot be added to it.
- The neutral ink the roles set on buttons would land on a primary action's
fill, where the component pairs `--accent-fg` with `--accent`; an enabled
`[data-primary]` button therefore keeps `--accent-fg`, and nothing else is
recoloured.
`[data-primary]` button therefore keeps `--accent-fg` and carries its
`--accent` fill with it, so a container with no fill rule of its own never
shows light text on no fill. Nothing else is recoloured.
## Weight and contrast
+4 -1
View File
@@ -2617,6 +2617,7 @@
.prefs-nav::-webkit-scrollbar { display: none }
.prefs-nav .navsec, .prefs-nav .nv { display: none }
.prefs-nav button, .prefs-nav button.prefs-page { flex: none; min-height: 32px; padding: 6px 10px }
.prefs-nav .nm { flex: none; min-width: 0 }
.prefs-nav button::before {
left: 50%; top: auto; bottom: 0; width: 0; height: 2px;
transform: translateX(-50%); transition: width var(--t-card) var(--ease-spring);
@@ -2628,7 +2629,7 @@
@media (max-width: 560px) {
.prefs { padding: 0 }
.prefs-sheet { border-inline: 0; border-bottom: 0; border-radius: 0 }
.prefs-hd { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding: 12px 14px }
.prefs-hd { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 10px; padding: 12px 14px }
.prefs-find { grid-column: 1 / -1; grid-row: 2; width: 100%; max-width: none; margin-left: 0 }
.prefs-found { right: 14px; width: calc(100% - 28px) }
.prefs-main { --main-pad-b: 40px; padding: 20px 14px var(--main-pad-b) }
@@ -5027,6 +5028,8 @@
}
.fontown::placeholder { color: var(--ghost) }
.fontown:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px }
.fontown[type="time"] { color-scheme: light }
:root[data-theme="dark"] .fontown[type="time"] { color-scheme: dark }
.fontsample {
padding: 7px 10px; border-radius: var(--r-sm); background: var(--overlay);
@@ -6,4 +6,4 @@
.prefs-col :is(.v, .n, text, .composition-builtin-description) { font-weight: var(--w-reg); }
.prefs-col :is(.cur, div.v) { font-size: var(--fs-display); font-weight: var(--w-semi); }
.prefs-col .mi-mono { font-size: var(--fs-meta); }
.prefs-col :is(.btn, .act)[data-primary]:not(:disabled) { color: var(--accent-fg); }
.prefs-col :is(.btn, .act)[data-primary]:not(:disabled) { color: var(--accent-fg); background: var(--accent); border-color: var(--accent); }
@@ -25,4 +25,5 @@ it("only ever names a neutral text colour from the two tiers, bar a primary acti
it("lets an enabled primary action keep the foreground its fill is paired with", () => {
const rule = css.match(/\.prefs-col :is\(\.btn, \.act\)\[data-primary\]:not\(:disabled\)\s*\{([^}]*)\}/)?.[1] ?? "";
expect(rule).toMatch(/color:\s*var\(--accent-fg\)/);
expect(rule, "the foreground must travel with its fill, or a container without a fill rule shows light text on nothing").toMatch(/background:\s*var\(--accent\)/);
});
+1 -1
View File
@@ -1682,6 +1682,7 @@ body { background: var(--page) !important; }
.onb-foot { flex: none; padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 calc(10px * var(--fs-scale, 1))/1 var(--ui); }
@media (max-width: 760px) {
.studio-brand-lockup { padding-left: 14px; }
.studio-brand-sub { display: none; }
.flow, .flow:not([data-empty]) { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
.compose { width: calc(100% - 20px); margin-inline: auto; }
@@ -1936,7 +1937,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
@keyframes studio-session-run { 55%, 100% { transform: translateX(110%); } }
@keyframes studio-session-wait { 50% { background: color-mix(in srgb, var(--warn) 8%, transparent); } }
@keyframes studio-session-done { from { background: color-mix(in srgb, var(--ok) 18%, transparent); } }
.rail .sessrow > .wsdel { display: none; }
.rail .session-more {
position: absolute; top: 7px; right: 4px;
width: 27px;
@@ -4,6 +4,7 @@
.projects-heading-title { display: flex; align-items: center; gap: 6px; }
.projects-add { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: 6px; padding: 0; background: transparent; color: var(--muted); cursor: pointer; }
.projects-add .studio-icon { width: 16px; height: 16px; }
.projects-add.projects-recent { width: auto; padding-inline: 6px; font: inherit; }
.projects-add:hover:not(:disabled), .projects-heading .wsmore:is(:hover, [aria-expanded="true"]) { background: var(--overlay); color: var(--text); }
.projects-add:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
.projects-add:disabled { opacity: .4; cursor: default; }
@@ -63,3 +64,15 @@
@media (hover: none) { .rowbtns { opacity: 1; } }
.studio-session-segments button { font-size: calc(12px * var(--fs-scale, 1)); }
.studio-session-segments b { font-size: calc(10px * var(--fs-scale, 1)); }
.recent-description { margin: 0 0 12px; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.recent-machine { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--muted); }
.recent-machine select { flex: 1; min-width: 0; padding: 5px 8px; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xs); font: var(--w-reg) var(--fs-ui) var(--ui); }
.recent-sessions .picklist { min-height: 160px; }
.recent-session { min-height: 48px; }
.recent-session .recent-session-text { display: flex; flex: 1; flex-direction: column; gap: 3px; }
.recent-session-text b, .recent-session-text small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-session-text b { color: var(--text); font-weight: var(--w-med); }
.recent-session-text small { color: var(--muted); font-size: var(--fs-micro); }
.recent-session .sessage { flex: none; margin-left: auto; color: var(--muted); }
.recent-sessions .pickact { flex-wrap: wrap; }
.recent-page { order: -1; flex-basis: 100%; text-align: center; white-space: nowrap; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
+2
View File
@@ -84,6 +84,7 @@ const Settings = lazy(async () => {
const NO_REPORT: PaneReport = {
status: null,
workspace: null,
title: "",
steer: 0,
run: "idle",
@@ -561,6 +562,7 @@ export function App({ hub }: { hub: HubPort }) {
host={runtimes.find((rt) => rt.id === active)?.host}
port={activePort}
status={report.status}
workspace={report.workspace}
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
steer={report.steer}
account={account}
+9 -2
View File
@@ -19,12 +19,14 @@ import { OpenSessionLimitSection } from "./OpenSessionLimitSection";
import { LiveStreamBudgetSection } from "./LiveStreamBudgetSection";
import { NewAnswerSection } from "./NewAnswerSection";
import { PlanProgressSection } from "./PlanProgressSection";
import { ComposerHintSection } from "./ComposerHintSection";
import { SessionQuickSection } from "./SessionQuickSection";
import { NavEntriesSection } from "./NavEntriesSection";
import { NavRailSection } from "./NavRailSection";
import { GraphicsSection } from "./GraphicsSection";
import { GlobalShortcutSection } from "./GlobalShortcutSection";
import { SendShortcut } from "./SendShortcut";
import { RunningMessageSetting } from "./RunningMessageSetting";
import { KeysSheet } from "./KeysSheet";
import { SessionTabsSetting } from "./SessionTabsSetting";
import { EscStopSetting } from "./EscStopSetting";
@@ -34,6 +36,7 @@ import { ThemeRemove } from "./ThemeRemove";
import { WallpaperSceneImport } from "./WallpaperSceneImport";
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
import { useThemeInventory } from "./useThemeInventory";
import { ThemeScheduleFields } from "./ThemeScheduleFields";
// "" follows the machine; the rest are explicit, the same shape the light/dark
// control uses.
@@ -48,6 +51,7 @@ export const SCHEMES: [string, string][] = [
["auto", "跟随系统"],
["light", "浅色"],
["dark", "深色"],
["scheduled", "定时"],
];
interface Props {
@@ -233,7 +237,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<div className="grp-hd">
<h3>{t("明暗")}</h3>
</div>
<p className="hint">{t("跟随系统时会随系统主题切换;手动选择后将保持固定。")}</p>
<p className="hint">{t("跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。")}</p>
<div className="grp-items">
<div className="seg" data-text role="group" aria-label={t("明暗")}>
{SCHEMES.map(([id, name]) => (
@@ -242,6 +246,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</button>
))}
</div>
{theme === "scheduled" && <ThemeScheduleFields />}
</div>
<ApplyNote id="mode" />
</section>
@@ -570,9 +575,11 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<NewAnswerSection />
<GraphicsSection />
<SendShortcut />
<ComposerHintSection />
<RunningMessageSetting />
<EscStopSetting />
<GlobalShortcutSection />
<KeysSheet />
<EscStopSetting />
<PageSleepSetting />
<SessionTabsSetting />
<Folding />
+2 -8
View File
@@ -28,6 +28,7 @@ interface Props {
// controls that need a session simply do nothing until one is focused.
port: AgentPort | null;
status: SessionStatus | null;
workspace: WorkspaceInfo | null;
title?: string;
steer: number;
account: AccountState | null;
@@ -44,7 +45,7 @@ interface Props {
onError?: (e: unknown) => void;
}
export function Chrome({ port, status, title, steer, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
export function Chrome({ port, status, workspace: ws, title, steer, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
const root = status?.workspaceRoot || status?.cwd || "";
const project = root ? base(root) : "—";
// The folder mark opens the project itself. Only a shell that can enter a
@@ -56,13 +57,6 @@ export function Chrome({ port, status, title, steer, account, host, theme, onThe
const openProject = () => {
if (hub && root) void hub.openWorkspace(root).catch((e) => onError?.(e));
};
const [ws, setWs] = useState<WorkspaceInfo | null>(null);
useEffect(() => {
let current = true;
setWs(null);
if (port) port.workspaces().then((value) => { if (current) setWs(value); }).catch(() => { if (current) setWs(null); });
return () => { current = false; };
}, [port, root]);
const gathered = useFolded("bar");
const [more, setMore] = useState(false);
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Composer } from "./Composer";
import { ComposerHintSection } from "./ComposerHintSection";
import { MockPort } from "../port/mock";
import { boot, STORAGE } from "../i18n";
import { onComposerHintChange, setShowsComposerHint, showsComposerHint } from "../state/prefs";
beforeEach(() => { localStorage.clear(); localStorage.setItem(STORAGE, "zh"); boot(); });
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
function draw(queued = false) {
const onSubmit = vi.fn(async () => true);
const view = render(<Composer port={new MockPort()} status={null} running={queued} queued={queued} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()} />);
return { ...view, onSubmit, box: screen.getByRole<HTMLTextAreaElement>("combobox", { name: "任务输入" }) };
}
describe("composer hint visibility", () => {
it("defaults on, accepts only explicit off, and tolerates unavailable storage", () => {
expect(showsComposerHint()).toBe(true);
for (const value of ["", "on", "false", "garbage"]) {
localStorage.setItem("rx-composer-hint", value);
expect(showsComposerHint()).toBe(true);
}
setShowsComposerHint(false);
expect(showsComposerHint()).toBe(false);
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
expect(showsComposerHint()).toBe(true);
expect(() => setShowsComposerHint(false)).not.toThrow();
get.mockRestore();
set.mockRestore();
});
it("notifies mounted subscribers and removes them on unsubscribe", () => {
const heard = vi.fn();
const stop = onComposerHintChange(heard);
setShowsComposerHint(false);
stop();
setShowsComposerHint(true);
expect(heard).toHaveBeenCalledTimes(1);
});
it.each([false, true])("applies immediately and persists on remount (queued=%s)", async (queued) => {
const settings = render(<ComposerHintSection />);
const view = draw(queued);
expect(view.box.placeholder.length).toBeGreaterThan(0);
await userEvent.click(within(settings.container).getByRole("button", { name: "关闭" }));
expect(view.box.placeholder).toBe("");
expect(view.box.getAttribute("aria-label")).toBe("任务输入");
expect(view.box.getAttribute("aria-describedby")).toBeTruthy();
expect(localStorage.getItem("rx-composer-hint")).toBe("off");
view.unmount();
const remounted = draw(queued);
expect(remounted.box.placeholder).toBe("");
await userEvent.click(within(settings.container).getByRole("button", { name: "显示" }));
await waitFor(() => expect(remounted.box.placeholder.length).toBeGreaterThan(0));
expect(localStorage.getItem("rx-composer-hint")).toBe("on");
});
it("keeps entered text, focus, IME confirmation and submission when hidden", async () => {
localStorage.setItem("rx-composer-hint", "off");
const { box, onSubmit } = draw();
await userEvent.type(box, "implement the change");
expect(box.value).toBe("implement the change");
expect(document.activeElement).toBe(box);
fireEvent.compositionStart(box);
fireEvent.keyDown(box, { key: "Enter", isComposing: true });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.compositionEnd(box);
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 120);
fireEvent.keyDown(box, { key: "Enter" });
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("implement the change"));
});
});
+14 -11
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit, WorkspaceInfo } from "../port/port";
import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
@@ -18,7 +18,7 @@ import { ChipMirror, useSkillChips } from "./ChipMirror";
import { useIme } from "./ime";
import { usePlaceholderFit } from "./placeholderfit";
import { useFitHeight } from "./fitHeight";
import { useSendShortcut } from "../state/prefs";
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
import { sendAria, sendHint, sendsOnKey } from "./sendkey";
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { kindOf, nameOf, previewURL } from "./chipfile";
@@ -68,10 +68,10 @@ interface Props {
sidePanel?: ReactNode;
changeCount?: number;
countElsewhere?: boolean;
// The work tree's Git state as git itself reports it, refreshed on the same
// path as changeCount (turn boundaries, writes). null is "not answered yet";
// repo:false is a workspace with no repository, and the two render apart.
// Git state follows changeCount's refresh path (turn boundaries and writes).
// null is unread; repo:false is a workspace without a repository.
git?: WorkspaceGit | null;
workspace?: WorkspaceInfo | null;
// The switch already answers with the workspace's Git state.
onTreeChanged?: (git: WorkspaceGit) => void;
// Bumped when settings change; a source edited there can change the ladder.
@@ -79,7 +79,7 @@ interface Props {
draftKey?: string;
}
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, workspace = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
const touch = touchKeyboard();
const providerOrder = useProviderOrder();
const modelGroups = useModelFilter();
@@ -195,6 +195,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
}, chips, addSession);
const ime = useIme();
const sendShortcut = useSendShortcut();
const runningIntent = useRunningMessageIntent();
// A counter, not a boolean: asking twice in a row has to move the cursor twice.
useEffect(() => {
@@ -217,7 +218,9 @@ export function Composer({ port, status, running, queued = false, quote, restore
pending.current = null;
}, []);
useFitHeight(box, text, placeCaret);
const placeholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
const showHint = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
const fittedPlaceholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
const placeholder = showHint ? fittedPlaceholder : "";
// Attachments ride into the turn as path references, exactly as they do from
// the CLI — the host saved the bytes, the turn parser resolves the token. A
@@ -637,7 +640,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
? t("正在添加附件…")
: failed
? t("有附件添加失败,请重试或移除")
: sendHint(sendShortcut, running, touch)}
: sendHint(sendShortcut, running, touch, runningIntent)}
</span>
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
</div>
@@ -691,7 +694,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("计划")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
/>
</div>
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} workspace={workspace} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
kernel turns off the moment a plan is approved. The lifecycle is a
separate reading — an approved plan is still running, and saying so
@@ -788,7 +791,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
<path d="M8 12.8V3.4M4.2 7.2 8 3.4l3.8 3.8" />
</svg>
</span>
<span>{t(submitting ? "正在发送…" : running ? "插话" : "发送")}</span>
<span>{t(submitting ? "正在发送…" : running ? runningIntent === "followup" ? "排队发送" : "插话" : "发送")}</span>
</button>}
</span>
</div>
@@ -0,0 +1,16 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { onComposerHintChange, setShowsComposerHint, showsComposerHint } from "../state/prefs";
import { Group } from "./Group";
export function ComposerHintSection() {
const on = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
return (
<Group id="composer-hint" title={t("输入框提示")} hint={t("在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。")}>
<div className="seg" data-text role="group" aria-label={t("输入框提示")}>
<button data-action="appearance.composer-hint" data-value="off" aria-pressed={!on} onClick={() => setShowsComposerHint(false)}>{t("关闭")}</button>
<button data-action="appearance.composer-hint" data-value="on" aria-pressed={on} onClick={() => setShowsComposerHint(true)}>{t("显示")}</button>
</div>
</Group>
);
}
+5 -2
View File
@@ -53,6 +53,7 @@ import { Find } from "./Find";
import { useFind } from "./usefind";
import { RunAnalysis } from "./RunAnalysis";
import { useBrowserTabs } from "./BrowserPanel";
import { useWorkspaceInfo } from "./useWorkspaceInfo";
import { useRevealAgentPages, useRevealBrowserOpen } from "./browserreveal";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { useBrowserFull } from "./browserfull";
@@ -102,6 +103,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
const status = sessionRead.kind === "settled" ? sessionRead.status : null;
const ready = sessionRead.kind === "settled";
const workspace = useWorkspaceInfo(port, status?.workspaceRoot || status?.cwd || rt.root, ready);
const [tab, showView] = useShowView(rt.id, onManualBrowser);
const [sideAsk, setSideAsk] = useState(false);
const [worktreeTarget, setWorktreeTarget] = useState<HTMLDivElement | null>(null);
@@ -463,6 +465,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
useEffect(() => {
onReport(rt.id, {
status,
workspace,
title,
steer: waiting(queue).filter((it) => it.intent === "steer").length,
run: trustWaiting && run === "idle" ? "halt" : run,
@@ -477,7 +480,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
rows: traj.rows.length,
surfaces,
});
}, [rt.id, onReport, status, title, queue, run, running, blocked, trustWaiting, cost, contextPercent, ctx, mcp, walletDisplay, s.interactions, docked, tab, traj.rows.length, surfaces]);
}, [rt.id, onReport, status, workspace, title, queue, run, running, blocked, trustWaiting, cost, contextPercent, ctx, mcp, walletDisplay, s.interactions, docked, tab, traj.rows.length, surfaces]);
return (
<section
@@ -636,7 +639,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
{alert && <div className="cmpalert">{alert}</div>}
<RootMissingBanner items={s.items} onPick={onOpenProject} />
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} workspace={workspace} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<MeterRail
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost} turnCost={s.metrics.coverage === "none" ? NO_COST : s.metrics.turn}
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
@@ -7,11 +7,12 @@ import { pinToViewport } from "./place";
import { StudioIcon } from "./StudioIcon";
import { workspaceMenuKeys } from "./WorkspaceOrder";
export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onToggleAll, allFolded, canFold, searching }: {
export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onRecent, onToggleAll, allFolded, canFold, searching }: {
adder: Adder;
canSelect: boolean;
selecting: boolean;
onSelect: () => void;
onRecent?: () => void;
onToggleAll?: () => void;
allFolded?: boolean;
canFold?: boolean;
@@ -42,6 +43,10 @@ export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onToggle
</button>}
</span>
<span className="projects-heading-actions">
{onRecent && <button className="projects-add projects-recent" data-action="session.recent"
title={t("最近的会话")} aria-label={t("最近的会话")} onClick={onRecent}>
<span>{t("最近")}</span>
</button>}
<button className="projects-add" data-busy={adder.busy ? "" : undefined} disabled={adder.busy}
title={t("打开项目")} aria-label={t("打开项目")} data-action="workspace.add" onClick={() => adder.add()}>
<StudioIcon name="plus" />
+10 -5
View File
@@ -43,6 +43,9 @@ const theirs = (it: QueueItem) => it.origin !== "host" || it.state === "blocked"
// Codes whose entry the kernel knows never reached the model, so sending it
// again cannot repeat an effect. Every other uncertain code may have run.
const RETRY_SAFE = new Set(["steer_unapplied"]);
// The kernel edits, reorders and re-freezes only a line nobody has taken yet; a
// line already accepted as guidance can only be withdrawn or sent now.
const editable = (it: QueueItem) => it.state === "queued" || it.state === "blocked" || it.state === "uncertain";
const retryable = (it: QueueItem) =>
it.state === "blocked" || (it.state === "uncertain" && !!it.blockCode && RETRY_SAFE.has(it.blockCode));
@@ -264,7 +267,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
)}
{live && (
<span className="qacts">
{items.length > 1 && <>
{items.length > 1 && editable(it) && <>
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>
<StudioIcon name="arrow" />
</button>
@@ -272,9 +275,11 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
<StudioIcon name="arrow" data-flip="" />
</button>
</>}
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")} aria-label={t("改")}>
<StudioIcon name="edit" />
</button>
{editable(it) && (
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")} aria-label={t("改")}>
<StudioIcon name="edit" />
</button>
)}
{/* Waiting is the normal case and needs no button. This is the
other one: the turn in front of this line is not worth
finishing, and nothing else can end it. */}
@@ -295,7 +300,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
</button>
)}
{/* Only an entry that quoted files has anything to re-freeze. */}
{!!it.refs?.length && (
{editable(it) && !!it.refs?.length && (
<button data-action="queue.refresh" data-target={it.id} onClick={() => onRefresh(it.id)} title={t("重新冻结引用的文件")}>
{t("刷新")}
</button>
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { HubPort, TreeSession, TreeWorkspace } from "../port/hub";
import type { RemoteHost } from "../port/remote";
import { hidesLegacySessions, onHidesLegacySessionsChange } from "../state/prefs";
import { SessionAge } from "./SessionAge";
import { StudioIcon } from "./StudioIcon";
import { TREE_PAGE } from "./treewindow";
import { useModalFocus } from "./useModalFocus";
import { listenAction } from "./listen";
export function RecentSessions({ hub, hosts, onPick, onClose }: {
hub: HubPort;
hosts: RemoteHost[];
onPick: (host: string, workspace: TreeWorkspace, session: TreeSession) => Promise<void>;
onClose: () => void;
}) {
const sheet = useRef<HTMLDivElement>(null);
useModalFocus(sheet);
const hideLegacy = useSyncExternalStore(onHidesLegacySessionsChange, hidesLegacySessions, hidesLegacySessions);
const [view, setView] = useState({ host: "", q: "", offset: 0 });
const [draft, setDraft] = useState("");
const [tree, setTree] = useState<TreeWorkspace[] | null>(null);
const [busy, setBusy] = useState(true);
const [opening, setOpening] = useState(false);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
const timer = setTimeout(() => setView((current) => current.q === draft.trim() ? current : { ...current, q: draft.trim(), offset: 0 }), 250);
return () => clearTimeout(timer);
}, [draft]);
useEffect(() => { sheet.current?.querySelector<HTMLInputElement>("input")?.focus(); }, []);
useEffect(() => hub.onTreeChanged(() => setRetry((n) => n + 1)), [hub]);
useEffect(() => {
const escape = (event: KeyboardEvent) => { if (event.key === "Escape" && !opening) onClose(); };
return listenAction(window, "keydown", { action: "layer.dismiss", listener: escape as EventListener });
}, [onClose, opening]);
useEffect(() => {
let stale = false;
setBusy(true);
setError("");
const window = { order: "recent" as const, scope: "active" as const, limit: TREE_PAGE, offset: view.offset, q: view.q, hideLegacy };
const read = view.host ? hub.remoteTree(view.host, window) : hub.tree(window);
void read.then((next) => {
if (stale) return;
if (next?.some((ws) => ws.recentOffset !== view.offset)) throw new Error(t("此运行环境不支持最近会话,请更新 Reasonix"));
setTree(next ?? []);
}).catch((e: unknown) => { if (!stale) setError(reason(e)); })
.finally(() => { if (!stale) setBusy(false); });
return () => { stale = true; };
}, [hub, view, hideLegacy, retry]);
const rows = (tree ?? []).flatMap((ws) => ws.sessions.map((session) => ({ ws, session })))
.sort((a, b) => (b.session.lastActiveAt ?? 0) - (a.session.lastActiveAt ?? 0) || (a.session.path < b.session.path ? -1 : a.session.path > b.session.path ? 1 : 0));
const remaining = (tree ?? []).reduce((n, ws) => n + (ws.remaining ?? 0), 0);
const pick = async (ws: TreeWorkspace, session: TreeSession) => {
setOpening(true);
setError("");
try {
await onPick(view.host, ws, session);
onClose();
} catch (e) {
setError(reason(e));
} finally {
setOpening(false);
}
};
return createPortal(
<div className="pickveil" role="dialog" aria-modal="true" aria-label={t("最近的会话")} ref={sheet}>
<div className="pickcard recent-sessions">
<h2>{t("最近的会话")}</h2>
<p className="recent-description">{t("按最近活动排列,包含已完成的会话。选择后定位到所属项目。")}</p>
{hosts.some((host) => !host.disabled) && <label className="recent-machine">{t("运行环境")}
<select data-action="session.recent-environment" aria-label={t("运行环境")} value={view.host} disabled={opening} onChange={(event) => setView({ host: event.target.value, q: view.q, offset: 0 })}>
<option value="">{t("本机")}</option>
{hosts.filter((host) => !host.disabled).map((host) => <option key={host.name} value={host.name}>{host.name}</option>)}
</select>
</label>}
<div className="pickpath"><input data-action="workspace.search" type="search" aria-label={t("搜索最近会话 / 项目")} placeholder={t("搜索最近会话 / 项目")} value={draft} disabled={opening} onChange={(event) => setDraft(event.target.value)} /></div>
<div className="picklist" aria-busy={busy} data-busy={busy ? "" : undefined}>
{!busy && !error && !rows.length && <p className="pickempty">{t("没有匹配的会话")}</p>}
{busy && <p className="picknote" role="status">{t("正在读取会话…")}</p>}
{!busy && !error && rows.map(({ ws, session }) => <button key={session.path} className="pickrow recent-session" disabled={opening}
data-action="session.open" data-target={session.path} aria-label={`${session.title || session.name} · ${ws.name}`}
title={`${session.workspaceRoot || ws.root}\n${session.path}`} onClick={() => void pick(ws, session)}>
<StudioIcon name="chat" />
<span className="recent-session-text"><b>{session.title || session.name}</b><small>{ws.name} · {session.workspaceRoot || ws.root}</small></span>
<SessionAge at={session.lastActiveAt} />
</button>)}
{error && <><p className="picknote" role="alert">{error}</p><button className="pickgo" data-action="session.recent-retry" onClick={() => setRetry((n) => n + 1)}>{t("重试")}</button></>}
</div>
<div className="pickact">
<button data-action="session.recent-page" data-value="previous" disabled={busy || opening || view.offset === 0} onClick={() => setView({ ...view, offset: Math.max(0, view.offset - TREE_PAGE) })}>{t("上一页")}</button>
<span className="recent-page">{t("第 {n} 页", { n: view.offset / TREE_PAGE + 1 })}</span>
<button data-action="session.recent-page" data-value="next" disabled={busy || opening || !!error || remaining === 0} onClick={() => setView({ ...view, offset: view.offset + TREE_PAGE })}>{t("下一页")}</button>
<button data-action="layer.dismiss" disabled={opening} onClick={onClose}>{t("关闭")}</button>
</div>
</div>
</div>, document.body,
);
}
@@ -0,0 +1,16 @@
import { t } from "../i18n";
import { setRunningMessageIntent, useRunningMessageIntent } from "../state/prefs";
import { Group } from "./Group";
export function RunningMessageSetting() {
const intent = useRunningMessageIntent();
return (
<Group id="running-message-intent" title={t("运行中发送消息")} hint={t("选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。")}>
<div className="seg" data-text role="radiogroup" aria-label={t("运行中发送消息")}>
<button role="radio" data-action="composer.running-message-intent" data-value="steer" aria-checked={intent === "steer"} onClick={() => setRunningMessageIntent("steer")}>{t("下个工具边界插话")}</button>
<button role="radio" data-action="composer.running-message-intent" data-value="followup" aria-checked={intent === "followup"} onClick={() => setRunningMessageIntent("followup")}>{t("整轮结束后发送")}</button>
</div>
<p className="hint">{t("可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。")}</p>
</Group>
);
}
+30 -3
View File
@@ -1,9 +1,9 @@
import { FeedbackBadge, feedbackEntryTab } from "./feedbackentry";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { t } from "../i18n";
import logoWordmark from "../assets/logo-wordmark.svg";
import type { AccountState } from "../port/port";
import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub";
import { workspaceContains } from "../port/hub";
import type { RemoteHost } from "../port/remote";
import type { Adder } from "./addws";
@@ -26,6 +26,8 @@ import type { TreePaging, TreeView } from "./treewindow";
import { MASK, useHidesAmounts } from "./wallet";
import { useFileDrop } from "./filedrop";
import type { PaneView } from "../state/paneview";
import { RecentSessions } from "./RecentSessions";
import { sessionRuntime } from "./sessionScope";
interface Props {
hub: HubPort;
@@ -137,6 +139,9 @@ export function Sidebar({
const destinations = useDestinations();
const [palette, setPalette] = useState(false);
const [projectPicker, setProjectPicker] = useState(false);
const [recent, setRecent] = useState(false);
const [located, setLocated] = useState<{ target: string } | null>(null);
const locatedOnce = useRef<typeof located>(null);
const [folded, setFolded] = useSidebarFolds();
const hideAmounts = useHidesAmounts();
const hiddenHosts = useHiddenHosts();
@@ -225,9 +230,30 @@ export function Sidebar({
const toggleAll = () => setFolded(allFolded
? new Set([...folded].filter((key) => !foldable.includes(key))) : new Set([...folded, ...foldable]));
const pickRecent = async (host: string, workspace: TreeWorkspace, session: TreeSession) => {
const held = sessionRuntime(session, runtimes, host);
if (held) onFocusPane(held.id);
else if (host) {
await onOpenRemote(host, session.workspaceRoot || workspace.root, session.path);
await readRemoteTree(host);
} else await onOpen({ root: session.workspaceRoot || workspace.root, sessionPath: session.path });
setRailScope("all");
onFold(host ? remoteProjectKey(host, workspace.root) : workspace.root, false);
setLocated({ target: host ? remoteProjectKey(host, workspace.root) + ":" + session.path : session.path });
};
useEffect(() => {
if (!located || recent || locatedOnce.current === located) return;
const row = [...document.querySelectorAll<HTMLElement>('.rail [role="treeitem"]')]
.find((element) => element.dataset.target === located.target);
if (!row) return;
row.scrollIntoView?.({ block: "nearest" });
row.focus({ preventScroll: true });
locatedOnce.current = located;
}, [located, recent, tree, remoteTrees]);
return (
<>
{recent && <RecentSessions hub={hub} hosts={shownRemotes} onPick={pickRecent} onClose={() => setRecent(false)} />}
{projectPicker && <OpenProject hub={hub} adder={adder} hosts={shownRemotes}
onClose={() => setProjectPicker(false)} onError={onError} onOpen={async (host, root) => {
await onOpenRemote(host, root);
@@ -289,7 +315,7 @@ export function Sidebar({
</div>
</div>
</div>
<RailSearch>
<RailSearch resetKey={located}>
<ContentMatches hub={hub} tree={tree} onOpen={onOpen} onError={onError} onReference={onReferenceSession} />
<Workspaces
hub={hub}
@@ -301,6 +327,7 @@ export function Sidebar({
folded={folded}
onToggleAll={toggleAll}
allFolded={allFolded}
onRecent={() => setRecent(true)}
canFold={foldable.length > 0}
onFold={onFold}
reload={reloadTree}
@@ -0,0 +1,21 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { onThemeScheduleChange, setThemeSchedule, themeSchedule } from "../state/themeSchedule";
const FIELDS = [["start", "深色开始时间"], ["end", "恢复浅色时间"]] as const;
export function ThemeScheduleFields() {
const schedule = useSyncExternalStore(onThemeScheduleChange, themeSchedule);
return <>
<p className="hint" id="theme-schedule-hint">{t("每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。")}</p>
{FIELDS.map(([field, label]) => (
<label className="prow" key={field}>
<span className="tx">{t(label)}</span>
<input type="time" className="fontown" value={schedule[field]} required step={60}
aria-describedby="theme-schedule-hint" data-action="appearance.schedule" data-target={field}
onChange={(e) => setThemeSchedule({ ...schedule, [field]: e.target.value })} />
</label>
))}
{schedule.start === schedule.end && <p className="hint">{t("开始和结束时间相同时全天使用浅色。")}</p>}
</>;
}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Versions } from "./Versions";
@@ -95,6 +95,16 @@ describe("the version panel", () => {
expect(screen.getByRole("link", { name: "下载完整安装包" })).toBeTruthy();
});
it("opens the full installer page in the system browser, where a file can be saved", async () => {
const openExternal = vi.fn(() => Promise.resolve());
const port = { ...portAt({ version: "2.21.0", phase: "error", received: 0, total: 0, code: "update.not_applied", err: "update: set aside app.exe: access denied" }), openExternal };
render(<Versions port={port} />);
const link = await screen.findByRole("link", { name: "下载完整安装包" });
const notPrevented = fireEvent.click(link);
expect(notPrevented).toBe(false);
expect(openExternal).toHaveBeenCalledWith("https://github.com/esengine/DeepSeek-Reasonix/releases/tag/studio-v2.21.0");
});
it("explains a move that did not land by its code, with the way out", async () => {
const port = portAt({ version: "2.21.0", phase: "error", received: 0, total: 0, code: "update.not_applied", err: "update: set aside app.exe: access denied" });
render(<Versions port={port} />);
+10 -2
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent, type MouseEvent } from "react";
import { bytes } from "../i18n/format";
import { current, t } from "../i18n";
import type { UpdateProgress, VersionHub, VersionNotes } from "../port/port";
@@ -29,6 +29,7 @@ type Port = {
restartToVersion(v: string, force: boolean): Promise<void>;
onUpdateProgress(cb: (p: UpdateProgress) => void): () => void;
versionNotes?(version: string, retry?: boolean): Promise<VersionNotes>;
openExternal?(url: string): Promise<void>;
};
// A document read stays read: it is kept for as long as the panel is mounted,
@@ -84,6 +85,13 @@ function say(p: UpdateProgress): string {
// The newest few older releases stay on the page; the rest are history one click away.
const PAST_SHOWN = 3;
// An installer is a file to save, which the window's own browser does not do.
function openInstallerPage(e: MouseEvent<HTMLAnchorElement>, port: Port, version: string) {
if (!port.openExternal) return;
e.preventDefault();
void port.openExternal(releasePage(version)).catch(() => {});
}
export function Versions({ port }: { port: Port }) {
const [hub, setHub] = useState<VersionHub | null>(null);
const [busy, setBusy] = useState(false);
@@ -349,7 +357,7 @@ export function Versions({ port }: { port: Port }) {
<span className="why">
{failure.why}
{failure.manual && (
<a className="lnk" href={releasePage(progress.version)} target="_blank" rel="noreferrer noopener">
<a className="lnk" href={releasePage(progress.version)} target="_blank" rel="noreferrer noopener" data-action="versions.full-installer" onClick={(e) => openInstallerPage(e, port, progress.version)}>
{t("下载完整安装包")}
</a>
)}
+7 -6
View File
@@ -1,4 +1,4 @@
import { inScope } from "./sessionScope";
import { inScope, sessionRuntime } from "./sessionScope";
import { Fragment, type ReactNode, memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { hidesLegacySessions, onHidesLegacySessionsChange } from "../state/prefs";
import { onSessionQuickActionsChange, showsSessionQuickActions } from "../state/sessionQuick";
@@ -57,6 +57,7 @@ interface Props {
paging?: TreePaging;
previewLimit?: number;
onToggleAll?: () => void;
onRecent?: () => void;
allFolded?: boolean;
canFold?: boolean;
// The session path being opened, or empty. The row shows it selected and busy
@@ -91,7 +92,7 @@ interface Props {
const rowLabel = (session: TreeSession) =>
session.title || (session.runtimeId && !session.turns ? t("新会话") : session.name);
function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) {
function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, onRecent, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) {
const [busy, setBusy] = useState("");
const [confirm, setConfirm] = useState("");
const [selection, setSelection] = useState<Set<string> | null>(null);
@@ -182,8 +183,9 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
// The tree and runtime list arrive from separate reads. During a close or
// restore the tree can briefly retain an id that no longer exists; focusing
// that stale id changes no pane and makes the row look unclickable.
if (session.runtimeId && runtimes.some((rt) => rt.id === session.runtimeId)) {
onFocus(session.runtimeId);
const held = sessionRuntime(session, runtimes);
if (held) {
onFocus(held.id);
return;
}
try {
@@ -363,7 +365,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
onExit={() => treeKeys.ref.current?.parentElement?.previousElementSibling?.querySelector<HTMLElement>('[data-action="machine.menu"]')?.focus()}
onBusy={(pending) => { setBusy(pending ? "batch" : ""); if (pending) { setSessionMenu(""); setEditing(""); setConfirm(""); } }}
onClose={onClose} onArchive={onArchive} liveIds={liveIds} reload={reload} onError={onError} />
<ProjectsHeader adder={adder}
<ProjectsHeader adder={adder} onRecent={onRecent}
onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle}
canSelect={displayed.length > 0} selecting={selection !== null}
onSelect={() => setSelection(new Set())} />
@@ -774,7 +776,6 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
{children}
</div>
</div>
</>
);
}
@@ -25,7 +25,7 @@ it("keeps open a menu opened between the chip's commit and its mount effects", a
});
watch.observe(root, { childList: true, subtree: true });
});
mounted.render(<WorktreeChip port={port} sources={sources} canCreate={false} selection={null} branch="main" busy={false}
mounted.render(<WorktreeChip port={port} workspace={null} sources={sources} canCreate={false} selection={null} branch="main" busy={false}
changeCount={0} onSelect={vi.fn()} refresh={async () => sources} onChanged={vi.fn()} onError={vi.fn()} />);
await clicked;
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -17,6 +17,7 @@ interface Props {
onSwitched?: (git: WorkspaceGit) => void;
onError: (error: unknown) => void;
sources: WorktreeSources;
workspace: WorkspaceInfo | null;
canCreate: boolean;
selection: WorktreeSelection | null;
branch: string;
@@ -26,11 +27,10 @@ interface Props {
refresh: () => Promise<WorktreeSources>;
}
export function WorktreeChip({ sources, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
export function WorktreeChip({ sources, workspace, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [sourceError, setSourceError] = useState(false);
const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const search = useRef<HTMLInputElement>(null);
@@ -48,14 +48,10 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
const directoryDescription = selection ? creationDescription : workspaceDirectoryDescription(isolated);
const scope = useRef({ port, root: sources.sourceRoot });
useEffect(() => {
let live = true;
if (scope.current.port !== port || scope.current.root !== sources.sourceRoot) {
scope.current = { port, root: sources.sourceRoot };
returnFocus.current = false; setOpen(false); setQuery("");
}
setWorkspace(null);
void port.workspaces().then((value) => { if (live) setWorkspace(value); }).catch(() => {});
return () => { live = false; };
}, [port, sources.sourceRoot]);
const put = useCallback(() => {
if (!trigger.current || !panel.current) return;
@@ -25,6 +25,7 @@ async function draw(shell: Record<string, unknown>, host?: string) {
render(
<Chrome
port={null}
workspace={null}
status={{ preset: "balanced", toolApprovalMode: "ask", workspaceRoot: ROOT, sessionPath: ROOT + "/s.jsonl" } as never}
title="会话"
steer={0}
+38 -6
View File
@@ -6,11 +6,14 @@ import type { HubPort, RuntimeView } from "../port/hub";
import { apply as applyLook } from "./look";
import { apply as applyThemePack } from "./theme";
import { trailing } from "./trailing";
import { onThemeScheduleChange, themeSchedule } from "../state/themeSchedule";
import { nextScheduledBoundary, scheduledScheme } from "./scheduledTheme";
import { host } from "../port/host";
export interface Paint {
theme: string;
setTheme: (v: string) => void;
// What "auto" resolved to. Panes take it as a prop, not off the root.
// The resolved colour mode, independent of the selected preference.
scheme: "light" | "dark";
contrast: string;
setContrast: (v: string) => void;
@@ -28,7 +31,13 @@ export interface Paint {
* writes `documentElement`'s dataset, so nothing else has to know the order
* a pack and a look have to be applied in. */
export function usePaint(hub: HubPort, runtimes: RuntimeView[], running: boolean, onError: (e: unknown) => void): Paint {
const [theme, setTheme] = useState(() => localStorage.getItem("rx-theme") ?? "auto");
const [theme, setTheme] = useState(() => {
try {
const saved = localStorage.getItem("rx-theme");
return saved && ["auto", "light", "dark", "scheduled"].includes(saved) ? saved : "auto";
} catch { return "auto"; }
});
const schedule = useSyncExternalStore(onThemeScheduleChange, themeSchedule);
const [scheme, setScheme] = useState<"light" | "dark">("dark");
// "" means never chosen, which is what lets the system's own contrast setting
// decide. Any explicit pick wins over it from then on.
@@ -86,8 +95,12 @@ export function usePaint(hub: HubPort, runtimes: RuntimeView[], running: boolean
// in flight — the transition is in CSS, this only moves the target.
useEffect(() => {
const mq = matchMedia("(prefers-color-scheme: dark)");
let timer: number | undefined;
const paint = () => {
const now = theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
clearTimeout(timer);
const clock = new Date();
const now = theme === "scheduled" ? scheduledScheme(schedule, clock) :
theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
document.documentElement.dataset.theme = now;
setScheme(now as "light" | "dark");
// The active pack is the same state shown by the appearance picker.
@@ -100,12 +113,31 @@ export function usePaint(hub: HubPort, runtimes: RuntimeView[], running: boolean
// After the pack, never before: size and type are the reader's, and a
// palette someone else authored does not get to overrule them.
applyLook(look, running);
try {
localStorage.setItem("rx-theme", theme);
localStorage.setItem("rx-theme-resolved", now);
} catch { /* this window keeps its choice */ }
host().syncTheme(theme, now as "light" | "dark");
if (theme === "scheduled") {
const delay = nextScheduledBoundary(schedule, clock);
if (delay !== null) timer = window.setTimeout(paint, delay);
}
};
paint();
mq.addEventListener("change", paint);
localStorage.setItem("rx-theme", theme);
return () => mq.removeEventListener("change", paint);
}, [theme, pack, running, look, contrast]);
if (theme === "scheduled") {
window.addEventListener("focus", paint);
window.addEventListener("pageshow", paint);
document.addEventListener("visibilitychange", paint);
}
return () => {
clearTimeout(timer);
mq.removeEventListener("change", paint);
window.removeEventListener("focus", paint);
window.removeEventListener("pageshow", paint);
document.removeEventListener("visibilitychange", paint);
};
}, [theme, schedule, pack, running, look, contrast]);
useEffect(() => {
if (contrast) document.documentElement.dataset.contrast = contrast;
@@ -2,16 +2,25 @@
import "./testkit";
import { useCallback, useState } from "react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import type { SessionStatus } from "../port/port";
import { MockHub } from "../port/mock_hub";
import type { SessionStatus, WorkspaceInfo } from "../port/port";
import type { WireEvent } from "../port/wire";
import { Chrome } from "./Chrome";
import { Pane, type PaneReport } from "./Pane";
import { App } from "./App";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
Element.prototype.getAnimations ??= () => [];
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
const deferredReads = ["mcp", "changes", "context", "queue"] as const;
const reads = ["checkpoints", "mcp", "changes", "context", "balance", "todos", "queue", "workspaces", "capabilityScope", "models"] as const;
@@ -33,6 +42,8 @@ async function mount(running = false, pathless = false) {
const mcp = await port.mcp();
const sessionPath = pathless ? undefined : "/sessions/active.jsonl";
let state: SessionStatus = { ...snapshot, running, sessionPath };
const sources = port.worktreeSources.bind(port);
vi.spyOn(port, "worktreeSources").mockImplementation(async () => ({ ...await sources(), sourceRoot: state.workspaceRoot || state.cwd }));
let release: () => void = () => {};
let reject: () => void = () => {};
vi.spyOn(port, "status").mockImplementationOnce(() => new Promise((resolve, fail) => { release = () => resolve(state); reject = () => fail(new Error("status offline")); }))
@@ -47,8 +58,8 @@ async function mount(running = false, pathless = false) {
const [report, setReport] = useState<PaneReport | null>(null);
const onReport = useCallback((_: string, next: PaneReport) => setReport(next), []);
return <>
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath }}
<Chrome port={port} status={report?.status ?? null} workspace={report?.workspace ?? null} title="active" steer={0} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: snapshot.workspaceRoot!, name: "workspace", sessionPath }}
title="active" active visible sideHost={null} side={false} onFocus={() => {}} onReport={onReport}
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
onOpenProject={() => {}} onKeepHere={() => {}} theme="light" dockW={320} dockMax={640} onDockW={() => {}} />
@@ -61,10 +72,13 @@ async function mount(running = false, pathless = false) {
change: (next: Partial<SessionStatus>) => { state = { ...state, ...next }; } };
}
it.each([false, true])("reads each deferred startup resource once after the session is known (running=%s)", async (running) => {
const pane = await mount(running);
it.each([
[false, false], [true, false], [false, true], [true, true],
])("reads startup resources once (running=%s, pathless=%s)", async (running, pathless) => {
const pane = await mount(running, pathless);
expect(pane.calls.workspaces).not.toHaveBeenCalled();
await pane.ready();
await waitFor(() => expect(pane.calls.workspaces.mock.calls.length).toBeGreaterThan(0));
await waitFor(() => expect(pane.calls.workspaces).toHaveBeenCalledTimes(1));
for (const name of deferredReads) expect(pane.calls[name], name).toHaveBeenCalledTimes(1);
expect(pane.calls.balance).toHaveBeenCalledTimes(1);
expect(pane.calls.todos).toHaveBeenCalledTimes(1);
@@ -82,7 +96,7 @@ it("waits for the initial status before reading deferred panels without holding
it("reads once for a new session which has no persisted path yet", async () => {
const pane = await mount(false, true);
await pane.ready();
await waitFor(() => expect(pane.calls.workspaces.mock.calls.length).toBeGreaterThan(0));
await waitFor(() => expect(pane.calls.workspaces).toHaveBeenCalledTimes(1));
for (const name of deferredReads) expect(pane.calls[name], name).toHaveBeenCalledTimes(1);
});
@@ -136,8 +150,10 @@ it("loads deferred panels and the branch chip when the first status fails", asyn
expect(pane.port.branchReads).toHaveBeenCalled();
expect(pane.calls.changes).toHaveBeenCalled();
expect((await screen.findByRole("button", { name: "配置工作目录" })).textContent).toContain("rescue");
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
pane.send({ kind: "turn_done" });
await waitFor(() => expect(pane.calls.queue).toHaveBeenCalledTimes(2));
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
expect(pane.calls.models).toHaveBeenCalledTimes(1);
});
@@ -156,3 +172,65 @@ it("keeps event-driven reads alive while the first status is pending", async ()
await pane.ready();
await waitFor(() => expect(pane.calls.queue).toHaveBeenCalledTimes(2));
});
it("shares the workspace result with both directory badges and rejects a stale root read", async () => {
const pane = await mount();
const old = deferred<WorkspaceInfo>();
pane.calls.workspaces.mockReturnValueOnce(old.promise);
await pane.ready();
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
pane.calls.workspaces.mockResolvedValue({ current: "/next", isolated: true, canSwitch: true, canIsolate: true, recents: [] });
pane.change({ workspaceRoot: "/next" });
pane.gap();
await waitFor(() => expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain("/next"));
await waitFor(() => expect(screen.getByRole("button", { name: "配置工作目录" }).textContent).toContain("独立工作树"));
await act(async () => old.resolve({ current: "/old", isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain("/next");
expect(screen.getByRole("button", { name: "配置工作目录" }).title).toContain("/next");
expect(pane.calls.workspaces).toHaveBeenCalledTimes(2);
});
it("recovers workspace metadata after a failed read when the directory changes", async () => {
const pane = await mount();
pane.calls.workspaces.mockRejectedValueOnce(new Error("workspace offline"));
await pane.ready();
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
expect(document.querySelector(".chrome .isolab:not(.hostlab)")).toBeNull();
pane.calls.workspaces.mockResolvedValue({ current: "/next", isolated: true, canSwitch: true, canIsolate: true, recents: [] });
pane.change({ workspaceRoot: "/next" });
pane.gap();
await waitFor(() => expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain("/next"));
expect(pane.calls.workspaces).toHaveBeenCalledTimes(2);
});
it("keeps workspace reads and late results isolated across focused panes", async () => {
const hub = new MockHub();
const portFor = hub.portFor.bind(hub);
const calls = new Map<string, number>();
const first = deferred<WorkspaceInfo>();
let firstRoot = "";
hub.portFor = (rt) => {
const port = portFor(rt);
if (calls.has(rt.id)) return port;
calls.set(rt.id, 0);
const status = port.status.bind(port);
port.status = async () => ({ ...await status(), workspaceRoot: rt.root, sessionPath: rt.sessionPath });
port.welcomeSeen = async () => true;
port.providerSetup = async () => null;
port.workspaces = () => {
calls.set(rt.id, calls.get(rt.id)! + 1);
if (rt.id === "r1") { firstRoot = rt.root; return first.promise; }
return Promise.resolve({ current: rt.root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
};
return port;
};
render(<App hub={hub} />);
await waitFor(() => expect(calls.get("r1")).toBe(1));
await userEvent.click(await screen.findByRole("treeitem", { name: /站点改版/ }));
await waitFor(() => expect([...calls.values()]).toEqual([1, 1]));
await act(async () => first.resolve({ current: firstRoot, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
expect(document.querySelector(".chrome .isolab:not(.hostlab)")).toBeNull();
await userEvent.click(screen.getByRole("treeitem", { name: /并行会话演示/ }));
await waitFor(() => expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain(firstRoot));
expect([...calls.values()]).toEqual([1, 1]);
});
@@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import "./testkit";
import { App } from "./App";
import { MockHub } from "../port/mock_hub";
import type { RuntimeView, TreeWorkspace } from "../port/hub";
import type { RuntimeView } from "../port/hub";
import type { AgentPort } from "../port/port";
const renders = vi.hoisted(() => new Map<string, number>());
@@ -26,15 +26,9 @@ afterEach(cleanup);
const PANES = 6;
const views: RuntimeView[] = Array.from({ length: PANES }, (_, i) => ({
id: `r${i + 1}`, base: i === 0 ? "" : `/rt/r${i + 1}`, root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix",
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
}));
class SixPaneHub extends MockHub {
open(req: { root?: string; sessionPath?: string }) {
const held = views.find((v) => v.sessionPath === req.sessionPath);
return held ? Promise.resolve(held) : super.open(req);
constructor() {
super(PANES);
}
portFor(rt: RuntimeView): AgentPort {
const port = super.portFor(rt);
@@ -42,15 +36,6 @@ class SixPaneHub extends MockHub {
port.welcomeSeen = async () => true;
return port;
}
runtimes(): Promise<RuntimeView[]> {
return Promise.resolve([...views]);
}
tree(): Promise<TreeWorkspace[]> {
return Promise.resolve([{
root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", open: true,
sessions: Array.from({ length: PANES }, (_, i) => ({ path: `/sessions/pane-${i + 1}.jsonl`, name: `pane-${i + 1}`, title: `会话 ${i + 1}`, turns: 3 })),
}]);
}
}
// A hidden pane holds a whole transcript. Switching tabs changes which pane is
@@ -86,3 +71,26 @@ it("still makes the chosen pane the visible one, and its focus callback works",
shown.get("r2")!.focus();
await waitFor(() => expect([...shown.entries()].filter(([, p]) => p.visible).map(([id]) => id)).toEqual(["r2"]));
});
it("focuses existing seeded conversations without reopening or rebinding their panes", async () => {
const hub = new SixPaneHub();
const initial = await hub.runtimes();
const identities = initial.map((runtime) => [runtime.id, runtime.sessionPath]);
const open = vi.spyOn(hub, "open");
const { container } = render(<App hub={hub} />);
const pick = async (id: string) => {
const row = await waitFor(() => {
const path = initial.find((runtime) => runtime.id === id)!.sessionPath;
const found = [...container.querySelectorAll<HTMLElement>(".sessrow")].find((row) => row.dataset.target === path);
expect(found).not.toBeNull();
return found!;
});
await userEvent.click(row);
await waitFor(() => expect([...shown.entries()].filter(([, pane]) => pane.visible).map(([runtime]) => runtime)).toEqual([id]));
};
await pick(initial[1].id);
await pick(initial[0].id);
expect(open).not.toHaveBeenCalled();
expect((await hub.runtimes()).map((runtime) => [runtime.id, runtime.sessionPath])).toEqual(
identities);
});
+2 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { AgentPort, ContextBreakdown, McpEntry, SessionStatus } from "../port/port";
import type { AgentPort, ContextBreakdown, McpEntry, SessionStatus, WorkspaceInfo } from "../port/port";
import type { HubPort, RuntimeView } from "../port/hub";
import type { ConnectOffer } from "./ConnectNote";
import type { PaneView } from "../state/paneview";
@@ -8,6 +8,7 @@ import type { PaneView } from "../state/paneview";
// focus: everything else about a session stays inside the pane that owns it.
export interface PaneReport {
status: SessionStatus | null;
workspace: WorkspaceInfo | null;
title: string;
steer: number;
run: string;
+3 -1
View File
@@ -320,6 +320,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "appearance", anchor: "legacy-sessions", title: "1.x 旧会话", scope: "machine", apply: "immediate", keywords: ["1.x", "旧会话", "迁移", "隐藏", "legacy", "sessions", "sidebar"] },
{ section: "appearance", anchor: "live-grace", title: "进行中列表保留已完成会话", scope: "machine", apply: "immediate", keywords: ["进行中", "完成", "保留", "过滤", "running", "grace", "sidebar"] },
{ section: "appearance", anchor: "send-shortcut", title: "发送快捷键", scope: "machine", apply: "immediate", keywords: ["发送", "回车", "换行", "enter", "ctrl", "cmd"] },
{ section: "appearance", anchor: "running-message-intent", title: "运行中发送消息", scope: "machine", apply: "immediate", keywords: ["插话", "排队", "下一轮", "steer", "queue", "followup", "running"] },
{ section: "appearance", anchor: "global-shortcut", title: "全局显示 / 隐藏快捷键", scope: "machine", apply: "immediate", keywords: ["global", "show", "hide", "shortcut", "keyboard", "全局", "显示", "隐藏"] },
{ section: "appearance", anchor: "keys", title: "快捷键", scope: "machine", apply: "immediate", keywords: ["shortcut", "keyboard", "ctrl", "cmd", "esc", "home", "查找", "搜索", "运行分析", "工作台"] },
{ section: "appearance", anchor: "esc-stop", title: "Esc 停止任务", scope: "machine", apply: "immediate", keywords: ["esc", "停止", "中断", "确认", "误触", "stop"] },
@@ -327,6 +328,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "appearance", anchor: "session-tabs", title: "会话标签栏", scope: "machine", apply: "immediate", keywords: ["标签", "会话切换", "tabs", "session"] },
{ section: "appearance", anchor: "folding", title: "会话折叠", scope: "machine", apply: "immediate", keywords: ["展开", "收起", "思考", "执行过程", "步骤", "输出", "简报", "fold"] },
{ section: "appearance", anchor: "plan-progress", title: "收起时显示计划进度", scope: "machine", apply: "immediate", keywords: ["计划", "进度条", "待办", "收起", "todo", "plan", "progress", "collapsed"] },
{ section: "appearance", anchor: "composer-hint", title: "输入框提示", scope: "machine", apply: "immediate", keywords: ["占位", "任务", "提示", "输入框", "placeholder", "composer", "hint"] },
{ section: "appearance", anchor: "window", title: "窗口", scope: "machine", apply: "immediate", keywords: ["托盘", "关闭行为", "休眠", "睡眠"] },
// Immediate because the endpoint hands the holder every running sink reads,
// not because the file was written: writing it is what a restart would need.
@@ -338,7 +340,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "appearance", anchor: "weight", title: "文字粗细", scope: "machine", apply: "immediate", keywords: ["加粗", "字重"] },
{ section: "appearance", anchor: "contrast", title: "文字对比度", scope: "machine", apply: "immediate", keywords: ["柔和", "对比"] },
{ section: "appearance", anchor: "effects", title: "视觉效果", scope: "machine", apply: "immediate", keywords: ["磨砂", "模糊", "动画", "显卡", "gpu", "性能", "省电", "blur"] },
{ section: "appearance", anchor: "mode", title: "明暗", scope: "machine", apply: "immediate", keywords: ["深色", "浅色", "跟随系统"] },
{ section: "appearance", anchor: "mode", title: "明暗", scope: "machine", apply: "immediate", keywords: ["深色", "浅色", "跟随系统", "定时", "时间段", "schedule", "dark", "light"] },
{ section: "appearance", anchor: "scheme", title: "配色", scope: "machine", apply: "immediate", keywords: ["主题", "theme", "色板"] },
];
@@ -8,6 +8,8 @@ import { MockPort } from "../port/mock";
import { HttpError } from "../port/port";
import type { RuntimeView } from "../port/hub";
import { draftKey } from "./drafts";
import { RunningMessageSetting } from "./RunningMessageSetting";
import { setRunningMessageIntent } from "../state/prefs";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
@@ -32,6 +34,43 @@ async function open(port = new MockPort()) {
}
describe("queued submission and withdrawal", () => {
it("changes running submission from steering to a follow-up through the shared setting", async () => {
const port = await open();
const steer = vi.spyOn(port, "steer");
const followup = vi.spyOn(port, "queueFollowup");
render(<RunningMessageSetting />);
fireEvent.click(screen.getByRole("radio", { name: "整轮结束后发送" }));
type(BODY);
expect(screen.getByText("Enter 排队 · Shift+Enter 换行")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "排队发送" }));
await screen.findByRole("button", { name: "取回" });
expect(followup).toHaveBeenCalledWith(BODY, undefined);
expect(steer).not.toHaveBeenCalled();
expect((await port.queue()).items[0].intent).toBe("followup");
fireEvent.click(screen.getByRole("button", { name: "取回" }));
await waitFor(() => expect(box().value).toBe(BODY));
fireEvent.click(screen.getByRole("radio", { name: "下个工具边界插话" }));
expect(screen.getByRole("button", { name: "插话" })).toBeTruthy();
});
it("holds a preferred follow-up while paused and restores it after reload", async () => {
setRunningMessageIntent("followup");
const port = new MockPort();
await port.setQueuePaused(true);
await open(port);
type(BODY);
fireEvent.click(screen.getByRole("button", { name: "排队发送" }));
await screen.findByText("待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送");
expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
cleanup();
render(<Pane {...props} port={port} />);
await screen.findByRole("button", { name: "取回" });
expect(box().value).toBe("");
expect((await port.queue()).items[0].intent).toBe("followup");
fireEvent.click(screen.getByRole("button", { name: "取回" }));
await waitFor(() => expect(box().value).toBe(BODY));
});
it.each(["", "A new draft", BODY])("clears on submit and restores once beside %j", async (draft) => {
const port = await open();
vi.spyOn(port, "steer").mockImplementation((text) => port.queueFollowup(text));
+7 -1
View File
@@ -43,7 +43,7 @@ const drawIdle = (q: QueueSnapshot) => paint(q, false);
describe("compact queue presentation", () => {
it("keeps action identities and order when edit and withdraw become icons", () => {
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
const html = draw(snapshot({ items: [item({ state: "queued" }), item({ id: "i2", state: "queued" })] }));
expect(html).toContain('title="编辑" aria-label="改"');
expect(html).toContain('title="取回" aria-label="取回"');
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
@@ -52,6 +52,12 @@ describe("compact queue presentation", () => {
expect(html).not.toContain("draggable");
});
it("offers only send-now and withdraw on guidance the turn has already accepted", () => {
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
expect(actions).toEqual(["queue.pause", "queue.send-now", "queue.cancel", "queue.send-now", "queue.cancel"]);
});
it("says paused once and preserves the resume control", () => {
const html = draw(snapshot({ paused: true }));
expect(html).toContain("发送已暂停");
+2 -1
View File
@@ -11,9 +11,10 @@ const RailQuery = createContext("");
/** What the rail is searching for, trimmed and lowercased. Empty when idle. */
export const useRailQuery = () => useContext(RailQuery).trim().toLowerCase();
export function RailSearch({ children }: { children: ReactNode }) {
export function RailSearch({ children, resetKey }: { children: ReactNode; resetKey?: unknown }) {
const [q, setQ] = useState("");
const find = useRef<HTMLInputElement>(null);
useEffect(() => { setQ(""); }, [resetKey]);
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
@@ -0,0 +1,188 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { App } from "./App";
import { RecentSessions } from "./RecentSessions";
import { MockHub } from "../port/mock_hub";
import type { TreeWindow, TreeWorkspace } from "../port/hub";
import type { WireEvent } from "../port/wire";
import { setHidesLegacySessions, setLiveGraceMinutes } from "../state/prefs";
afterEach(() => { cleanup(); setHidesLegacySessions(false); setLiveGraceMinutes(0); localStorage.clear(); });
function setup() {
const hub = new MockHub();
const portFor = hub.portFor.bind(hub);
hub.portFor = (runtime) => {
const port = portFor(runtime);
port.providerSetup = async () => null;
port.welcomeSeen = async () => true;
return port;
};
hub.remoteHosts = async () => [];
return hub;
}
it("pages the complete recent history, returning to the previous page and retrying a failed read", async () => {
const hub = setup();
const asked: TreeWindow[] = [];
let fail = false;
hub.tree = async (window) => {
asked.push(window!);
if (fail) { fail = false; throw new Error("offline"); }
const offset = window?.offset ?? 0;
return [{ root: "/w", name: "w", recentOffset: offset, remaining: Math.max(0, 351 - offset - 50), sessions: Array.from({ length: Math.min(50, 351 - offset) }, (_, i) => ({
path: `/w/${offset + i}.jsonl`, name: `finished ${offset + i}`, lastActiveAt: 1000 - offset - i,
})) }];
};
render(<RecentSessions hub={hub} hosts={[]} onPick={async () => {}} onClose={() => {}} />);
const dialog = within(screen.getByRole("dialog", { name: "最近的会话" }));
expect(await dialog.findByRole("button", { name: /finished 0 · w/ })).toBeTruthy();
for (let page = 1; page <= 6; page++) await userEvent.click(dialog.getByRole("button", { name: "下一页" }));
expect(await dialog.findByRole("button", { name: /finished 300 · w/ })).toBeTruthy();
fail = true;
await userEvent.click(dialog.getByRole("button", { name: "下一页" }));
expect(await dialog.findByRole("alert")).toHaveProperty("textContent", "offline");
await userEvent.click(dialog.getByRole("button", { name: "重试" }));
expect(await dialog.findByRole("button", { name: /finished 350 · w/ })).toBeTruthy();
expect(dialog.getByRole("button", { name: "下一页" })).toHaveProperty("disabled", true);
await userEvent.click(dialog.getByRole("button", { name: "上一页" }));
expect(await dialog.findByRole("button", { name: /finished 300 · w/ })).toBeTruthy();
expect(asked.at(-1)).toMatchObject({ order: "recent", scope: "active", limit: 50, offset: 300 });
});
it("discovers a finished conversation outside All's loaded window and locates its collapsed project", async () => {
const hub = setup();
const ordinary = hub.tree.bind(hub);
const open = vi.spyOn(hub, "open");
const root = "~/projects/my-website";
const path = "/sessions/finished.jsonl";
hub.tree = async (window) => {
if (window?.order === "recent") return [{ root, name: "my-website", recentOffset: window.offset ?? 0, remaining: 0, sessions: [{ path, name: "finished", title: "Finished release", lastActiveAt: 2000 }] }];
const tree = await ordinary(window);
const held = (await hub.runtimes()).find((rt) => rt.sessionPath === path);
return tree.map((ws) => ws.root !== root || !held ? ws : { ...ws, sessions: [...ws.sessions, { path, name: "finished", title: "Finished release", runtimeId: held.id }] });
};
render(<App hub={hub} />);
const project = await screen.findByRole("treeitem", { name: /my-website/ });
await userEvent.click(project);
await userEvent.click(screen.getByRole("button", { name: "最近的会话" }));
await userEvent.click(await screen.findByRole("button", { name: "Finished release · my-website" }));
await waitFor(() => expect(open).toHaveBeenCalledWith(expect.objectContaining({ root, sessionPath: path })));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "最近的会话" })).toBeNull());
const located = await screen.findByRole("treeitem", { name: "Finished release" });
expect(located.closest(".wsnode")?.getAttribute("data-current")).toBe("");
expect(project.getAttribute("aria-expanded")).toBe("true");
expect(located.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(located);
});
it("focuses an already-open session without opening it again, and resets Running and sidebar search", async () => {
const hub = setup();
const ordinary = hub.tree.bind(hub);
hub.tree = async (window) => window?.order === "recent" ? [{ root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", recentOffset: window.offset ?? 0, remaining: 0, sessions: [{
path: "/sessions/mock.jsonl", name: "mock", title: "Recent finished", runtimeId: "r1", lastActiveAt: 2000,
}] }] : ordinary(window);
const open = vi.spyOn(hub, "open");
render(<App hub={hub} />);
await screen.findByRole("treeitem", { name: "并行会话演示" });
const segments = document.querySelector(".studio-session-segments")!;
await userEvent.click(within(segments as HTMLElement).getByRole("button", { name: "进行中" }));
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "unrelated");
await userEvent.click(screen.getByRole("button", { name: "最近的会话" }));
await userEvent.click(await screen.findByRole("button", { name: "Recent finished · DeepSeek-Reasonix" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "最近的会话" })).toBeNull());
expect(open).not.toHaveBeenCalled();
expect(within(segments as HTMLElement).getByRole("button", { name: "全部" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("searchbox", { name: "搜索会话 / 项目" })).toHaveProperty("value", "");
expect(await screen.findByRole("treeitem", { name: "并行会话演示" })).toBeTruthy();
});
it("reads remote recent pages only after selecting that machine, retaining host-qualified identity", async () => {
const hub = setup();
const remote = vi.spyOn(hub, "remoteTree").mockResolvedValue([{ root: "/same", name: "remote project", recentOffset: 0, remaining: 0, sessions: [{ path: "/same/session.jsonl", name: "Remote finished", workspaceRoot: "/same/worktree", lastActiveAt: 5000 }] }]);
const picked = vi.fn(async () => {});
render(<RecentSessions hub={hub} hosts={[{ name: "gpu-box", target: "mock", status: "stopped" }, { name: "disabled", target: "mock", status: "idle", disabled: true }]} onPick={picked} onClose={() => {}} />);
expect(remote).not.toHaveBeenCalled();
expect(screen.queryByRole("option", { name: "disabled" })).toBeNull();
await userEvent.selectOptions(screen.getByRole("combobox", { name: "运行环境" }), "gpu-box");
await userEvent.click(await screen.findByRole("button", { name: "Remote finished · remote project" }));
expect(remote).toHaveBeenCalledWith("gpu-box", expect.objectContaining({ order: "recent", offset: 0 }));
expect(picked).toHaveBeenCalledWith("gpu-box", expect.objectContaining({ root: "/same" }), expect.objectContaining({ workspaceRoot: "/same/worktree" }));
});
it("keeps a just-completed session discoverable after it leaves Running", async () => {
const hub = setup();
const port = hub.portFor((await hub.runtimes())[0]);
const snapshot = await port.status();
let running = true;
vi.spyOn(port, "status").mockImplementation(async () => ({ ...snapshot, running }));
let deliver: ((event: WireEvent) => void) | undefined;
vi.spyOn(port, "subscribe").mockImplementation((listener) => { deliver = listener; return () => {}; });
setLiveGraceMinutes(0);
render(<App hub={hub} />);
await screen.findByRole("treeitem", { name: "并行会话演示" });
await waitFor(() => expect(document.querySelector(".studio-runstate[data-running]")).not.toBeNull());
const segments = within(document.querySelector(".studio-session-segments") as HTMLElement);
await userEvent.click(segments.getByRole("button", { name: "进行中" }));
expect(screen.getByRole("treeitem", { name: "并行会话演示" })).toBeTruthy();
running = false;
act(() => deliver?.({ kind: "turn_done" }));
await waitFor(() => expect(screen.queryByRole("treeitem", { name: "并行会话演示" })).toBeNull());
await userEvent.click(screen.getByRole("button", { name: "最近的会话" }));
await userEvent.click(await screen.findByRole("button", { name: "并行会话演示 · DeepSeek-Reasonix" }));
expect(await screen.findByRole("treeitem", { name: "并行会话演示" })).toBeTruthy();
expect(segments.getByRole("button", { name: "全部" }).getAttribute("aria-pressed")).toBe("true");
});
it("uses activity rather than unread or pin state, and applies search before paging with the saved legacy preference", async () => {
const fixture: TreeWorkspace[] = [{ root: "/w", name: "w", sessions: [
{ path: "/w/unread", name: "Unread older", unread: true, lastActiveAt: 100 },
{ path: "/w/pinned", name: "Pinned oldest", pinned: true, lastActiveAt: 50 },
{ path: "/w/recent", name: "Finished newest", lastActiveAt: 500 },
{ path: "/w/archive", name: "Archived hidden", archived: true, lastActiveAt: 1000 },
{ path: "/w/legacy", name: "Legacy history", legacy: true, lastActiveAt: 700 },
] }];
class FixtureHub extends MockHub {
tree(window?: TreeWindow) { return Promise.resolve(this.recentTree(fixture, window)); }
}
const hub = new FixtureHub();
const asked = vi.spyOn(hub, "tree");
setHidesLegacySessions(true);
const view = render(<RecentSessions hub={hub} hosts={[]} onPick={async () => {}} onClose={() => {}} />);
await screen.findByRole("button", { name: "Finished newest · w" });
expect([...document.querySelectorAll(".recent-session")].map((row) => row.getAttribute("aria-label"))).toEqual(["Finished newest · w", "Unread older · w", "Pinned oldest · w"]);
await userEvent.type(screen.getByRole("searchbox", { name: "搜索最近会话 / 项目" }), "Legacy");
expect(await screen.findByRole("button", { name: "Legacy history · w" })).toBeTruthy();
expect(asked).toHaveBeenLastCalledWith(expect.objectContaining({ q: "Legacy", hideLegacy: true, offset: 0 }));
view.unmount();
render(<RecentSessions hub={hub} hosts={[]} onPick={async () => {}} onClose={() => {}} />);
expect(await screen.findByRole("button", { name: "Finished newest · w" })).toBeTruthy();
expect(screen.queryByRole("button", { name: /Legacy history/ })).toBeNull();
});
it("does not mistake an older remote kernel's partial tree for complete recent history", async () => {
const hub = setup();
hub.remoteTree = async () => [{ root: "/w", name: "w", remaining: 100, sessions: [{ path: "/w/old", name: "Not a recent page" }] }];
render(<RecentSessions hub={hub} hosts={[{ name: "old-host", target: "mock", status: "idle" }]} onPick={async () => {}} onClose={() => {}} />);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "运行环境" }), "old-host");
expect(await screen.findByRole("alert")).toHaveProperty("textContent", "此运行环境不支持最近会话,请更新 Reasonix");
expect(screen.queryByRole("button", { name: /Not a recent page/ })).toBeNull();
});
it("keeps failed opens in the picker and reconciles a removed session on retry", async () => {
const hub = setup();
let removed = false;
hub.tree = async () => [{ root: "/w", name: "w", recentOffset: 0, remaining: 0, sessions: removed ? [] : [{ path: "/w/gone", name: "Removed elsewhere" }] }];
const pick = vi.fn(async () => { removed = true; throw new Error("No longer available"); });
const close = vi.fn();
render(<RecentSessions hub={hub} hosts={[]} onPick={pick} onClose={close} />);
await userEvent.click(await screen.findByRole("button", { name: "Removed elsewhere · w" }));
expect(await screen.findByRole("alert")).toHaveProperty("textContent", "No longer available");
expect(close).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "重试" }));
expect(await screen.findByText("没有匹配的会话")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Removed elsewhere · w" })).toBeNull();
});
@@ -0,0 +1,27 @@
import type { ThemeSchedule } from "../state/themeSchedule";
function darkAt(start: number, end: number, minute: number): boolean {
return start < end ? minute >= start && minute < end : start > end && (minute >= start || minute < end);
}
export function scheduledScheme({ start, end }: ThemeSchedule, now: Date): "light" | "dark" {
const from = Number(start.slice(0, 2)) * 60 + Number(start.slice(3));
const until = Number(end.slice(0, 2)) * 60 + Number(end.slice(3));
return darkAt(from, until, now.getHours() * 60 + now.getMinutes()) ? "dark" : "light";
}
export function nextScheduledBoundary(range: ThemeSchedule, now: Date): number | null {
if (range.start === range.end) return null;
const from = Number(range.start.slice(0, 2)) * 60 + Number(range.start.slice(3));
const until = Number(range.end.slice(0, 2)) * 60 + Number(range.end.slice(3));
const current = darkAt(from, until, now.getHours() * 60 + now.getMinutes());
const next = new Date(now);
next.setSeconds(0, 0);
// Walking actual minutes honours skipped and repeated local times at DST changes.
// Only the resulting boundary gets a timer; there is no minute polling.
for (let minute = 0; minute < 26 * 60; minute++) {
next.setTime(next.getTime() + 60_000);
if (darkAt(from, until, next.getHours() * 60 + next.getMinutes()) !== current) return next.getTime() - now.getTime();
}
return null;
}
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { sendHint } from "./sendkey";
afterEach(() => { vi.restoreAllMocks(); vi.resetModules(); });
const key = (patch = {}) => ({ key: "Enter", code: "Enter", ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...patch });
@@ -35,4 +36,11 @@ describe("platform send chords", () => {
expect(sendsOnKey(key({ shiftKey: true }), "enter")).toBe(false);
expect(sendsOnKey(key(), "enter", true)).toBe(false);
});
it("describes follow-up submission for both send shortcuts and touch without changing idle hints", () => {
expect(sendHint("enter", true, false, "followup")).toBe("Enter 排队 · Shift+Enter 换行");
expect(sendHint("modifier_enter", true, false, "followup")).toContain("排队 · Enter 换行");
expect(sendHint("enter", true, true, "followup")).toBe("点按排队 · 回车换行");
expect(sendHint("enter", false, false, "followup")).toBe(sendHint("enter", false, false, "steer"));
});
});
+7 -2
View File
@@ -1,5 +1,5 @@
import { t } from "../i18n";
import type { SendShortcut } from "../state/prefs";
import type { RunningMessageIntent, SendShortcut } from "../state/prefs";
import { ariaChord, chord, pressedChord } from "./keys";
type Key = { key: string; code: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean };
@@ -13,7 +13,12 @@ export function sendAria(mode: SendShortcut, touch = false): string | undefined
return mode === "modifier_enter" ? ariaChord("Enter") : touch ? undefined : "Enter Shift+Enter";
}
export function sendHint(mode: SendShortcut, running: boolean, touch: boolean): string {
export function sendHint(mode: SendShortcut, running: boolean, touch: boolean, intent: RunningMessageIntent = "steer"): string {
if (running && intent === "followup") {
if (touch) return t("点按排队 · 回车换行");
if (mode === "modifier_enter") return t("{key} 排队 · Enter 换行", { key: chord("Enter") });
return t("Enter 排队 · Shift+Enter 换行");
}
if (touch) return t(running ? "点按插话 · 回车换行" : "点按发送 · 回车换行");
if (mode === "modifier_enter") return t(running ? "{key} 插话 · Enter 换行" : "{key} 发送 · Enter 换行", { key: chord("Enter") });
return t(running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行");
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { inScope, listedAsLive } from "./sessionScope";
import { inScope, listedAsLive, sessionRuntime } from "./sessionScope";
const live = { path: "/a.jsonl", name: "a", archived: false };
const archived = { path: "/b.jsonl", name: "b", archived: true };
@@ -40,3 +40,15 @@ describe("a finished session stays listed as running for the grace the reader ch
expect(listedAsLive(false, undefined, 5 * MIN, 30)).toBe(false);
});
});
it("resolves open session identity by host and path without trusting a stale runtime ID", () => {
const runtimes = [
{ id: "local", base: "", root: "/w", name: "local", sessionPath: "/same.jsonl" },
{ id: "remote", base: "", root: "/w", name: "remote", host: "gpu", sessionPath: "/same.jsonl" },
{ id: "changed", base: "", root: "/w", name: "changed", sessionPath: "/other.jsonl" },
];
const session = { path: "/same.jsonl", name: "same", runtimeId: "changed" };
expect(sessionRuntime(session, runtimes)?.id).toBe("local");
expect(sessionRuntime(session, runtimes, "gpu")?.id).toBe("remote");
expect(sessionRuntime({ ...session, path: "/closed.jsonl" }, runtimes)).toBeUndefined();
});
+6 -1
View File
@@ -1,4 +1,4 @@
import type { TreeSession } from "../port/hub";
import type { RuntimeView, TreeSession } from "../port/hub";
export type SessionScope = "all" | "live" | "pinned" | "archived";
@@ -20,3 +20,8 @@ export function inScope(session: TreeSession, scope: SessionScope, searching: bo
export function listedAsLive(live: boolean, finishedAt: number | undefined, now: number, graceMinutes: number): boolean {
return live || (graceMinutes > 0 && finishedAt !== undefined && now - finishedAt < graceMinutes * 60_000);
}
export function sessionRuntime(session: TreeSession, runtimes: RuntimeView[], host = ""): RuntimeView | undefined {
return runtimes.find((runtime) => (runtime.host || "") === host &&
(runtime.sessionPath === session.path || (!runtime.sessionPath && runtime.id === session.runtimeId)));
}
+2 -2
View File
@@ -16,8 +16,8 @@ export const PAGES: Partial<Record<Section, readonly SettingPage[]>> = {
appearance: [
{ id: "look", label: "外观与主题", anchors: ["mode", "scheme", "font", "size", "weight", "contrast", "effects", "wallpaper", "language", "advanced-appearance"] },
{ id: "navigation", label: "导航与侧栏", anchors: ["navrail", "naventries", "session-quick-actions", "legacy-sessions", "live-grace", "open-session-limit", "live-stream-budget", "session-tabs"] },
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "folding", "plan-progress"] },
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "global-shortcut", "keys", "esc-stop"] },
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "composer-hint", "folding", "plan-progress"] },
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "running-message-intent", "esc-stop", "global-shortcut", "keys"] },
{ id: "window", label: "窗口与性能", anchors: ["window", "notify", "page-sleep", "graphics"] },
],
};
@@ -93,6 +93,18 @@ describe("pages inside the appearance section", () => {
});
});
describe("the shortcuts page", () => {
it("lists what stops or sends a turn together, before the key table", async () => {
draw("appearance");
await userEvent.click(await screen.findByRole("tab", { name: /快捷键/ }));
await waitFor(() => expect(shown("send-shortcut")).toBe(true));
const order = [...document.querySelectorAll<HTMLElement>("[data-setting]")]
.filter((el) => shown(el.dataset.setting ?? ""))
.map((el) => el.dataset.setting);
expect(order).toEqual(["send-shortcut", "running-message-intent", "esc-stop", "keys"]);
});
});
describe("what wraps a setting leaves with it", () => {
it("hides the disclosure around a setting together with the setting", async () => {
draw("appearance:navrail");
@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
import "./testkit";
import { Settings } from "./Settings";
import { SETTINGS, SECTION_NAME, SETTING_AT, settingMatches } from "./prefsnav";
import { storageTotal } from "./storageTally";
import { boot, STORAGE, t } from "../i18n";
import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
@@ -14,7 +15,7 @@ afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.setItem(STORAGE,
const language = (lang: string) => { localStorage.setItem(STORAGE, lang); boot(); };
const block = (anchor: string) => document.querySelector<HTMLElement>(`[data-setting="${anchor}"]`);
function draw() {
async function draw() {
const port = new MockPort();
const calls: string[] = [];
const watched = new Proxy(port, {
@@ -25,6 +26,11 @@ function draw() {
},
}) as unknown as AgentPort;
const onClose = vi.fn();
// The pane tallies the disk usage a beat after it opens, and that walk reads
// the port. A known total means no walk is scheduled at all, so the search
// is measured against the port as it stood — not against a timer that fires
// midway through the search and reads the port on the way past.
await storageTotal(watched);
render(<Settings
hub={new MockHub() as never} port={watched}
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
@@ -41,7 +47,7 @@ it.each(["market", "plugins", "mcp", "skills", "ext-runtime", "hooks", "composit
async (anchor) => {
language("en");
const entry = SETTING_AT(anchor)!;
const { find, calls } = draw();
const { find, calls } = await draw();
await act(async () => {});
expect(block(anchor)).toBeNull();
const reads = [...calls];
@@ -66,7 +72,7 @@ it.each(["market", "plugins", "mcp", "skills", "ext-runtime", "hooks", "composit
it("finds extension blocks by the displayed section name without mounting them", async () => {
language("en");
const { find, calls, onClose } = draw();
const { find, calls, onClose } = await draw();
await act(async () => {});
const reads = [...calls];
await userEvent.type(find(), "Extension");
@@ -44,6 +44,22 @@ function draw() {
}
describe("finding a setting", () => {
it("finds the composer hint and opens its immediate machine-local control", async () => {
const { find, block } = draw();
await userEvent.type(find(), "placeholder");
const hit = await screen.findByRole("option", { name: /输入框提示/ });
expect(hit.getAttribute("data-target")).toBe("composer-hint");
await userEvent.click(hit);
await waitFor(() => expect(block("composer-hint")).toBeTruthy());
const section = block("composer-hint")!;
expect(section.closest("[hidden]")).toBeNull();
expect(section.querySelector("[data-apply]")?.getAttribute("data-apply")).toBe("immediate");
expect(section.querySelector("[data-scope]")?.getAttribute("data-scope")).toBe("machine");
await userEvent.click(section.querySelector<HTMLButtonElement>('[data-value="off"]')!);
expect(localStorage.getItem("rx-composer-hint")).toBe("off");
await userEvent.click(section.querySelector<HTMLButtonElement>('[data-value="on"]')!);
expect(localStorage.getItem("rx-composer-hint")).toBe("on");
});
it("finds collapsed todo progress and opens its immediate machine-local control", async () => {
const { find, block } = draw();
await userEvent.type(find(), "todo");
@@ -0,0 +1,153 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import "./testkit";
import { Appearance } from "./Appearance";
import { usePaint } from "./paint";
import { nextScheduledBoundary, scheduledScheme } from "./scheduledTheme";
import { setThemeSchedule, themeSchedule } from "../state/themeSchedule";
import { MockHub } from "../port/mock_hub";
import { boot, STORAGE, t } from "../i18n";
const overnight = { start: "20:00", end: "07:00" };
const at = (hour: number, minute = 0, second = 0) => new Date(2026, 9, 29, hour, minute, second);
const fail = vi.fn();
beforeEach(() => {
localStorage.clear();
localStorage.setItem(STORAGE, "zh");
boot();
setThemeSchedule(overnight);
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllEnvs(); });
it.each([
[overnight, 19, 59, "light"], [overnight, 20, 0, "dark"],
[overnight, 0, 0, "dark"], [overnight, 6, 59, "dark"], [overnight, 7, 0, "light"],
[{ start: "09:00", end: "17:00" }, 9, 0, "dark"],
[{ start: "09:00", end: "17:00" }, 17, 0, "light"],
[{ start: "07:00", end: "07:00" }, 7, 0, "light"],
] as const)("resolves a local half-open daily range %o at %i:%i", (range, hour, minute, scheme) => {
expect(scheduledScheme(range, at(hour, minute))).toBe(scheme);
});
it("waits only until a real transition, including overnight and sub-minute boundaries", () => {
expect(nextScheduledBoundary(overnight, at(19, 59, 59))).toBe(1000);
expect(nextScheduledBoundary(overnight, at(23))).toBe(8 * 60 * 60 * 1000);
expect(nextScheduledBoundary({ start: "07:00", end: "07:00" }, at(12))).toBeNull();
});
it("persists a valid range, ignores incomplete edits, and defaults corrupt storage", () => {
setThemeSchedule({ start: "18:30", end: "06:45" });
expect(themeSchedule()).toEqual({ start: "18:30", end: "06:45" });
expect(JSON.parse(localStorage.getItem("rx-theme-schedule")!)).toEqual(themeSchedule());
setThemeSchedule({ start: "", end: "24:00" });
expect(themeSchedule()).toEqual({ start: "18:30", end: "06:45" });
localStorage.setItem("rx-theme-schedule", '{"start":"99:00","end":"07:00"}');
expect(themeSchedule()).toEqual(overnight);
localStorage.setItem("rx-theme-schedule", "not JSON");
expect(themeSchedule()).toEqual(overnight);
});
it("keeps a choice for this window when persistence is unavailable", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("unavailable"); });
setThemeSchedule({ start: "21:00", end: "08:00" });
expect(themeSchedule()).toEqual({ start: "21:00", end: "08:00" });
});
async function setup() {
const hub = new MockHub();
const runtimes = await hub.runtimes();
const port = hub.machinePort();
vi.spyOn(port, "themes").mockResolvedValue([{ id: "test", name: "Test", active: true,
tokens: { light: { bg: "#eeeeee", fg: "#111111" }, dark: { bg: "#111111", fg: "#eeeeee" } } }]);
const hook = renderHook(() => usePaint(hub, runtimes, false, fail));
await act(async () => {});
return { hub, port, ...hook };
}
const packed = () => ((document.getElementById("pack-theme") as HTMLStyleElement)?.sheet?.cssRules[0] as CSSStyleRule)?.style;
it("repaints active packs and contrast exactly at both boundaries, and restores mode/range after remount", async () => {
localStorage.setItem("rx-theme", "scheduled");
localStorage.setItem("rx-contrast", "strong");
vi.useFakeTimers(); vi.setSystemTime(at(19, 59, 59));
const first = await setup();
expect(first.result.current.scheme).toBe("light");
expect(packed().getPropertyValue("--page")).toBe("#eeeeee");
expect(localStorage.getItem("rx-theme-resolved")).toBe("light");
act(() => vi.advanceTimersByTime(1000));
expect(first.result.current.scheme).toBe("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
expect(packed().getPropertyValue("--page")).toBe("#111111");
expect(packed().getPropertyValue("--text")).toContain("+ 0.101");
expect(localStorage.getItem("rx-theme-resolved")).toBe("dark");
act(() => vi.advanceTimersByTime(11 * 60 * 60 * 1000));
expect(first.result.current.scheme).toBe("light");
first.unmount();
const second = await setup();
expect(second.result.current.theme).toBe("scheduled");
expect(second.result.current.scheme).toBe("light");
expect(themeSchedule()).toEqual(overnight);
});
it("reconciles sleep, visibility and clock changes on focus without replaying missed boundaries", async () => {
localStorage.setItem("rx-theme", "scheduled");
vi.useFakeTimers(); vi.setSystemTime(at(19));
const { result } = await setup();
expect(vi.getTimerCount()).toBe(1);
vi.setSystemTime(at(23));
act(() => window.dispatchEvent(new Event("focus")));
expect(result.current.scheme).toBe("dark");
vi.setSystemTime(at(8));
act(() => document.dispatchEvent(new Event("visibilitychange")));
expect(result.current.scheme).toBe("light");
expect(vi.getTimerCount()).toBe(1);
});
it("keeps explicit and system modes authoritative and cancels the schedule when leaving it", async () => {
vi.useFakeTimers(); vi.setSystemTime(at(23));
const { result } = await setup();
act(() => result.current.setTheme("light"));
act(() => setThemeSchedule({ start: "00:00", end: "23:59" }));
expect(result.current.scheme).toBe("light");
act(() => result.current.setTheme("dark"));
expect(result.current.scheme).toBe("dark");
act(() => result.current.setTheme("auto"));
expect(result.current.scheme).toBe(matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
act(() => result.current.setTheme("scheduled"));
expect(result.current.scheme).toBe("dark");
expect(vi.getTimerCount()).toBe(1);
act(() => result.current.setTheme("light"));
expect(vi.getTimerCount()).toBe(0);
act(() => vi.advanceTimersByTime(24 * 60 * 60 * 1000));
expect(result.current.scheme).toBe("light");
});
it("changes the real paint through appearance controls and leaves contrast, palettes and fixed modes available", async () => {
vi.useFakeTimers(); vi.setSystemTime(at(21));
const { port, result } = await setup();
function Picker() {
const p = result.current;
return <Appearance port={port} theme={p.theme} onTheme={p.setTheme} contrast={p.contrast} onContrast={p.setContrast}
weight={p.weight} onWeight={p.setWeight} look={p.look} onLook={p.onLook} reloadThemes={p.reloadThemes} />;
}
const view = render(<Picker />);
expect(screen.queryByLabelText(t("深色开始时间"))).toBeNull();
fireEvent.click(screen.getByRole("button", { name: t("定时") }));
view.rerender(<Picker />);
expect(result.current.scheme).toBe("dark");
fireEvent.change(screen.getByLabelText(t("深色开始时间")), { target: { value: "22:00" } });
expect(result.current.scheme).toBe("light");
expect(screen.getByLabelText(t("恢复浅色时间"))).toBeTruthy();
expect(screen.getByRole("group", { name: t("界面主题") })).toBeTruthy();
fireEvent.click(view.container.querySelector(".appearance-advanced summary")!);
fireEvent.click(screen.getByRole("button", { name: t("更强") }));
view.rerender(<Picker />);
expect(result.current.contrast).toBe("strong");
fireEvent.click(screen.getByRole("button", { name: t("深色") }));
view.rerender(<Picker />);
expect(result.current.scheme).toBe("dark");
expect(screen.queryByLabelText(t("深色开始时间"))).toBeNull();
expect(themeSchedule().start).toBe("22:00");
});
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
import type { AgentPort, WorkspaceInfo } from "../port/port";
export function useWorkspaceInfo(port: AgentPort | null, root: string, ready: boolean) {
const [read, setRead] = useState<{ port: AgentPort; root: string; value: WorkspaceInfo } | null>(null);
useEffect(() => {
if (!port || !root || !ready) return;
let current = true;
setRead(null);
void port.workspaces().then((value) => {
if (current) setRead({ port, root, value });
}).catch(() => {});
return () => { current = false; };
}, [port, root, ready]);
return ready && read?.port === port && read.root === root ? read.value : null;
}
@@ -1,8 +1,11 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook } from "@testing-library/react";
import { useSubmit } from "./usesubmit";
import { HttpError, type AgentPort } from "../port/port";
import { setRunningMessageIntent, type RunningMessageIntent } from "../state/prefs";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
const hook = (submit: () => Promise<unknown>) => {
const dispatch = vi.fn();
@@ -29,4 +32,57 @@ describe("useSubmit", () => {
const { send } = hook(async () => undefined);
expect(await send("hello")).toBe(true);
});
it.each<RunningMessageIntent>(["steer", "followup"])("routes a running message through the selected canonical intent: %s", async (intent) => {
setRunningMessageIntent(intent);
const port = {
steer: vi.fn(async () => ({ itemId: "steer", disposition: "steer_accepted" })),
queueFollowup: vi.fn(async () => ({ itemId: "followup", disposition: "queued_followup" })),
submit: vi.fn(),
};
const dispatch = vi.fn();
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch, trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
expect(await result.current("later")).toBe(true);
expect(port.steer).toHaveBeenCalledTimes(intent === "steer" ? 1 : 0);
expect(port.queueFollowup).toHaveBeenCalledTimes(intent === "followup" ? 1 : 0);
expect(port.submit).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: intent }));
});
it("uses a changed preference on the next message without remounting", async () => {
const port = { steer: vi.fn(), queueFollowup: vi.fn() };
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch: vi.fn(), trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
await result.current("steer first");
act(() => setRunningMessageIntent("followup"));
await result.current("follow up next");
expect(port.steer).toHaveBeenCalledWith("steer first");
expect(port.queueFollowup).toHaveBeenCalledWith("follow up next", undefined);
});
it.each<RunningMessageIntent>(["steer", "followup"])("leaves idle submission unchanged with %s selected", async (intent) => {
setRunningMessageIntent(intent);
const submit = vi.fn(async () => undefined);
const { send } = hook(submit);
expect(await send("idle")).toBe(true);
expect(submit).toHaveBeenCalledWith("idle", undefined);
});
it("keeps skill invocations as follow-ups even with steering selected", async () => {
const port = { steer: vi.fn(), queueFollowup: vi.fn(async () => ({ itemId: "skill" })) };
const dispatch = vi.fn();
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch, trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
const chips = { submit: "review", invocations: [] };
await result.current("review", chips);
expect(port.steer).not.toHaveBeenCalled();
expect(port.queueFollowup).toHaveBeenCalledWith("review", chips);
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: "followup" }));
});
it("uses the canonical receipt when an undeliverable steer becomes a follow-up", async () => {
const port = { steer: vi.fn(async () => ({ itemId: "converted", disposition: "queued_followup" })) };
const dispatch = vi.fn();
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch, trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
await result.current("just missed the turn");
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: "followup" }));
});
});
+9 -8
View File
@@ -2,6 +2,7 @@ import { useCallback, type Dispatch } from "react";
import { HttpError, type AgentPort, type ChipCall } from "../port/port";
import { localId } from "../state/session";
import type { reduceTraj } from "../state/trajectory";
import { useRunningMessageIntent } from "../state/prefs";
/** What came of one line: sent, taken back, or refused because this
* connection cannot run a `!` command at all. The third is a different thing to
@@ -24,6 +25,7 @@ export function useSubmit({ port, running, dispatch, trajDispatch, refreshStatus
refreshStatus: () => void;
fail: (e: unknown) => void;
}) {
const runningIntent = useRunningMessageIntent();
// The kernel refuses a submit it cannot start with a code, not a sentence:
// the words are fine, the timing is not. Queueing them is what that code
// asks for.
@@ -44,16 +46,15 @@ export function useSubmit({ port, running, dispatch, trajDispatch, refreshStatus
return useCallback(
async (text: string, chips?: ChipCall): Promise<Submitted> => {
const steering = running;
const queuedWhileRunning = running;
const id = localId();
dispatch({ kind: "__user", text, pending: steering, id } as never);
dispatch({ kind: "__user", text, pending: queuedWhileRunning, id } as never);
trajDispatch({ kind: "__user", text });
try {
if (steering) {
// The row is already on screen; the receipt is what gives it a name
// to be taken back by while it waits at the tool boundary.
const queued = chips ? await port.queueFollowup(text, chips) : await port.steer(text);
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: chips || queued.paused ? "followup" : "steer", paused: queued.paused } as never);
if (queuedWhileRunning) {
const followup = !!chips || runningIntent === "followup";
const queued = followup ? await port.queueFollowup(text, chips) : await port.steer(text);
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: followup || queued.paused || queued.disposition === "queued_followup" ? "followup" : "steer", paused: queued.paused } as never);
} else {
await submitOrQueue(text, id, chips);
}
@@ -64,6 +65,6 @@ export function useSubmit({ port, running, dispatch, trajDispatch, refreshStatus
return e instanceof HttpError && e.reason?.code === "shell.unavailable_over_http" ? "shell_unavailable" : false;
}
},
[port, running, dispatch, trajDispatch, submitOrQueue, fail],
[port, running, runningIntent, dispatch, trajDispatch, submitOrQueue, fail],
);
}
@@ -12,12 +12,12 @@ function draw(fresh = true, branch = "main", isolated = false) {
const readSources = port.worktreeSources.bind(port);
vi.spyOn(port, "worktreeSources").mockImplementation(async () => ({ ...await readSources(), sourceRoot: "/work/demo" }));
vi.spyOn(port, "status").mockResolvedValue({ workspaceRoot: "~/projects/DeepSeek-Reasonix" } as SessionStatus);
vi.spyOn(port, "workspaces").mockResolvedValue({ current: "/work/demo", canSwitch: true, canIsolate: true, recents: [], isolated });
const workspace = { current: "/work/demo", canSwitch: true, canIsolate: true, recents: [], isolated };
const isolate = vi.spyOn(port, "isolateWorkspace").mockResolvedValue();
const submit = vi.fn(async () => true);
const error = vi.fn();
const status = { workspaceRoot: "/work/demo", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", toolApprovalMode: "ask", plan: false } as SessionStatus;
render(<Composer port={port} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch, detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
render(<Composer port={port} workspace={workspace} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch, detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
return { port, isolate, submit, error };
}
@@ -1,14 +1,29 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Workspaces } from "./Workspaces";
import { Chrome } from "./Chrome";
import { Chrome as ChromeView } from "./Chrome";
import { MockPort } from "../port/mock";
import type { TreeWorkspace } from "../port/hub";
import type { WorkspaceInfo } from "../port/port";
import { useWorkspaceInfo } from "./useWorkspaceInfo";
function Chrome(props: Omit<ComponentProps<typeof ChromeView>, "workspace">) {
const workspace = useWorkspaceInfo(props.port, props.status?.workspaceRoot || props.status?.cwd || "", props.status !== null);
return <ChromeView {...props} workspace={workspace} />;
}
afterEach(cleanup);
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
const root = "/projects/site";
const copy = "/state/worktrees/one/site";
const path = "/sessions/isolated.jsonl";
@@ -63,17 +78,51 @@ it.each([false, true])("only shows a directory badge when isolated=%s", async (i
}
});
it("ignores old checkout metadata when switching conversations before it arrives", async () => {
it.each([root, copy])("ignores old checkout metadata when switching ports to %s", async (current) => {
const old = new MockPort();
const next = new MockPort();
let finish!: (value: Awaited<ReturnType<MockPort["workspaces"]>>) => void;
vi.spyOn(old, "workspaces").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.spyOn(next, "workspaces").mockResolvedValue({ current: root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
const { promise, resolve } = deferred<WorkspaceInfo>();
vi.spyOn(old, "workspaces").mockReturnValue(promise);
const reads = vi.spyOn(next, "workspaces").mockResolvedValue({ current, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
const props = { title: "工作", steer: 0, account: null, theme: "dark" as const,
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
const { rerender } = render(<Chrome {...props} port={old} status={{ workspaceRoot: copy } as never} />);
await act(async () => { rerender(<Chrome {...props} port={next} status={{ workspaceRoot: root } as never} />); });
await act(async () => finish({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
await act(async () => { rerender(<Chrome {...props} port={next} status={{ workspaceRoot: current } as never} />); });
await act(async () => resolve({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
expect(screen.queryByText("项目目录")).toBeNull();
expect(screen.queryByText("独立工作树")).toBeNull();
expect(reads).toHaveBeenCalledTimes(1);
});
it("reads again for a changed root on the same port and rejects its stale response", async () => {
const port = new MockPort();
const { promise, resolve } = deferred<WorkspaceInfo>();
const reads = vi.spyOn(port, "workspaces")
.mockReturnValueOnce(promise)
.mockResolvedValue({ current: root, isolated: true, canSwitch: true, canIsolate: true, recents: [] });
const props = { port, title: "工作", steer: 0, account: null, theme: "dark" as const,
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
const { rerender } = render(<Chrome {...props} status={{ workspaceRoot: copy } as never} />);
rerender(<Chrome {...props} status={{ workspaceRoot: root } as never} />);
await waitFor(() => expect(screen.getByText("独立工作树").title).toContain(root));
await act(async () => resolve({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
expect(screen.getByText("独立工作树").title).toContain(root);
expect(reads).toHaveBeenCalledTimes(2);
rerender(<Chrome {...props} status={{ workspaceRoot: root, running: true, sessionPath: path } as never} />);
expect(reads).toHaveBeenCalledTimes(2);
});
it("waits for a directory, accepts cwd, and clears the badge when the pane closes", async () => {
const port = new MockPort();
const reads = vi.spyOn(port, "workspaces").mockResolvedValue({ current: root, isolated: true, canSwitch: true, canIsolate: true, recents: [] });
const props = { title: "工作", steer: 0, account: null, theme: "dark" as const,
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
const { rerender } = render(<Chrome {...props} port={null} status={null} />);
rerender(<Chrome {...props} port={port} status={{} as never} />);
expect(reads).not.toHaveBeenCalled();
rerender(<Chrome {...props} port={port} status={{ cwd: root } as never} />);
await screen.findByText("独立工作树");
expect(reads).toHaveBeenCalledTimes(1);
rerender(<Chrome {...props} port={null} status={null} />);
expect(screen.queryByText("独立工作树")).toBeNull();
});
+2
View File
@@ -55,6 +55,8 @@ reasonix plugin doctor <name>
Reports component status, plan, effect receipts, recoverability, lifecycle metrics, and the process-local `runtimeOwnerFallbacks` count. Product boot binds an isolated owner; a non-zero fallback count identifies compatibility code that reached the shared default owner and needs explicit wiring.
With no live runtime to ask (the CLI's case), recoverability and resume are reported as unknown, which JSON writes as `null`: effect receipts live only in the process that owned the runtime.
Plan diagnostics separate two facts: `prefixChanged` is computed after build by comparing the previous and current snapshot `CacheHash`; `providerChanged` reports provider capability additions, removals, or reloads.
A provider-only backend roll therefore reports `prefixChanged=false, providerChanged=true` when the provider-visible system prompt and tool schemas remain byte-identical.
+201
View File
@@ -30,6 +30,7 @@
- [Embedded documentation retrieval](#embedded-documentation-retrieval)
- [@ references](#-references)
- [Two-model collaboration](#two-model-collaboration)
- [Cross-project task dispatch](#cross-project-task-dispatch)
- [Letting the model change a setting](#letting-the-model-change-a-setting)
- [Google Gemini, native (experimental)](#google-gemini-native-experimental)
@@ -179,6 +180,14 @@ tool_timeout_seconds = { "generate_video" = 1800 } # optional raw MCP tool nam
For the full schema and every field's contract, see [`SPEC.md` §5](./SPEC.md#5-configuration-toml).
The startup tool summary reuses a trusted snapshot in the current Reasonix data
directory for up to 24 hours. Probes refreshed by another data directory do not
replace that valid snapshot.
On expiry, transient probe failures retain previous
successful observations, keeping the system prefix stable across rebuilds and
restarts; definitive changes, such as a removed tool, are adopted on refresh.
Installed and project-configured MCP servers need no per-tool trust
list. The dedicated two-model Planner may use every non-destructive MCP tool,
even when the server omits `readOnlyHint`; strict read-only sub-agents still
@@ -944,6 +953,36 @@ and `PreCompact` fire in every agent a session runs. Each fires under a
- `SubagentStop` fires on every end: answer, failure, cancel or refusal;
- neither can block: exit 2 only warns.
## Studio appearance schedule
In **Settings → Appearance → Light and dark**, choose **Scheduled** to use
dark mode during a daily local-time range and light mode outside it. The initial
range is 20:00–07:00; edit either time to apply it immediately.
The start is inclusive and the end is exclusive. Overnight ranges cross
midnight. Equal times mean light all day; choose **Dark** for dark all day.
**Follow the system**, **Light**, and **Dark** retain their existing behaviour.
Choosing one stops the schedule without forgetting its times. The title-bar
theme toggle also chooses a fixed mode.
The range and mode stay on this device, using the same local preferences as
other theme choices. Desktop restores these across launches; web preferences
belong to the browser origin. If storage cannot be written, the choice lasts
only for the current window.
Scheduling uses the device's clock and timezone, not a server timezone. Skipped
or repeated daylight-saving times follow the actual local clock. Returning to
the window, restoring a page, or reloading recalculates the current scheme.
The active theme pack and text contrast still apply. Scheduling changes the
pack's light/dark variant, not the selected pack. The desktop window's native
frame and background follow the same resolved scheme.
Before the page loads, the shell uses the last saved resolved scheme as a
startup tint. The page then recalculates from the current clock and updates
the shell. CLI/TUI theme settings are unchanged.
## Keyboard shortcuts
Shortcuts are documented by client because users usually look for the keys that
@@ -1000,6 +1039,14 @@ Composer shortcuts:
| Plain `Up` / `Down` at the prompt boundary | Recalls older or newer submitted prompts | Modified arrows and native text navigation stay with the textarea. |
| `Esc` while a turn is running | Cancels the running turn | If the turn has not produced a response yet, the draft is restored. |
**Settings → Appearance → Conversation display → Composer hint** controls
guidance in the empty task input. It is on by default. Turning it off hides
both task and queue placeholders immediately and persists on this machine.
The input's accessible name, entered text, queue content, IME handling and
send shortcuts stay unchanged. Turning the hint back on restores the
responsive guidance at the theme's normal contrast.
Remembered projects have Move up / Move down in their project menu. Open it with the keyboard, navigate with arrows or Home/End, and use Escape to return to the trigger. Moving a project changes sidebar order without changing the launch project.
Studio remembers which local and remote projects you collapse in the sidebar,
@@ -1007,6 +1054,32 @@ including Collapse all / Expand all, when you reopen it in the same browser or
desktop profile. New projects start expanded. These display choices stay on
this device; they do not change sessions or synchronize to another client.
**Recent** in the project heading finds saved conversations across projects,
including completed work that has left **Running**. It uses each conversation's
last saved activity, not its unread status, pin order or last opening time.
The picker reads 50 conversations per page from the complete matching history.
**Previous page**, **Next page** and its session/project search reach older
conversations without the sidebar's 300-row display ceiling.
Selecting a conversation opens it, or focuses its existing pane, switches the
sidebar to **All**, clears its search and expands the owning project. Worktree
conversations retain their own working directory. On phones, opening a session
closes the rail as usual; reopening it shows the selected project and session.
Archived conversations stay in **Archived**. Recent respects the saved
hide-legacy preference; an explicit search can still find legacy history.
Deleted files or unavailable folders report the normal opening error.
The environment selector starts with this machine. Selecting a remote host
reads its recent history through the existing connection flow; hidden or disabled
hosts are not offered. Older kernels ask for an update rather than showing a
partial sidebar window as complete recent history.
Activity comes from persisted session metadata and survives restart. The picker
reopens on the first page with no search; it does not create a separate visit
history or change the saved project order.
Ordinary Reasonix-managed worktree chats appear under their original project,
without a separate project row for each checkout.
@@ -1548,6 +1621,16 @@ background tool-call failure. Removing the MCP server also removes its local
OAuth state unless a lower-priority declaration for the same resource becomes
effective.
If a server's configuration file or source directory was deleted outside
Reasonix, **Remove** in the running session also clears its retained connection,
startup failure, or on-demand tools.
An unknown name still reports an error, and unreadable or unwritable
configuration is not treated as a successful removal.
Servers managed by a plugin package must be disabled or removed with that
package; removing a writable declaration may reveal a same-name fallback.
Browse the official MCP Registry from **Settings → MCP servers → Browse
registry**, or use `reasonix mcp browse [query]` and
`reasonix mcp install <registry-name>`. Registry access is explicit and never
@@ -2040,6 +2123,20 @@ so ordinary `@mentions` stay literal. Typing `/` or `@` opens an autocomplete
menu — slash commands, or hierarchical file navigation (one directory level at a
time, descend into folders) plus MCP resources.
- **Line snapshots:** `@path/to/file:10-20` attaches source lines 10 through 20,
inclusive and starting at 1. The file is read when the message is sent, with
its original start and end lines recorded for accurate citations.
- **Paths:** keep the existing space escape spelling, e.g.
`@src/my\ file.go:10-20`. Only a final digits-only range suffix is recognized;
Windows drive colons are preserved.
- **Literal mentions:** if the path does not resolve to an existing local entry,
the token stays literal, including `@alice:1-2` and `@10-12`.
- **Refusals:** for existing paths, zero, inverted, or out-of-file ranges produce
a visible notice without falling back to the whole file. Ranges require a
readable regular text file; selected text over 64 KiB is refused rather than truncated.
- Whole-file references and text selected in the workspace viewer remain
available.
In Studio's file panel, right-click one file or folder:
- **Copy path** copies its absolute workspace path, including remote roots.
@@ -2491,6 +2588,110 @@ Not built, and why:
- Gemini: its video input is in the native Gemini API (inline data, the Files API, YouTube URLs); the OpenAI-compatible page does not list a video content part, and Reasonix reaches Gemini through that endpoint.
- Zhipu GLM: the documentation shows `video_url` with a public HTTPS address only and says nothing about base64 or limits, so a local file cannot be sent without Reasonix uploading it somewhere.
## Cross-project task dispatch
Use this when one project needs an answer from another project's session, rather
than letting the current session edit across project roots. Dispatch is
experimental and off by default.
Start from a **local session in Reasonix Studio
or the web UI**; terminal/protocol sessions and SSH-driven panes cannot open the
receiving session.
1. Add the target folder to your projects in the sidebar and open it. Answer its
workspace trust question there before dispatching. The target must be an
existing directory in your project list; use its full path if several projects
share a folder name.
2. In the sending session, open **Settings → Session messages → Messages between
sessions (experimental)**. Turn on **Turn on session messages**, then **Let the
model hand a task to another session**.
These save to your user config, not
`reasonix.toml`. They apply between turns; if a turn is running, send the task
on the next turn after saving. No session restart is needed for these UI
switches.
3. Make a concrete request, including the context the other project needs:
> In `/work/shop-web`, we need to show an order's delivery status. Hand a task
> to the `shop-api` project at `/work/shop-api`: inspect its order-status API,
> report the endpoint, response fields and possible status values, and do not
> change files. Bring its answer back here and explain how this frontend
> should call it.
The model uses `dispatch_to_project` to open a session in the target project.
Later dispatches from this sender to the same project reuse that dispatched
session while it remains open; they do not take over an arbitrary existing
conversation.
4. You can inspect the work in the target project's session. When its turn ends,
its final answer is sent back automatically to the **sending session**, as a
message labelled as data from the other session.
Return to the sending
conversation for the reply and the model's continuation. The initial dispatch
receipt only confirms delivery, not completion.
If you paused the receiving
queue yourself, standing consent does not resume it: resume it to process the
queued task or reply.
The equivalent user configuration is:
```toml
[peers]
enabled = true
dispatch = true
```
**Consent and permissions.** Enabling dispatch is standing consent for tasks and
replies between the paired sessions: there is no separate message-approval card
for each one.
Ordinary messages between unpaired sessions still need approval
unless you separately release the receiving project under **Projects allowed through**.
That list is optional for dispatch and is not workspace trust; releasing a
project skips incoming-message consent, not tool permissions or sandbox rules.
You can revoke a released project there.
A newly dispatched session inherits the sender's model, preset, approval mode
and planning mode.
In particular, a YOLO sender opens a YOLO session: the target
project's `ask` permission rules are auto-allowed in that mode, while `deny`
rules still hold.
Workspace permissions and sandbox restrictions still apply;
dispatch consent is not a promise that no files will change. Choose the sender's
mode and task scope accordingly.
**Trust and limits.** Naming an undecided project already in your sidebar can
trust it automatically where whole-workspace trust is allowed. A declined
project is refused.
If trust needs a user answer, its pane opens with the trust
question and the task is **not sent**: answer there, then ask to dispatch again.
For a folder not yet in your project list, add it yourself, or let the model
request `open_projects`; its card lists the paths and requires your explicit
approval even in YOLO mode before adding or trusting them.
`open_projects` is unavailable in plan mode until the plan is approved.
Dispatch refuses unknown or ambiguous projects and the current project itself
(work locally or use a sub-agent instead).
The default ceilings are one dispatch
level and eight open dispatched sessions per sender; user-only
`peers.max_dispatch_depth` and `peers.max_dispatched_sessions` can change them
(`0` removes a ceiling).
A workspace-bound agent composition cannot dispatch
into another workspace.
Disabling dispatch also stops automatic replies; live
pairings are held in memory, so do not rely on them surviving an application
restart.
## Letting the model change a setting
The model has a `setting` tool. When something it needs is switched off, such as handing a task to another project, it can ask you to turn the switch on instead of sending you to Settings.
+130
View File
@@ -30,6 +30,7 @@
- [内置文档检索](#内置文档检索)
- [@ 引用](#-引用)
- [双模型协同](#双模型协同)
- [跨项目任务派发](#跨项目任务派发)
- [让模型修改设置](#让模型修改设置)
- [Google Gemini 原生接口(实验)](#google-gemini-原生接口实验)
@@ -188,6 +189,10 @@ tool_timeout_seconds = { "generate_video" = 1800 } # 可选:raw MCP tool 名
完整 schema 与每个字段的契约见 [`SPEC.md` §5](./SPEC.md#5-configuration-toml)。
启动工具摘要会复用当前 Reasonix 数据目录中仍可信的快照,有效期为 24 小时。其他数据目录
刷新探测结果不会替换这份有效快照。快照过期后,临时探测失败会保留先前的成功记录,让系统
前缀在运行时重建和应用重启之间保持稳定;工具被移除等确定性变化会在刷新时生效。
已安装或由项目配置声明的 MCP server 不需要逐工具信任名单。独立双模型 Planner 可使用所有
非 destructive 工具,即使 server 没有声明 `readOnlyHint`;严格只读 subagent 仍要求
`readOnlyHint: true` 且无 `destructiveHint`。
@@ -715,6 +720,29 @@ deny = ["Bash(git push*)"]
- 不存在“仅本次会话”的作用域:hooks 来自设置文件,对每个加载它们的会话生效。
- 原生 hook 不能替用户回答审批提示;只有 `PreToolUse` 的阻断会拒绝一次调用。
## Studio 定时明暗
在 **设置 → 外观 → 明暗** 选择 **定时**,每天在指定的本机时间段内使用深色,
其余时间使用浅色。初始时间段为 20:00–07:00,修改任一时间立即生效。
开始时间进入深色,结束时间恢复浅色,支持跨午夜。开始与结束相同时全天为浅色;
若要全天深色,请选择 **深色**。
**跟随系统**、**浅色** 和 **深色** 保持原有行为。选择它们会停止定时,
但保留已设置的时间。标题栏的明暗切换按钮也会选择固定模式。
模式和时间段与其他主题偏好一样保存在本机。桌面端在重启后恢复;
网页版按浏览器来源分别保存。若无法写入存储,选择仅在当前窗口有效。
定时使用当前设备的时钟和时区,不使用服务器时区。夏令时跳过或重复的时间
按实际本地时钟处理。重新聚焦窗口、恢复页面或重新加载时会重新判断当前明暗。
已选主题包和文字对比度仍然生效;定时仅切换主题包的深浅版本。
桌面窗口的原生边框和背景也跟随同一次明暗判断。
页面加载前,桌面 shell 使用上次保存的明暗作为启动底色;
页面随后按当前时钟重新判断并同步 shell。CLI/TUI 的主题设置不受影响。
## 快捷键
这里按使用端来写,因为用户通常是先知道“我现在在桌面端/CLI”,再找对应按键。
@@ -764,6 +792,12 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u
| 输入边界处的普通 `Up` / `Down` | 回放更旧或更新的已提交提示词 | 带修饰键的方向键和原生文本导航仍交给 textarea。 |
| 运行中按 `Esc` | 取消当前 turn | 如果后端尚未开始回复,会恢复草稿。 |
**设置 → 外观 → 会话显示 → 输入框提示**控制空白任务输入框中的引导文字,默认显示。
关闭后立即隐藏任务和排队占位提示,并记住本机选择。
输入框的无障碍名称、已输入文字、队列内容、输入法处理和发送快捷键不变。
重新开启后,以主题原有对比度恢复自适应提示。
菜单与控件:
| 按键或控件 | 作用 | 说明 |
@@ -893,6 +927,24 @@ Studio 会在同一浏览器或桌面配置中记住侧边栏本机和远程项
已保存项目的操作菜单提供上移和下移。用键盘打开菜单后,方向键或 Home/End 可导航,Escape 会返回触发按钮。移动只改变侧栏顺序,不改变启动项目。
项目标题旁的**最近**按会话最后保存的活动时间跨项目查找,也能找到已经离开**进行中**的已完成会话。
未读状态、置顶顺序和仅仅打开会话都不会改变这个排序。
列表每页读取 50 个会话,上一页、下一页和会话/项目搜索覆盖完整匹配历史,
不受侧栏 300 行显示上限限制。
点选后打开会话或聚焦已有面板,切回**全部**、清空侧栏搜索并展开所属项目。
worktree 会话保留自己的工作目录;手机上仍照常关闭侧栏,再打开侧栏即可看到选中的项目和会话。
归档会话仍在**归档**中。最近遵循已保存的隐藏旧版会话设置,明确搜索时仍能找到旧版历史。
文件已删除或目录不可用时,显示正常的打开错误。
运行环境默认选择本机;选中远程主机后才按既有连接流程读取其最近会话。
隐藏或禁用的主机不会出现;旧版内核会提示更新,不把不完整的侧栏窗口当作完整历史。
活动时间来自持久化的会话元数据,重启后仍然有效。每次打开最近都从空搜索的第一页开始,
不会另建访问历史,也不改变已保存的项目顺序。
Reasonix 管理的普通 worktree 会话统一列在原项目下,不再为隔离目录单列同名项目。
只有独立工作树会在顶部和底部目录选择器显示中性色的“独立工作树”标识。
普通目录不显示徽标,底部只显示分支名。
@@ -1170,6 +1222,10 @@ OAuth client 与 token 状态保存在工作区之外、该 server 私有的 Rea
主动点击或运行登录命令后打开浏览器,不会因后台工具调用失败而自动弹出浏览器。删除 MCP server
也会删除其本地 OAuth 状态;若删除后有同一 resource 的低优先级声明生效,则保留该状态。
如果在 Reasonix 外删除了服务器的配置文件或来源目录,运行中会话里的「移除」也能清掉
残留连接、启动失败记录或按需工具。未知名称仍会报错,配置无法读取或写入时不会伪报移除成功。
由插件包管理的服务器仍需停用或移除对应插件;删除可写声明后,同名的低优先级声明可能重新生效。
可在 **设置 → MCP 服务器 → 浏览市场** 打开官方 MCP Registry,也可使用
`reasonix mcp browse [query]` 与 `reasonix mcp install <registry-name>`。Registry
只在用户显式浏览或安装时联网,不进入启动路径。需要 secret 或必填参数的条目只显示为手动配置,
@@ -1500,6 +1556,15 @@ evaluator 判定。旧 `.reasonix/autoresearch/<task-id>/` 目录保持只读:
真实存在**时才当作引用,普通 `@mention` 保持原文。敲 `/` 或 `@` 会弹出补全菜单——斜杠
命令,或**逐层**的文件导航(一次只列当前一层目录、可下钻进子目录)外加 MCP 资源。
- **行快照:**`@path/to/file:10-20` 只附上源文件第 10 至 20 行(含首尾,从 1 开始计数)。
文件在发送消息时读取,上下文块携带原始起止行号,方便模型准确引用源文件位置。
- **路径:**有空格时沿用转义写法,例如 `@src/my\ file.go:10-20`。
只有末尾的纯数字 `:起始行-结束行` 才识别为范围,不会拆掉 Windows 盘符中的冒号。
- **普通提及:**路径无法解析为真实存在的本地条目时,整个标记保持原文,包括 `@alice:1-2` 和 `@10-12`。
- **拒绝:**路径存在时,行号为零、倒序或超出文件范围会显示提示,不会退回引用整个文件。
范围只支持可读的普通文本文件;所选内容超过 64 KiB 会拒绝,而非静默截断。
- 整文件引用和工作区查看器中的手动选中文本引用保持不变。
Studio 文件面板里,右键文件或文件夹可选择「复制路径」(工作区中的绝对路径)或
「复制引用」(可粘贴到输入框的 `@` 引用)。空格和制表符会被转义,目录引用以 `/`
结尾。引用语法无法表示的名称会禁用「复制引用」;工作区提供绝对根路径后才可
@@ -1845,6 +1910,71 @@ video_protocol = "inline_data_url" # 或 "moonshot_file"
- Gemini:视频输入在原生 Gemini 接口里(内联数据、Files API、YouTube 链接);OpenAI 兼容页没有列视频内容块,而 Reasonix 是通过那个端点接 Gemini 的。
- 智谱 GLM:文档只给了公网 HTTPS 地址的 `video_url` 示例,没写 base64 与限制,所以不能在不替用户把文件传到别处的前提下发送本地文件。
## 跨项目任务派发
当一个项目需要另一个项目的会话提供答案,而不是让当前会话跨项目根目录修改文件时,
可以使用派发。此功能仍为实验功能,默认关闭。请从 **Reasonix Studio 或 Web UI 中的
本机会话**发起;终端、协议会话和 SSH 驱动的窗格不能打开接收任务的会话。
1. 将目标文件夹添加到侧栏的项目列表并打开,先在那里回答工作区信任问题。
目标必须是项目列表中实际存在的目录;若多个项目的文件夹同名,请使用完整路径。
2. 在发起会话打开 **设置 → 会话间消息 → 会话间消息(实验)**,先打开
**启用会话间消息**,再打开 **允许模型把任务交给别的会话**。
开关写入用户配置,不是 `reasonix.toml`,在回合之间生效;如果当前回合还在运行,
保存后请在下个回合发送任务。这两个 UI 开关不需要重启会话。
3. 给出具体请求,并附上对方项目需要的上下文,例如:
> 我们在 `/work/shop-web` 里要展示订单配送状态。请把任务交给
> `/work/shop-api` 的 `shop-api` 项目:检查订单状态 API,报告端点、响应字段和
> 可能的状态值,不要修改文件。把答案带回这里,并解释这个前端该怎样调用。
模型通过 `dispatch_to_project` 在目标项目打开会话。同一个发起会话后续再向该项目
派发时,只要之前派出的会话仍开着,就会复用它;不会接管任意已有对话。
4. 可以在目标项目的会话里查看工作过程。对方回合结束时,最终答复会自动送回
**发起会话**,标注为来自另一个会话的数据。回到发起对话查看回信和模型的后续处理。
最初的派发回执只说明已送达,不代表已完成。如果你亲手暂停了接收队列,
总同意不会替你恢复;需要恢复队列,才会处理排队的任务或回信。
等价的用户配置为:
```toml
[peers]
enabled = true
dispatch = true
```
**同意与权限。** 打开派发就是对配对会话间任务和回信的长期同意,不会为每条消息单独
出审批卡。不属于派发配对的普通会话间消息仍需批准,除非你在 **放行的项目** 中另行
放行了接收项目。派发不要求配置此列表;它也不是工作区信任:放行项目只跳过来信的
同意步骤,不会跳过工具权限或沙盒规则。可以在该列表随时撤销放行。
新派出的会话继承发起方的模型、预设、审批档位和计划模式。特别是 YOLO 发起方会
打开 YOLO 会话:该模式下目标项目的 `ask` 权限规则会自动放行,`deny` 规则仍然有效。
工作区权限与沙盒限制仍然适用;同意派发不代表保证不会修改文件。
请据此选择发起会话的模式和任务范围。
**信任与限制。** 对已在侧栏、尚未决定信任的项目,在允许整工作区信任时,
派发点名本身就可能自动信任它。已经拒绝信任的项目会被拒绝派发。
若信任需要你回答,目标窗格会打开并显示信任问题,任务**尚未发送**:
请在那里回答,然后再要求派发。
对于尚未加入项目列表的文件夹,可以自己添加,
或让模型通过 `open_projects` 请求;其审批卡会列出路径,即使在 YOLO 模式下也必须
由你明确批准,才会添加或信任。
计划模式下不能使用 `open_projects`,需要先批准计划。
不存在或重名不明确的项目、当前项目本身都会被拒绝(当前项目应在本地完成或用
subagent)。
默认最多派发一层,每个发起会话最多保持八个派出会话打开;
只能由用户级配置 `peers.max_dispatch_depth` 和 `peers.max_dispatched_sessions`
调整,`0` 表示取消对应上限。
绑定工作区的 agent composition 不能向另一个工作区派发。
关闭派发也会停止自动回信;运行中的配对关系保存在内存里,不要依赖它们跨应用重启保留。
## 让模型修改设置
模型有一个 `setting` 工具。需要的功能被关着时(比如把任务交给另一个项目),它可以请你把开关打开,而不是让你自己去设置里找。能被某项设置解除的拒绝会以 `(fix: setting ...)` 结尾,工具也可以按设置 id 里的词列出设置。
+17
View File
@@ -770,6 +770,23 @@ leave the workspace are still reported as claim violations.
Declaring paths is what buys parallelism; it costs `bash` on hosts where the OS
sandbox cannot enforce write roots.
- On resume after yielding for user input, the owner-side claim record identifies
other sessions that claimed overlapping paths during the wait.
- A foreign claim or incomplete history adds a persistent `workspace_claim_yielded`
checkpoint coverage gap alongside the model notice and completion-receipt gap.
- A crossing observation cannot attribute watched paths or a whole-workspace
diff to its tool, even when files look unchanged. Unknown mutations retain
their `unproven_mutation` obligation.
- The lease owner tracks observation boundaries per prepared call, including
child calls and optimistic calls that claim no additional paths.
- Fresh or disjoint operations are not tainted by another waiter's repeated
notice, and cannot discharge the earlier operation's debt.
The existing bounded claim log reports unknown coverage when a full retained
history no longer includes the yield's mark; a short complete history is not
treated as truncation. These facts describe cooperating lease owners, not writes
by external processes that bypass the claim protocol.
### 3.13 Sub-agent context inheritance is explicit
A child inherits nothing implicitly. What it receives is exactly this:
+2 -2
View File
@@ -2,7 +2,7 @@
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-08
reviewed: 2026-10-29
---
# Studio parity with the 1.x desktop app
@@ -89,7 +89,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
| Browser panel | Have | `ui/BrowserPanel.tsx` |
| Integrated terminal panel | Unverified | `ui/Shell.tsx` only chooses the shell; no terminal view was found by name; no issue yet |
| Worktree merge dialog | Unverified | Worktree handling exists in `ui/Workspaces.tsx`; the merge flow was not checked |
| Scheduled tasks (heartbeat) | Missing | #11384 |
| Scheduled tasks (heartbeat) | Have differently | Scheduled runs exist (`ui/ScheduledRuns.tsx` and the schedule API, with budgets, breakers and pause reasons); 1.x's separate heartbeat loop was not ported; #11384 |
| Keyboard shortcuts cheat sheet | Unverified | Key handling is in `ui/keys.ts`, `ui/windowkeys.ts`; no cheat sheet found by name; no issue yet |
| CSV and media preview in the workspace panel | Have differently | `ui/document`: CSV/TSV, PDF and still images render in the workbench; audio/video remain unsupported. CSV/TSV、PDF 和图片可直接阅读,音视频仍不支持内联预览。 |
| File delivery cards / 文件交付卡片 | Have | `present_files` and `ui/document/DeliveryCard.tsx`: validated file references reopen in the shared workbench; auxiliary actions live in More. 已验证文件引用复用工作台标签,辅助操作位于“更多”。 |
+95
View File
@@ -0,0 +1,95 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-28
---
# Worktree apply ancestors / 工作树应用的父目录边界
## Behavior / 行为
An isolated candidate names Git directory entries. If a source ancestor becomes
a directory link while that candidate runs, matching leaf bytes do not establish
that the candidate still addresses the original path.
Applying now refuses that
ancestor with the existing path-conflict result before reading leaf content or
writing a sibling change. Links inside the repository are refused too, including
links from a selected project folder to its surrounding repository.
隔离候选使用 Git 目录项路径。候选运行期间,如果源文件的父目录被换成目录链接,
即使最终文件内容相同,也不能认定仍是原路径。应用现在会在读取最终文件内容或写入
其他变更前,以已有的路径冲突结果拒绝该父目录。仓库内部链接也会被拒绝,包括从所选
项目目录指向仓库其他目录的链接。
Deletion ancestors are checked in the shared worktree publisher, including
ordinary isolation and best-of callers. Path-by-path apply checks every changed
path's ancestors before hashing leaves.
Deletion uses an open `os.Root` for the source repository, so a parent
link cannot redirect that filesystem operation outside the opened root. A link
at the final deleted entry is removed as an entry; its target is not removed.
Whole-workspace application may delete a file before creating a directory at
the same name. Its deletion preflight preserves that conversion; Git owns the
subsequent restoration. Path-by-path application's existing refusal of
unsupported tree entries is unchanged.
共同 worktree 发布层检查删除路径的父目录,覆盖普通隔离和 best-of 调用方。逐路径应用
还在计算文件摘要前检查所有变更路径的父目录。
删除通过源仓库的已打开 `os.Root` 执行,父目录链接不能将该删除
操作重定向到根目录外。被删除的最终目录项本身若是链接,只删除链接,不删除其目标。
整工作区应用可能先删除一个文件,再创建同名目录。删除前置检查保留这一转换,后续
恢复由 Git 负责。逐路径应用原有的“不支持的 tree 条目”拒绝规则不变。
The existing apply tool reports the conflict and retains the original result ID
and candidate. After restoring the directory, reopening the Store can apply that
same ID. New directories, ordinary deletions, and unrelated workspace edits keep
their existing behavior.
No configuration, tool schema, prompt prefix, journal,
or persisted format changes; existing results use the checks when applied by the
new reader. Older binaries retain their previous behavior.
已有应用工具报告冲突并保留原 result ID 和候选。恢复目录后,重新打开 Store 可使用
同一 ID 应用。新建目录、正常删除及工作区其他修改保留原行为。不修改配置、工具
schema、提示词前缀、journal 或持久化格式;新版本应用旧结果时同样执行检查。
旧二进制仍保留原行为。
## Evidence and limits / 证据与限制
Regression fixtures create real Git candidates, then replace source ancestors
with external, internal, dangling, or non-directory entries. They check both
deletions and writes, sibling preservation, and Store reconstruction followed by
same-ID retry.
Windows fixtures use directory junctions; Unix fixtures use
symbolic links. The existing Windows isolation inventory requires every named
regression to pass, with no missing, duplicate, failed, or skipped terminal.
回归测试创建真实 Git 候选,再将源父目录换成外部链接、内部链接、悬空链接或普通文件,
覆盖删除、写入、其他文件保持不变,以及重建 Store 后用原 ID 重试。Windows 使用目录
junction,Unix 使用符号链接。已有 Windows 隔离清单要求每个指定测试通过,拒绝缺失、
重复、失败或跳过的终态。
These are ancestor-type preconditions and confined deletion, not an atomic
multi-file transaction or an inode compare-and-swap. A directory replaced by
another real directory containing identical files is not distinguished.
Checks cannot fence arbitrary writers between inspection and Git restoration. File
restoration still uses Git, and errors after publication begins may leave partial
changes.
Physical process settlement, remote effects, mount changes, complete
native publisher ownership, and crash-safe rollback remain separate work.
本修补提供父目录类型前置条件及受根目录约束的删除,不是多文件原子事务或 inode CAS。
真实目录被换成包含相同文件的另一真实目录时,不会区分。
检查无法阻止任意外部写入者
在检查与 Git 恢复之间修改路径。文件恢复仍由 Git 执行,开始发布后的错误可能留下部分
变更。进程物理终结、远程效果、挂载点变化、完整原生发布所有权与崩溃安全回滚仍是
独立的未完成工作。
@@ -73,6 +73,9 @@ func adoptCollectingNotices(t *testing.T, from *control.Controller, home, dir st
if err := ApplyRuntimeMigration(to, from, CaptureRuntimeMigration(from)); err != nil {
t.Fatalf("ApplyRuntimeMigration: %v", err)
}
if from.History()[0].Content != to.History()[0].Content {
t.Logf("system prompt changed during migration: %q", firstDivergence(from.History()[0].Content, to.History()[0].Content))
}
return notices
}
+51 -30
View File
@@ -10,10 +10,10 @@ import (
// RuntimeDoctorReport is the structured runtime diagnostics document for
// `reasonix doctor runtime` and desktop status panels.
type RuntimeDoctorReport struct {
Status *extension.RuntimeStatus `json:"status,omitempty"`
Status *extension.RuntimeStatus `json:"status"`
Metrics extension.LifecycleMetricsSnapshot `json:"metrics"`
Recoverability extension.Recoverability `json:"recoverability"`
Resume extension.ResumeDecision `json:"resume"`
Recoverability *extension.Recoverability `json:"recoverability"`
Resume *extension.ResumeDecision `json:"resume"`
PublishedGen uint64 `json:"publishedGeneration"`
DrainingGens []uint64 `json:"drainingGenerations,omitempty"`
RuntimeOwnerFallbacks uint64 `json:"runtimeOwnerFallbacks"`
@@ -22,33 +22,39 @@ type RuntimeDoctorReport struct {
// CollectRuntimeDoctor builds a report from an optional live BuildResult and
// its session-lineage owner. Also sweeps expired drains so doctor is a natural
// product trigger for drain-timeout force-expire.
// product trigger for drain-timeout force-expire. A nil BuildResult means no
// runtime was available to ask: verdicts stay nil (unknown), never clean.
func CollectRuntimeDoctor(res *BuildResult) RuntimeDoctorReport {
owner := extension.DefaultRuntimeOwner
if res != nil && res.Owner != nil {
owner = res.Owner
report := RuntimeDoctorReport{
Metrics: extension.DefaultLifecycleMetrics.Snapshot(),
RuntimeOwnerFallbacks: extension.RuntimeOwnerFallbackCount(),
}
if res == nil {
return report
}
owner := res.Owner
if owner == nil {
owner = extension.DefaultRuntimeOwner
}
gate := owner.Gate
_ = gate.SweepAndForceExpire()
gen := gate.Published()
report := RuntimeDoctorReport{
Metrics: extension.DefaultLifecycleMetrics.Snapshot(),
PublishedGen: gen,
DrainingGens: gate.DrainingGenerations(),
RuntimeOwnerFallbacks: extension.RuntimeOwnerFallbackCount(),
Recoverability: owner.AssessRecoverability(gen),
Resume: owner.DecideResume(gen),
report.PublishedGen = gen
report.DrainingGens = gate.DrainingGenerations()
rec := owner.AssessRecoverability(gen)
decision := owner.DecideResume(gen)
report.Recoverability = &rec
report.Resume = &decision
report.Status = res.Status
if res.Status != nil {
report.Text = FormatRuntimeStatus(res.Status)
}
if res != nil {
report.Status = res.Status
if res.Status != nil {
report.Text = FormatRuntimeStatus(res.Status)
}
if res.Snapshot != nil {
g := res.Snapshot.Generation()
report.Recoverability = owner.AssessRecoverability(g)
report.Resume = owner.DecideResume(g)
}
if res.Snapshot != nil {
g := res.Snapshot.Generation()
rec := owner.AssessRecoverability(g)
decision := owner.DecideResume(g)
report.Recoverability = &rec
report.Resume = &decision
}
if report.Text == "" {
report.Text = FormatRuntimeStatus(report.Status)
@@ -68,13 +74,28 @@ func RenderRuntimeDoctorText(report RuntimeDoctorReport) string {
body = "runtime status: unavailable\n"
}
body += fmt.Sprintf("runtime owner fallbacks: %d\n", report.RuntimeOwnerFallbacks)
body += fmt.Sprintf("recoverability: clean=%v irreversible=%v\n", report.Recoverability.Clean, report.Recoverability.HasIrreversible)
body += fmt.Sprintf("resume: allow=%v cleanRollback=%v\n", report.Resume.AllowResume, report.Resume.CleanRollback)
for _, n := range report.Recoverability.Notes {
body += " note: " + n + "\n"
if report.Recoverability == nil {
body += "recoverability: unknown\n"
} else {
body += fmt.Sprintf("recoverability: clean=%v irreversible=%v\n", report.Recoverability.Clean, report.Recoverability.HasIrreversible)
}
for _, n := range report.Resume.Notes {
body += " resume-note: " + n + "\n"
if report.Resume == nil {
body += "resume: unknown\n"
} else {
body += fmt.Sprintf("resume: allow=%v cleanRollback=%v\n", report.Resume.AllowResume, report.Resume.CleanRollback)
}
if report.Recoverability == nil || report.Resume == nil {
body += " note: no runtime was available to ask; effect receipts live only in the process that owned the runtime\n"
}
if report.Recoverability != nil {
for _, n := range report.Recoverability.Notes {
body += " note: " + n + "\n"
}
}
if report.Resume != nil {
for _, n := range report.Resume.Notes {
body += " resume-note: " + n + "\n"
}
}
if len(report.DrainingGens) > 0 {
body += fmt.Sprintf("draining generations: %v\n", report.DrainingGens)
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more