Compare commits

...
38 Commits
Author SHA1 Message Date
yhh cd20bb50be test(studio): the sidebar guards expect the retired feedback row to be absent
sidebar-tools aligned the settings icon with the feedback row's and
rail-rows-align measured it; with the channel retired the row is not drawn,
so both now assert that instead.
2026-11-11 23:44:44 -08:00
yhh a6b7f9d8c5 docs(release): 2.61.0 notes name the Go toolchain bump 2026-11-11 23:11:54 -08:00
yhh 75f5604001 build: Go 1.26.11, which fixes GO-2026-6859 in net/http
govulncheck reports the standard library's net/http at the pinned 1.26.10
as reachable through the provider client, the egress proxy and the serve
hub.
2026-11-11 23:11:23 -08:00
yhh bb5888a50d fix(studio): the wrapped changes head owns its height, and the resize handle stays inside the explorer
The head rule lost to a more specific fixed height, so its second line drew
over the first row; and the handle hung 4px past the explorer, which made
the panel scroll sideways. Both browser guards of the git panel caught it.
2026-11-11 23:06:20 -08:00
yhh 9eace52719 test: lift the feedback and telemetry retirement in the suites of the live behaviour
The boot effect tests drive the real feedback service and the serve market
test the statistics consent; both now run with the retirement switches off,
as the telemetry and cli suites already do.
2026-11-11 22:49:15 -08:00
yhh c8a7d7ef9a docs(release): Studio 2.61.0 notes 2026-11-11 22:46:43 -08:00
yhh 21ffa334b7 feat: retire the feedback channel, crash report upload and anonymous statistics
The project is no longer maintained, so nothing reports to the maintainers.
The boot assembly builds no feedback service, the telemetry reporter and its
opt-out reading are retired by one switch, crashreport.Send refuses with
ErrRetired, and the window draws no feedback entry. The code stays for
whoever takes the project over; tests of the live behaviour lift the
switches.
2026-11-11 22:46:01 -08:00
yhh ccaefa0822 docs(readme): remove community and contact channels
Drop the Discord, QQ group and Douyin links, the Discussions badge, and
the QR images only the README referenced.
2026-11-11 22:38:44 -08:00
yhh 72070dde3e docs(readme): announce end of maintenance and remove donation info
Add the 2026-11-12 end-of-maintenance notice to both READMEs, linking the
pinned announcement, and drop the Support section with its PayPal link and
WeChat/Alipay QR codes.
2026-11-11 22:36:03 -08:00
yhh 76fdbabd6e docs(readme): remove the Right Code and PackyCode sponsor sections
Both listings have run their month. The sponsor logos go with them; the
Support section's payment codes stay.
2026-11-11 22:12:30 -08:00
yhh e3ef61b9aa fix(studio): declare the file tree resize handle's action (#15096)
The handle's three handlers counted as unnamed inputs and pushed the
actions census over its ceiling.
2026-11-11 20:15:09 -08:00
yhh 0a17f77749 fix(studio): choosing the explorer no longer leaves the start menu beside it
With the tree open and nothing selected, the canvas still drew the start
menu, whose first entry was the explorer already shown. It now asks for a
file instead, and the menu returns when the tree is hidden.
2026-11-11 20:15:08 -08:00
yhh 803583e4a2 fix(studio): the commit history re-reads when the repository moves
It read once when opened and kept showing that tip. It now re-reads quietly,
keeping the rows on screen, when the agent has written, the change count
moves or the branch changes. A commit made without any of those still waits
for the section to be reopened.
2026-11-11 20:15:08 -08:00
yhh a717dcd344 fix(studio): the changes head wraps its buttons in a narrow explorer
The head held the fold key, title, file count and three buttons on one
34px row. In a 190-260px column the count shrank to a character and broke
vertically, and the buttons overflowed into a horizontal scrollbar. The
row now wraps, and the title and count stay whole.
2026-11-11 20:15:08 -08:00
yhh 3763768436 feat(studio): the workbench file tree can be widened (#15096)
The tree's column was a fixed clamp, so long names stayed cut off even
with the workbench maximised. A separator on its edge now resizes it by
drag or keys, remembers the width in this browser, and resets on double
click or Home.
2026-11-11 05:20:12 -08:00
yhh 7c15d9c619 fix(studio): a read that cannot reach the kernel says so and names the read (#15070)
Writes already ended an unreachable kernel as a typed DeliveryError;
reads let the browser's bare "Failed to fetch" through, so neither the
window nor a feedback report said which read failed or why. Reads now
raise the same unreachable fault, carrying the path.
2026-11-11 05:20:11 -08:00
yhh 1ea3f097f4 fix(release): the Homebrew cask's postflight_steps use the install-steps DSL (#15071)
postflight_steps is evaluated by Homebrew::InstallSteps::DSL, which has
neither staged_path nor system_command, so Homebrew 7 reported the cask
unreadable. The step now uses run with a {{staged_path}} template, and a
test renders the cask and rejects the imperative style.
2026-11-11 04:27:41 -08:00
yhh f4b815ecda fix(studio): a refused action's reason is no longer painted over by the composer
The window's error was rendered in .cmpalert, a corner layer fixed to the bottom
right at z-index 6, while the composer's tool row sits at z-index 8: the model
picker and the send button covered it. It now joins the stack above the composer
that the pane's own errors already use; passing notes stay in the corner. The
window's notices move to useWindowNotices so App.tsx stays under its ceiling.
2026-11-10 23:28:05 -08:00
yhh 7f4c9dc589 fix(studio): pnpm install of the frontend completes on Windows (#15067)
@a2ui/web_core uses redos-detector at run time to refuse regular expressions that
backtrack catastrophically, so the dependency has to stay. Every 6.1.x tarball
ships dist/__mocks__/package-json:version.d.ts, a name Windows cannot create, and
the install stopped on it. pnpm.overrides now points at the upstream 6.1.4 tarball
with that one entry removed (the other 44 are byte-identical; upstream integrity
and the removal are recorded in vendor/README.md), and a test fails if a vendored
package holds a name Windows cannot write.
2026-11-10 23:15:52 -08:00
yhh 8aa4ea7a87 feat(peers): open_projects can add folders inside the session's workspace without the card, if the user turns it on (#15090)
The open_projects card is a fresh per-request decision because trusting a folder
lets its own configuration run programs. peers.open_nested, off by default,
skips it only when every folder is strictly inside the asking session's own
workspace after links are resolved, and a notice lists the paths. A folder
outside, a missing one, a link that leaves the workspace, plan mode, a folder the
user declined, and the host's own vetting all behave as before.

The setting is user file only: a project's reasonix.toml cannot turn it on, the
setting tool cannot write it (Guarded), and a networked client without the
provider-edit grant is refused. Settings gets a switch, held inert until the
model may hand off tasks.
2026-11-10 23:08:06 -08:00
yhh a0af95ffab fix(cli): a Cygwin or MSYS terminal is told why the UI cannot run there (#15091)
Without a pseudoconsole mintty gives a program named pipes, so GetConsoleMode
fails and a bare reasonix printed the usage text while reasonix tui said it
needed an interactive terminal. Either end being such a pipe (named
msys-/cygwin-<id>-ptyN-from|to-master, with -nat on MSYS2) now prints a hint to
use cmd, PowerShell or Windows Terminal instead. Verified inside mintty with
MSYS=disable_pcon.
2026-11-10 22:22:15 -08:00
yhh 2cc5ad25f4 fix(terminal): a Windows without a pseudoconsole is told so, not that the shell failed to start (#15091)
conpty reports ErrConPtyUnsupported on a Windows before 1809; it was folded into
terminal.start_failed, and the pane showed only that the terminal could not
start. It now carries terminal.unsupported, refused with 501, and Studio says the
built-in terminal needs Windows 10 1809 or later. The terminal still does not open
on such a system.
2026-11-10 22:22:15 -08:00
yhh 07e6a61344 fix(studio): a display formula no longer loses the top of a raised exponent (#15092)
The block scrolls sideways when wide, which hides its vertical overflow too,
and it had padding only below. KaTeX draws exponents, tall delimiters and
operator limits past the line box, so the top of them was cut at the clip edge.
Padding is now .6em above and below with the margin reduced to .2em, so the gap
between blocks is unchanged. A test holds the padding symmetric.
2026-11-10 22:07:01 -08:00
yhh 3807e411d7 feat(studio): a process list across owners, read when opened
Controller.Processes lists the session's running jobs and the sub-agent runs
this process is executing. GET /processes serves it with a coded refusal when
the read fails; Studio adds a Processes chip to the run rail that reads on open
and re-reads only when its jobs or tasks change, never on a timer.

SubagentStore.LiveRuns reads the in-memory registry instead of scanning the
store directory (0.2 ms against 23 ms with 300 finished runs). A sub-agent
record now names the turn that spawned it (ParentTurn, the in-flight marker id
already held in memory), and the port methods for sub-agents and processes
move to their own RunningWorkPort so port.ts stays under its ceiling.
2026-11-10 22:01:19 -08:00
yhh 21f37476dc feat(process): one vocabulary for running work, and each owner's states mapped onto it
internal/contract/process is a closed set of states and typed causes. Each
owner (jobs, taskmonitor, delegation, schedule, sessioninbox, agentgraph) keeps
its own states and transitions and projects them with a pure function; a test
per owner reads the declared constants from source and fails when one has no
mapping. Nothing runs these on a hot path.
2026-11-10 22:01:18 -08:00
yhh 5cd38fde8f perf(sessionstore): archiving a session no longer canonicalizes every neighbour's path
recoveryArchiveLineage resolved each listed session through the filesystem to
look it up in an exclusion set that is empty on the common call, so one
archive cost time proportional to the directory: 342 ms at 400 sessions, and
quadratic over a batch. The cheap structural checks now run first and the
path is resolved only when there is something to exclude. 24 ms at 400.

Adds BenchmarkSetSessionLineageArchived.
2026-11-10 20:31:00 -08:00
esengine 77bd4e242f fix(studio): a skill warning is shown once per app run, not once per window
Opening a history session or a new window builds a pane with its own skill
store, and each store reported the same missing description again, so a skill
the person had already been told about came back as a warning in every window.
The warning is a fact about a file on disk. The hub now keeps what it has shown
and a repeated text reaches no further window; a changed text is shown again.

Refs #15055
2026-11-11 01:18:48 +00:00
esengine dc48bdc0ac test(sessionstore): the digest allocation count is not measured under the race detector
The race detector turns off sync.Pool reuse, so 2.6 allocations per message
were counted against a 0.5 limit that holds without it. The package's own
raceDetector constant skips it there, as the save baseline already does.
2026-11-11 00:39:54 +00:00
esengine 0431f53066 test(peers): the unread fixture pins the dispatch switch off so a message stays held 2026-11-11 00:17:44 +00:00
esengine fe02e68959 release-notes: studio 2.60.0 2026-11-10 23:55:12 +00:00
esengine 3dee12cd27 fix(ci): declare the picker's actions and move the peer tests that held a message to the switch-off case
The census requires every rendered data-action to be listed, and the serve
tests for messages between sessions still expected a stranger's message to wait
for a card while the dispatch switch is on.
2026-11-10 23:54:09 +00:00
esengine 4dc2b1938b feat(peers): with the dispatch switch on, a message from the user's other sessions reaches the model without a card
The dispatch switch already let a dispatched pair in unasked; any other session
of the user's still waited for an approval, so a model that sent a plain peer
message stalled until the person clicked. While the switch is on every message
from the user's sessions is released, audited as consent "switch" and framed
to the model as data the user did not read; a dispatch partner keeps its own
framing. With the switch off nothing changes. The tool descriptions, receipt,
slash notice and UI copy no longer promise an approval, and the tool-schema
golden follows.
2026-11-10 17:22:13 +00:00
esengine 6c9c6851af feat(studio): pick an element in the built-in browser and save the edit to the local page
The picker's shell half had no control. The browser toolbar now arms it, and a
picked element on a page opened from a workspace file can have its text, color,
background and size changed. The edit is patched into the file's source (the
element is re-found there, not trusted from the page) and written back through
saveWorkspaceFile with its revision check; anything that cannot be placed
reliably is refused with a typed reason and nothing is written.
2026-11-10 17:19:11 +00:00
yhh 7facc49510 test: every test directory is testenv.TempDir, and repolint refuses a bare one
On Windows a removed file's directory entry outlives it until the last handle
closes, so t.TempDir()'s cleanup fails the test as "directory is not empty"
with every file already gone. testenv.TempDir retries, but nothing required it,
and 1145 bare calls in 391 test files had come back; internal/state/sessionstore
failed a different test on most full runs.

Four of its tests ran 150 times each: 41 failures in 600 with t.TempDir(), 0 in
600 with testenv.TempDir(t), and the whole package then passed 6 of 6. A full
go test ./... after the sweep has no cleanup failure; its three failing packages
(jq missing, find.exe on PATH, WSL launcher detection) fail the same at HEAD.

The new temp-dir rule reports a TempDir() call in a test that is not os-qualified.
testenv's own test is exempt, since it cannot import itself.
2026-11-10 02:12:52 -08:00
yhh e19fc12a59 perf(sessionstore): a save digests the transcript and writes its index without per-message copies
Every save re-digests the whole transcript to check it still begins with what
is on disk, and rewrites the display index. The digest marshalled each message
into a fresh slice and the index was written indented, so a save allocated in
proportion to the session. The digest now encodes each message straight into
the hash through one encoder, byte-identical to before, and the index is
written compact.

Median of four runs of BenchmarkIncrementalSave: 1000 messages 33.7 to 24.6 ms
and 3.64 to 1.53 MB per save; 5000 messages 49.3 to 42.2 ms and 18.9 to 8.7 MB;
20000 messages 136.8 to 109.7 ms and 78.4 to 37.7 MB. The digest test pins the
bytes against the format's own definition, including characters JSON escapes.
2026-11-10 01:28:36 -08:00
yhh 2ff95dbc5e feat(studio): the workbench shows the file tree beside the open file
The tree is the left column and the file the right, in the dock as well as at
full width. Choosing a file leaves the tree open, so the next one is a click
away instead of a trip back to the Files tab; any other surface still takes the
whole column and folds the tree. The explorer title no longer wraps one
character per line in the narrow column.

Docked, the list used to hide the canvas, so the content views counted
themselves hidden while it was open and a closed last file brought the list
back. Neither holds side by side, so both rules are removed and the tests that
asserted them are rewritten to the new layout.
2026-11-10 00:59:01 -08:00
yhh f4a646549c fix(tools): the bash description steers searching to tool:grep and tool:glob
grep and glob are deferred tools reached through use_capability, so the model
had no direct name to prefer and searched through the shell: 33 of 37 searches
in a three-task sample. The description now names tool:grep and tool:glob and
leaves the shell its pipelines. Over the same three tasks the built-in share of
searches went from 4 of 37 to 16 of 31.

The tool schema grows by 62 tokens and the prefix changes once, so the golden
baseline and docs/TOOL_CONTRACT.md are regenerated with it.
2026-11-10 00:58:54 -08:00
yhh 302b6732a5 feat(tools): turnprofile splits a trajectory's turns into exclusive time buckets
Reads --trajectory JSONL and assigns every millisecond of a turn to one of
user wait, compaction, tool, model first token, model generation, discarded
attempt, or host, so where a turn spends its wall-clock is read off a recorded
run. Read-only; definitions and limits are in the README.
2026-11-09 23:40:13 -08:00
623 changed files with 6152 additions and 1508 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
<defs>
<style>
.cls-1 {
fill: #ffffff;
stroke: #ffffff;
stroke-miterlimit: 10;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
<defs>
<style>
.cls-1 {
fill: #111111;
stroke: #111111;
stroke-miterlimit: 10;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

+3 -34
View File
@@ -2,6 +2,9 @@
<img src="docs/logo-ghost-wave-effect.svg" alt="Reasonix" width="360"/>
</p>
> [!WARNING]
> **Reasonix is no longer maintained as of 2026-11-12.** The final release is v2.61.0; there will be no further releases, fixes or issue triage. The project is MIT-licensed and forks are welcome. See the [announcement](https://github.com/esengine/DeepSeek-Reasonix/issues/15109).
<p align="center">
<strong>English</strong>
&nbsp;·&nbsp;
@@ -16,10 +19,6 @@
<a href="./docs/SPEC.md">Spec</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -31,9 +30,6 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -48,19 +44,8 @@
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
>
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
<br/>
## ❤️Sponsor
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
## What it is
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
@@ -312,19 +297,3 @@ Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for design
<br/>
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
</p>
---
<p align="center"><sub><strong>Support this project</strong></sub></p>
If Reasonix has been useful and you'd like to say thanks, you can. It stays a
coffee, not a contract — donations don't buy feature priority or change how
issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付 / 支付宝(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
</p>
+3 -33
View File
@@ -2,6 +2,9 @@
<img src="docs/logo-ghost-wave-effect.svg" alt="Reasonix" width="360"/>
</p>
> [!WARNING]
> **Reasonix 已于 2026-11-12 起停止维护。** 最后一个版本为 v2.61.0,此后不再发布新版本、修复缺陷或处理 Issue。项目采用 MIT 协议,欢迎 fork。详见[公告](https://github.com/esengine/DeepSeek-Reasonix/issues/15109)。
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
@@ -16,10 +19,6 @@
<a href="./docs/SPEC.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -31,9 +30,6 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -48,17 +44,6 @@
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
>
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
## ❤️赞助商
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
## 它是什么
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
@@ -293,18 +278,3 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
<br/>
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
</p>
---
<p align="center"><sub><strong>支持本项目</strong></sub></p>
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付 / 支付宝(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
</p>
+13 -12
View File
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/safety/sandbox"
)
@@ -57,11 +58,11 @@ func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
if err := validateAnswerIsolation(tasks); err != nil {
t.Fatal(err)
}
work := t.TempDir()
work := testenv.TempDir(t)
if err := stageAnswerIsolation(tasks[0].answerRoot, work); err != nil {
t.Fatal(err)
}
home := t.TempDir()
home := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nbash = \"off\"\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -105,7 +106,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
if err := stageAnswerIsolation(answerRoot, filepath.Dir(answerRoot)); err == nil {
t.Fatal("workdir inside the checkout was accepted")
}
work := t.TempDir()
work := testenv.TempDir(t)
path := filepath.Join(work, "reasonix.toml")
if err := os.WriteFile(path, []byte("# task fixture\n"), 0o600); err != nil {
t.Fatal(err)
@@ -120,7 +121,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
requireShellStub(t)
suite := t.TempDir()
suite := testenv.TempDir(t)
taskDir := filepath.Join(suite, "tasks", "demo")
if err := os.MkdirAll(filepath.Join(taskDir, "memory", "project"), 0o755); err != nil {
t.Fatal(err)
@@ -129,7 +130,7 @@ func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "fake-agent")
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
set -e
test -f reasonix.toml
@@ -154,7 +155,7 @@ done
func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
requireShellStub(t)
suite := t.TempDir()
suite := testenv.TempDir(t)
taskDir := filepath.Join(suite, "tasks", "demo")
answer := filepath.Join(taskDir, "memory", "project", "fact.md")
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
@@ -166,7 +167,7 @@ func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "fake-agent")
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
set -e
printf 'ok\n' > answer.txt
@@ -194,7 +195,7 @@ done
func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
requireShellStub(t)
root := filepath.Join(t.TempDir(), "tasks")
root := filepath.Join(testenv.TempDir(t), "tasks")
var tasks []task
for _, id := range []string{"missing", "complete"} {
dir := filepath.Join(root, id)
@@ -206,7 +207,7 @@ func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
}
tasks = append(tasks, task{ID: id, Prompt: id, TimeoutSec: 30, dir: dir, answerRoot: root})
}
bin := filepath.Join(t.TempDir(), "fake-agent")
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
set -e
printf 'ok\n' > answer.txt
@@ -251,7 +252,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
{"glob lists name only", `{"event":{"kind":"tool_result","tool":{"name":"glob","args":"{\"pattern\":\"/repo/tasks/demo/memory/project/*.md\"}","output":"/repo/tasks/demo/memory/project/fact.md"}}}`, false},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "run.trajectory.jsonl")
path := filepath.Join(testenv.TempDir(t), "run.trajectory.jsonl")
if err := os.WriteFile(path, []byte(tc.data+"\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -264,7 +265,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
}
func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
root := filepath.Join(t.TempDir(), "tasks")
root := filepath.Join(testenv.TempDir(t), "tasks")
answer := filepath.Join(root, "demo", "memory", "project", "fact.md")
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
t.Fatal(err)
@@ -272,7 +273,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA fact body in the first trajectory leg.\n"), 0o600); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
dir := testenv.TempDir(t)
for index, data := range []string{
`{"event":{"kind":"tool_result","tool":{"name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}","output":"A fact body in the first trajectory leg."}}}`,
`{"event":{"kind":"text","text":"done"}}`,
+6 -4
View File
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
)
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
@@ -64,7 +66,7 @@ func timeAt(s string) time.Time {
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
t.Helper()
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
t.Fatalf("write batch: %v", err)
}
@@ -174,7 +176,7 @@ func TestBatchAuditRoundTrip(t *testing.T) {
// The axes carry what changes a figure's meaning, never the operator's machine:
// meter/tape/trajectory paths and steer text stay out of the manifest.
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
prov := fixtureProvenance(dir)
prov.axes = batchAxes(suiteConfig{
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
@@ -260,7 +262,7 @@ func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
// manifest, no commit to tie the figures to, and rows cut short.
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
t.Run("missing manifest", func(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
writeRows(t, dir, fixtureRows())
out, err := auditBatch(dir)
if err == nil {
@@ -387,7 +389,7 @@ const manifestWithoutOptionals = `{
`
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
writeRows(t, dir, fixtureRows())
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
if err != nil {
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/base/fileutil/encoding"
"reasonix/internal/base/testenv"
)
// benchmarksRoot holds every committed task corpus. Validation discovers the
@@ -363,7 +364,7 @@ func fullDecisions() map[authoringRule]ruleDecision {
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
t.Helper()
root := t.TempDir()
root := testenv.TempDir(t)
var policies []corpusPolicy
for _, name := range corpora {
dir := tempCorpus(t, root, name, "t-"+name, "")
@@ -437,7 +438,7 @@ func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
name: "a registry entry that shadows the root's corpus by name",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
elsewhere := tempCorpus(t, testenv.TempDir(t), "registered", "t-impostor", "")
policies[0].Dir = elsewhere
return root, policies
},
@@ -505,7 +506,7 @@ func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
}
}
root := t.TempDir()
root := testenv.TempDir(t)
for _, c := range []struct {
name string
corpus string
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestWilsonAndNewcombeMatchKnownValues(t *testing.T) {
@@ -42,7 +44,7 @@ func writeReport(t *testing.T, dir, name, capability string, pass int, n int) st
}
func TestReproductionSection(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
got := reproductionSection(a, same)
+7 -5
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
@@ -38,7 +40,7 @@ func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
// The real filesystem is the other witness: writing over a directory fails.
func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
if err := restoreSource(testenv.TempDir(t), []byte("original")); err == nil {
t.Fatal("restoring onto a directory reported success")
}
}
@@ -47,7 +49,7 @@ func TestTreeMatchesSeesADirtyTree(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
repo := t.TempDir()
repo := testenv.TempDir(t)
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
@@ -88,7 +90,7 @@ func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go unavailable")
}
repo := t.TempDir()
repo := testenv.TempDir(t)
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
@@ -150,7 +152,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
repo := t.TempDir()
repo := testenv.TempDir(t)
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
@@ -197,7 +199,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
// tree must not be left on base just because the failure happened early. The
// defer that restores is registered before the check for exactly this path.
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
repo := t.TempDir()
repo := testenv.TempDir(t)
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
+2 -2
View File
@@ -29,7 +29,7 @@ printf '%%s' "$metrics" > metrics-path.txt
printf '%%s' '%s' > "$metrics%s"
exit %d
`, body, suffix, code)
path := filepath.Join(t.TempDir(), name)
path := filepath.Join(testenv.TempDir(t), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
@@ -73,7 +73,7 @@ case "$metrics" in
esac
exit 0
`
path := filepath.Join(t.TempDir(), name)
path := filepath.Join(testenv.TempDir(t), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
+9 -7
View File
@@ -10,6 +10,8 @@ import (
"sync/atomic"
"testing"
"time"
"reasonix/internal/base/testenv"
)
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
@@ -44,7 +46,7 @@ func readAll(t *testing.T, resp *http.Response) string {
// A run replayed from its tape gets the recorded stream byte for byte, never
// reaches the provider, and is metered as the recorded run was.
func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
live := readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -71,7 +73,7 @@ func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
// A kernel change that alters what the model is asked shows as the first
// request whose body departs from the tape, named down to the message.
func TestTapeReplayNamesTheFirstRequestThatDiffers(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -123,7 +125,7 @@ func waitFor(t *testing.T, what string, cond func() bool) {
// A recording whose request file cannot be written is reported rather than
// treated as a tape: request.json is what marks the exchange complete.
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "request.json") {
@@ -161,7 +163,7 @@ func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
// A tape with no recorded request is incomplete, not a divergence: there is
// nothing to compare against, and blaming the harness would be wrong.
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -193,7 +195,7 @@ func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
// first: stopping is what waits for the handlers whose deferred writes carry the
// tape counters. Snapshotting before that misses the last exchange.
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
@@ -214,7 +216,7 @@ func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
// Re-recording over an existing tape must not let the previous run's
// completeness marker make a partial new exchange look complete.
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -263,7 +265,7 @@ func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
// incomplete exchange and prints no line about one. Only the failure paths were
// pinned before.
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
+6 -5
View File
@@ -7,11 +7,12 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
)
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
cases := []struct {
@@ -33,7 +34,7 @@ func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
}
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
mk := func(name string) string {
@@ -68,7 +69,7 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
}
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
home := filepath.Join(base, "state")
app := filepath.Join(base, "app")
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
@@ -96,7 +97,7 @@ func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
}
func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
state := filepath.Join(base, "state")
home := filepath.Join(base, "home")
app := filepath.Join(base, "app")
@@ -127,7 +128,7 @@ func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *test
}
func TestPickLaunchWorkspaceCreatesTheAutoWorkspaceLast(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
app := filepath.Join(base, "app")
exe := filepath.Join(app, "Reasonix Studio")
auto := filepath.Join(base, "state", "workspaces", "auto")
+3 -3
View File
@@ -112,7 +112,7 @@ func (r recordingRunner) Run(_ context.Context, input string) error {
// hub, its config path and its capabilities are the ones the host builds.
func testHub(t *testing.T, cfg config.ServeConfig) (*serve.Hub, chan string) {
t.Helper()
root := t.TempDir()
root := testenv.TempDir(t)
got := make(chan string, 1)
bc := serve.NewBroadcaster()
ctrl := control.New(control.Options{
@@ -382,7 +382,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
t.Error("the null device was read as a parent holding this host open")
}
path := filepath.Join(t.TempDir(), "not-a-pipe")
path := filepath.Join(testenv.TempDir(t), "not-a-pipe")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
@@ -399,7 +399,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
// The whole run, driven the way Electron drives it: a pipe for the lease, one
// line of handshake, traffic, then the parent letting go.
func TestRunServesUntilTheParentLetsGo(t *testing.T) {
page := t.TempDir()
page := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(page, "index.html"), []byte("<title>studio</title>"), 0o600); err != nil {
t.Fatal(err)
}
@@ -7,11 +7,12 @@ import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/packagegrant"
)
func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
for name, app := range map[string]string{
"no application": "",
"a path that is nothing": filepath.Join(dir, "missing.exe"),
@@ -27,7 +28,7 @@ func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
// The shell reads this line as structure, so both lists are present even when
// empty: a null would be a third answer it has to guess about.
func TestStripPackageGrantsReportsOneLineOfJSON(t *testing.T) {
app := filepath.Join(t.TempDir(), "Reasonix Studio.exe")
app := filepath.Join(testenv.TempDir(t), "Reasonix Studio.exe")
if err := os.WriteFile(app, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
@@ -84,7 +86,7 @@ func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
{"pull_request", "desktop/electron/test/unit.mjs", true},
} {
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
// A shell function owns command lookup on every host, including Git
// Bash on Windows; a PATH shim can resolve the runner's real git.
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
+5 -4
View File
@@ -10,6 +10,7 @@ import (
"aead.dev/minisign"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/update"
)
@@ -28,7 +29,7 @@ func TestSignFiles(t *testing.T) {
t.Setenv("MINISIGN_PRIVATE_KEY", string(enc))
t.Setenv("MINISIGN_PASSWORD", "pw")
dir := t.TempDir()
dir := testenv.TempDir(t)
artifact := filepath.Join(dir, "Reasonix-linux-amd64.tar.gz")
payload := []byte("pretend this is a release tarball")
if err := os.WriteFile(artifact, payload, 0o644); err != nil {
@@ -51,7 +52,7 @@ func TestSignFiles(t *testing.T) {
// every platform is listed with a download URL, a parallel .minisig URL, and a
// non-empty digest. The .minisig and latest.json files must be ignored.
func TestGenManifest(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
names := []string{
"Reasonix-darwin-arm64.zip",
"Reasonix-darwin-amd64.zip",
@@ -158,7 +159,7 @@ func TestGenManifest(t *testing.T) {
}
func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(dir, "Reasonix-linux-amd64.tar.gz"), []byte("rc"), 0o644); err != nil {
t.Fatal(err)
}
@@ -181,7 +182,7 @@ func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
// TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known
// platform key is skipped rather than inventing a native_packages entry.
func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
for _, n := range []string{
"Reasonix-linux-amd64.tar.gz",
"Reasonix-mystery.deb",
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/update"
)
@@ -13,7 +14,7 @@ import (
// the shared chunk store named; an unsigned index fails the release instead of
// being offered.
func TestManifestListsEachSignedDeltaFromTheMirror(t *testing.T) {
dir, deltaDir := t.TempDir(), t.TempDir()
dir, deltaDir := testenv.TempDir(t), testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := os.WriteFile(filepath.Join(dir, "ReasonixStudio-windows-amd64-installer.exe"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
+9 -8
View File
@@ -9,11 +9,12 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/update"
)
func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
body := []byte("studio archive bytes")
for _, name := range []string{
@@ -71,7 +72,7 @@ func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
// on the Studio side does. Only what installs itself may resolve as a platform
// asset, or the version panel offers a move it cannot make.
func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-linux-amd64.tar.gz",
@@ -128,7 +129,7 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
func TestEmptyDirIsAFailedRelease(t *testing.T) {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
if err := run(testenv.TempDir(t), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
t.Fatal("a release with no artifacts must fail, not publish an empty manifest")
}
}
@@ -136,7 +137,7 @@ func TestEmptyDirIsAFailedRelease(t *testing.T) {
// A mirrored release is fetched from the mirror first, with GitHub kept as the
// address a client falls back to; every full artifact and its signature alike.
func TestMirroredManifestPutsTheMirrorFirst(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe",
@@ -193,7 +194,7 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
// Both Windows architectures ship an installer and a portable archive under
// the same naming rule; each installer answers only its own platform key.
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe",
@@ -258,7 +259,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
}
return m
}
debOnly, both := t.TempDir(), t.TempDir()
debOnly, both := testenv.TempDir(t), testenv.TempDir(t)
write(debOnly, "ReasonixStudio-linux-amd64.deb")
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
before, after := generate(debOnly), generate(both)
@@ -292,7 +293,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
// the second architecture must not move the first one's entries.
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
dir := t.TempDir()
dir := testenv.TempDir(t)
generate := func() update.Manifest {
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
t.Fatal(err)
@@ -345,7 +346,7 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
// replaces: it must not appear as a platform asset or a native package, and
// adding it must not move any entry a shipped client reads.
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
+5
View File
@@ -77,5 +77,10 @@
"typescript": "^7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
},
"pnpm": {
"overrides": {
"redos-detector": "file:./vendor/redos-detector-6.1.4.tgz"
}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ class BenchPort extends MockPort {
// ?peerset=1 starts with session messages on and two projects already allowed through.
if (query.has("peerset")) {
this.peerSettings = {
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn", pending: query.has("pending"), available: true,
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, applies: "next_turn", pending: query.has("pending"), available: true,
path: "/Users/you/.reasonix/config.toml",
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
};
@@ -27,17 +27,16 @@ try {
check(`${scheme}: the shortcut buttons share one size`, spread("w") <= TOLERANCE && spread("h") <= TOLERANCE, JSON.stringify(strip.map((s) => [s.w, s.h])));
check(`${scheme}: the shortcut rows align and center their icons`, spread("left") <= TOLERANCE && strip.every((s) => Math.abs(s.cy - s.iconCy) <= TOLERANCE), JSON.stringify(strip.map((s) => [s.left, s.cy - s.iconCy])));
check(`${scheme}: the shortcut icons share one size`, new Set(strip.map((s) => s.icon.join("x"))).size === 1, strip.map((s) => s.icon.join("x")).join(" "));
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-tools-row", ".studio-feedback"].map((s) => {
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-tools-row"].map((s) => {
const row = document.querySelector(s);
if (!row) return null;
const r = row.getBoundingClientRect();
const svg = row.querySelector("svg, .studio-icon").getBoundingClientRect();
return { left: r.left, right: r.right, icon: svg.left - r.left, visible: r.width > 0 && r.height > 0 };
}));
check(`${scheme}: new session, search and feedback are drawn`, rows.every((r) => r?.visible), JSON.stringify(rows));
check(`${scheme}: new session, search and tools are drawn`, rows.every((r) => r?.visible), JSON.stringify(rows));
check(`${scheme}: those rows share the sidebar's left and right edges`, rows.every((r) => r && Math.abs(r.left - rows[0].left) <= TOLERANCE && Math.abs(r.right - rows[0].right) <= TOLERANCE), JSON.stringify(rows.map((r) => [r.left, r.right])));
const fill = await page.evaluate(() => getComputedStyle(document.querySelector(".studio-feedback")).backgroundColor);
check(`${scheme}: the feedback row has no fill at rest`, /rgba\(0, 0, 0, 0\)|transparent/.test(fill), fill);
check(`${scheme}: the retired feedback row is not drawn`, !(await page.locator(".studio-feedback").count()));
await ctx.close();
}
} finally {
+1 -6
View File
@@ -21,12 +21,7 @@ try {
const root = page.locator(".studio-tools");
const settings = root.locator('[data-action="chrome.settings"]');
assert.equal(await settings.innerText(), "设置");
const alignment = await settings.evaluate((button) => {
const icon = button.querySelector('svg').getBoundingClientRect();
const feedback = document.querySelector('.studio-feedback svg').getBoundingClientRect();
return Math.abs(icon.x - feedback.x);
});
assert.ok(alignment <= 4, `settings and feedback icons align: ${alignment}`);
assert.equal(await page.locator('.studio-feedback').count(), 0, 'the retired feedback row is not drawn');
if (hidden) {
assert.equal(await root.locator('.studio-tools-toggle').count(), 0);
await root.hover();
+8 -4
View File
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
redos-detector: file:./vendor/redos-detector-6.1.4.tgz
importers:
.:
@@ -2475,8 +2478,9 @@ packages:
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==}
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
resolution: {integrity: sha512-0a7pybN7BYeVkQnDiL0GuELrvrvRYd4cDqV0T7G0+UsRAdg7SfMy60Vf0V4SPeftu6hD60FiDi5VANJsP9+ISg==, tarball: file:vendor/redos-detector-6.1.4.tgz}
version: 6.1.4
engines: {node: '>=16.0.0'}
hasBin: true
@@ -3049,7 +3053,7 @@ snapshots:
'@lit/context': 1.1.6
'@preact/signals-core': 1.14.4
lit: 3.3.3
redos-detector: 6.1.4
redos-detector: file:vendor/redos-detector-6.1.4.tgz
validate-color: 2.2.4
zod: 3.25.76
zod-to-json-schema: 3.25.2([email protected])
@@ -5599,7 +5603,7 @@ snapshots:
- '@types/react'
- redux
[email protected]:
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
dependencies:
regjsparser: 0.13.0
+6
View File
@@ -96,6 +96,7 @@ export const ACTIONS: UIAction[] = [
{ id: "phone-device.retry", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "phone-device.restart", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
{ id: "workbench.tree-resize", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.open-project", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
@@ -416,6 +417,9 @@ export const ACTIONS: UIAction[] = [
{ id: "browser.tab", kind: "view", target: "entity", proof: "interaction" },
{ id: "browser.control", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "browser.navigate", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "browser.pick", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "browser.edit-save", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "browser.edit-close", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.find", kind: "view", target: "none", proof: "interaction" },
{ id: "turn.edit", kind: "view", target: "entity", proof: "interaction" },
{ id: "turn.resend", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
@@ -679,6 +683,7 @@ export const ACTIONS: UIAction[] = [
{ id: "reply.run-detail", kind: "view", target: "none", proof: "interaction" },
{ id: "deck.agents", kind: "view", target: "none", proof: "interaction" },
{ id: "deck.jobs", kind: "view", target: "none", proof: "interaction" },
{ id: "deck.processes", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.jump", kind: "navigation", target: "none", proof: "interaction" },
@@ -741,6 +746,7 @@ export const ACTIONS: UIAction[] = [
{ id: "peers.approve-project", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.enable", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.dispatch", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.open-nested", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.release-revoke", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "peers.release-add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.release-pick", kind: "view", target: "none", proof: "interaction" },
@@ -162,6 +162,8 @@
}
.workbench-explorer .workbench-explorer-head small, .workbench-history > summary small { margin-left: auto; color: var(--faint); font: var(--w-med) var(--fs-meta)/1 var(--ui); }
.workbench-explorer .workbench-explorer-head small + .btn, .workbench-explorer .workbench-explorer-head small ~ .btn { flex: none; }
.workbench-explorer .workbench-changes > .workbench-explorer-head { height: auto; min-height: 34px; padding-block: 5px; flex-wrap: wrap; row-gap: 4px; }
.workbench-explorer .workbench-changes > .workbench-explorer-head > :is(span, small, .workbench-section-title) { flex: none; white-space: nowrap; }
.workbench-section-fold { width: 18px; height: 18px; padding: 0; display: grid; place-items: center; flex: none; border: 0; background: none; color: var(--faint); cursor: pointer; }
.workbench-section-fold .studio-icon, .gh-fold { width: 12px; height: 12px; flex: none; color: var(--faint); transition: transform var(--t-line) ease; }
.workbench-history:not([open]) > summary .gh-fold { transform: rotate(-90deg); }
@@ -0,0 +1,4 @@
/** The project is no longer maintained, so nothing in the window offers to send
* a report or suggestion. The kernel answers `feedback.unavailable` on its own;
* this keeps the entry points from being drawn at all. */
export const FEEDBACK_RETIRED = true;
+2
View File
@@ -120,6 +120,7 @@ const SAID: Record<string, string> = {
"terminal.not_found": "kernel.that_terminal_is_not_open",
"terminal.shell_missing": "kernel.that_shell_is_not_on",
"terminal.start_failed": "kernel.the_shell_could_not_start",
"terminal.unsupported": "kernel.this_windows_has_no_built_in_terminal",
"terminal.exited": "kernel.the_shell_has_ended",
"terminal.limit": "kernel.too_many_terminals_are_open",
"workspace.file_missing": "kernel.the_file_was_not_found",
@@ -518,6 +519,7 @@ const SAID: Record<string, string> = {
"subagent.not_found": "kernel.no_sub_agent_run_was",
"subagent.not_owned": "kernel.that_sub_agent_belongs_to",
"subagent.read_failed": "kernel.could_not_read_the_sub",
"process.read_failed": "kernel.could_not_read_the_processes",
"subagent.not_running": "kernel.that_sub_agent_is_no",
"subagent.steer_empty": "kernel.guidance_cannot_be_empty",
"subagent.steer_too_large": "kernel.that_guidance_is_longer_than",
+1 -1
View File
@@ -396,7 +396,7 @@ export const CHAT: Messages = {
"chat.selected_text": { zh: "选中内容", en: "Selected text" },
"chat.selects_enter_references_tab_completes": { zh: "↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭", en: "↑↓ selects · Enter references · Tab completes or enters a folder · Esc closes" },
"chat.selects_tab_or_enter_completes": { zh: "↑↓ 选择 · Tab 或回车补全 · Esc 关闭", en: "↑↓ selects · Tab or Enter completes · Esc closes" },
"chat.send_text_to_another_reasonix": { zh: "向本机另一个 Reasonix 会话发送文字;对方用户批准后它的模型才会读到,发送本身不授予任何权限。", en: "Send text to another Reasonix session on this machine; its model reads it only after that session's user approves, and sending grants no permission." },
"chat.send_text_to_another_reasonix": { zh: "向本机另一个 Reasonix 会话发送文字;派发开关开着时对方的模型会直接读到,关着时要对方用户批准,发送本身不授予任何权限。", en: "Send text to another Reasonix session on this machine; with the dispatch switch on its model reads it directly, otherwise its user must approve first, and sending grants no permission." },
"chat.send_the_edit": { zh: "改完重发", en: "Send the edit" },
"chat.sending": { zh: "正在重发…", en: "Sending…" },
"chat.sent_to_background": { zh: "放到后台运行", en: "Sent to the background" },
@@ -111,6 +111,7 @@ export const KERNEL: Messages = {
"kernel.could_not_reach_host_detail": { zh: "连接 {host} 失败:{detail}", en: "Could not reach {host}: {detail}" },
"kernel.could_not_reach_the_account": { zh: "连不上账号服务,请检查网络后重试", en: "Could not reach the account service; check the network and try again" },
"kernel.could_not_read_the_file": { zh: "文件预览读取失败,请重试", en: "Could not read the file preview. Try again." },
"kernel.could_not_read_the_processes": { zh: "读取进程列表失败:{detail}", en: "Could not read the process list: {detail}" },
"kernel.could_not_read_the_sub": { zh: "读取子代理记录失败:{detail}", en: "Could not read the sub-agent record: {detail}" },
"kernel.could_not_read_this_commit": { zh: "无法读取这个提交", en: "Could not read this commit" },
"kernel.could_not_read_this_file": { zh: "无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。", en: "Could not read this file: {detail}. It may be open in another program, moved, or not yet downloaded from cloud storage." },
@@ -596,6 +597,7 @@ export const KERNEL: Messages = {
"kernel.the_session_workspace_or_runtime": { zh: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发", en: "The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again" },
"kernel.the_settings_were_written_but": { zh: "设置已写入,但运行时未能按新设置重建:{detail}", en: "The settings were written, but the runtime could not be rebuilt on them: {detail}" },
"kernel.the_sharing_listener_is_not": { zh: "共享入口配置不完整,无法接受连接。", en: "The sharing listener is not fully configured and cannot accept connections." },
"kernel.this_windows_has_no_built_in_terminal": { zh: "这个 Windows 版本不支持内置终端(需要 Windows 10 1809 或更高版本)", en: "The built-in terminal needs Windows 10 version 1809 or later" },
"kernel.the_shell_could_not_start": { zh: "终端没能启动", en: "The terminal could not start" },
"kernel.the_shell_has_ended": { zh: "终端里的程序已经结束,重新启动后再输入", en: "The shell has ended; restart it before typing" },
"kernel.the_shell_settings_were_not": { zh: "shell 设置未能保存:{detail}", en: "The shell settings were not saved: {detail}" },
+4 -2
View File
@@ -40,7 +40,7 @@ export const REMOTE: Messages = {
"remote.could_not_read_the_state": { zh: "无法读取会话间消息的状态。", en: "Could not read the state of messages between sessions." },
"remote.creating_qr_code": { zh: "正在生成二维码…", en: "Creating QR code…" },
"remote.default_connection_directory_path": { zh: "默认连接目录:{path}", en: "Default connection directory: {path}" },
"remote.delivered_to_to_it_is": { zh: "已送到「{to}」,对方的用户批准后才会读到。", en: "Delivered to “{to}”; it is read only after that session’s user approves it." },
"remote.delivered_to_to_it_is": { zh: "已送到「{to}」,对方的模型会直接读到(派发开关关着时需对方用户批准)。", en: "Delivered to “{to}”; its model reads it directly (it waits for its user only if the dispatch switch is off)." },
"remote.device_access": { zh: "设备访问", en: "Device access" },
"remote.device_access_n_online": { zh: "设备访问 · {n} 台在线", en: "Device access · {n} online" },
"remote.device_n": { zh: "设备 {n}", en: "Device {n}" },
@@ -79,6 +79,8 @@ export const REMOTE: Messages = {
"remote.last_used_at": { zh: "最近使用 {at}", en: "Last used {at}" },
"remote.leave_empty_to_let_the": { zh: "留空则每次开启时由系统随机选择,范围 1024–65535。更改端口后,手机需要重新扫码配对。", en: "Leave empty to let the system pick a port each time, or choose one from 1024 to 65535. After changing the port, phones need to scan a new code to pair again." },
"remote.left_blank_it_tries_in": { zh: "留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。", en: "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively." },
"remote.open_nested_folders_without_asking": { zh: "不询问直接打开本项目内的文件夹", en: "Open folders inside this project without asking" },
"remote.open_nested_folders_without_asking_ds": { zh: "模型用 open_projects 把本项目目录下的子文件夹加为项目时,直接添加并标为可信,不再弹确认卡;项目之外的文件夹仍然会询问。被信任的文件夹里的配置可以运行程序,请只在你信任这个仓库时开启。", en: "When the model adds a subfolder of this project as a project with open_projects, it is added and trusted without a card; folders outside the project still ask. A trusted folder's configuration can run programs, so turn this on only for a repository you trust." },
"remote.let_the_model_hand_a": { zh: "允许模型把任务交给别的会话", en: "Let the model hand a task to another session" },
"remote.local_pairing_valid_until_time": { zh: "局域网配对 · {time} 前有效", en: "Local pairing · valid until {time}" },
"remote.lost_the_connection_to_machine": { zh: "与 {machine} 的连接已中断", en: "Lost the connection to {machine}" },
@@ -234,7 +236,7 @@ export const REMOTE: Messages = {
"remote.when_something_on_your_computer": { zh: "电脑有事等你时,这台手机会收到一条通知。通知里只有“哪一类”和数量,不含会话名称、命令或对话内容。默认关闭,只提醒你勾选的几类。", en: "When something on your computer is waiting for you, this phone gets a notification. It carries only the kind of occasion and a count, never a session name, command or conversation text. Off by default, and only the occasions you tick are sent." },
"remote.which_machines_providers_and_keys": { zh: "远端会话使用哪台机器上配置的 Provider 与 Key", en: "Which machine's providers and keys a remote session uses" },
"remote.will_not_run": { zh: "无法运行", en: "will not run" },
"remote.write_what_to_tell_the": { zh: "写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。", en: "Write what to tell the other session. It reaches that session’s model only after its user approves." },
"remote.write_what_to_tell_the": { zh: "写下要告诉另一个会话的话。派发开关开着时会直接交给那边的模型,关着时要对方的用户批准。", en: "Write what to tell the other session. It goes straight to that session’s model while the dispatch switch is on; otherwise its user approves first." },
"remote.wsl_distribution": { zh: "WSL 发行版", en: "WSL distribution" },
"remote.youre_offline": { zh: "网络已断开", en: "You're offline" },
"remote.use_studio_on_your_phone": { zh: "在手机上用 Studio", en: "Use Studio on your phone" },
@@ -10,6 +10,26 @@ export const SUBAGENT: Messages = {
"subagent.deck.hint": { zh: "点一行在工作台打开", en: "Select a row to open it in the workbench" },
"subagent.deck.open_full": { zh: "查看完整记录:{name}", en: "Open full transcript: {name}" },
"subagent.deck.title": { zh: "本会话的子代理", en: "This session's subagents" },
"subagent.process.cause.adopted": { zh: "沿用已有结果", en: "Reused an earlier result" },
"subagent.process.cause.budget": { zh: "预算用尽", en: "Out of budget" },
"subagent.process.cause.claim": { zh: "等被占用的写入路径", en: "Waiting for a write path someone holds" },
"subagent.process.cause.held": { zh: "被搁置,尚未开始", en: "Held back, not started" },
"subagent.process.cause.parked": { zh: "停在无人回答的提问上", en: "Stopped on a question nobody could answer" },
"subagent.process.cause.slots": { zh: "等并发名额", en: "Waiting for a free slot" },
"subagent.process.cause.writers": { zh: "等写入名额", en: "Waiting for a writer slot" },
"subagent.process.chip": { zh: "进程", en: "Processes" },
"subagent.process.empty": { zh: "现在没有在运行的东西", en: "Nothing is running" },
"subagent.process.kind.job": { zh: "后台作业", en: "Background job" },
"subagent.process.kind.subagent": { zh: "子代理", en: "Sub-agent" },
"subagent.process.state.completed": { zh: "已完成", en: "Completed" },
"subagent.process.state.failed": { zh: "失败", en: "Failed" },
"subagent.process.state.interrupted": { zh: "已中断", en: "Interrupted" },
"subagent.process.state.pending": { zh: "等待上游", en: "Waiting on upstream" },
"subagent.process.state.queued": { zh: "排队中", en: "Queued" },
"subagent.process.state.running": { zh: "运行中", en: "Running" },
"subagent.process.state.skipped": { zh: "已跳过", en: "Skipped" },
"subagent.process.state.stopped": { zh: "已停止", en: "Stopped" },
"subagent.process.state.waiting": { zh: "等待中", en: "Waiting" },
"subagent.doing": { zh: "正在{what}", en: "Now: {what}" },
"subagent.full_transcript_of_subagent_name": { zh: "子代理完整记录:{name}", en: "Full transcript of subagent: {name}" },
"subagent.group.all_delivered": { zh: "{n} 个都已交付", en: "All {n} delivered" },
@@ -115,6 +115,8 @@ export const WORKBENCH: Messages = {
"workbench.reveal_in_finder": { zh: "在访达中显示", en: "Reveal in Finder" },
"workbench.run_interactively": { zh: "交互运行", en: "Run interactively" },
"workbench.search_files": { zh: "搜索文件", en: "Search files" },
"workbench.pick_a_file": { zh: "从左侧选一个文件", en: "Pick a file on the left" },
"workbench.pick_a_file_detail": { zh: "要开浏览器或终端,点上方标签栏的 +。", en: "For a browser or a terminal, use + in the tab bar above." },
"workbench.select_all": { zh: "全选", en: "Select all" },
"workbench.show_file_tree": { zh: "展开文件树", en: "Show file tree" },
"workbench.show_hidden_files": { zh: "显示隐藏文件", en: "Show hidden files" },
@@ -183,6 +185,7 @@ export const WORKBENCH: Messages = {
"workbench.changes_page_hint": { zh: "勾选=暂存;悬停文件头出「打开文件」「丢弃」;点差异里的行号打开这个文件。", en: "Tick to stage; hover a file header for Open file and Discard; click a line number to open the file." },
"workbench.diff_cut_short": { zh: "差异太长,只显示了前一部分。", en: "The diff is long; only its first part is shown." },
"workbench.expand_section": { zh: "展开{name}", en: "Expand {name}" },
"workbench.resize_the_file_tree": { zh: "调整文件树宽度", en: "Resize the file tree" },
"workbench.collapse_section": { zh: "收起{name}", en: "Collapse {name}" },
"workbench.name_is_not_saved": { zh: "{name} 还没保存", en: "{name} is not saved" },
"workbench.dont_save": { zh: "不保存", en: "Don't save" },
@@ -197,4 +200,30 @@ export const WORKBENCH: Messages = {
"workbench.copied_text": { zh: "已复制 {text}", en: "Copied {text}" },
"workbench.browser_tab_named": { zh: "浏览器标签 · {name}", en: "Browser tab · {name}" },
"workbench.lines_a_b": { zh: "第 {a}–{b} 行", en: "Lines {a}–{b}" },
"workbench.pick_element": { zh: "点选页面元素", en: "Pick an element on the page" },
"workbench.picking_element": { zh: "在页面上点一个元素,按 Esc 取消", en: "Click an element on the page; press Esc to cancel" },
"workbench.pick_ended_timeout": { zh: "等待太久,点选已取消", en: "Picking timed out and was cancelled" },
"workbench.pick_ended_hidden": { zh: "页面不在眼前,点选已取消", en: "The page is not on screen, so picking was cancelled" },
"workbench.pick_ended_navigation": { zh: "页面已跳转或重新加载,点选已取消", en: "The page navigated or reloaded, so picking was cancelled" },
"workbench.pick_ended_gone": { zh: "页面里已经找不到这个元素", en: "The element can no longer be found on the page" },
"workbench.edit_picked_element": { zh: "修改所选元素", en: "Edit the picked element" },
"workbench.edit_text": { zh: "文字", en: "Text" },
"workbench.edit_color": { zh: "文字颜色", en: "Text color" },
"workbench.edit_background": { zh: "背景色", en: "Background" },
"workbench.edit_font_size": { zh: "字号", en: "Font size" },
"workbench.edit_save": { zh: "保存到文件", en: "Save to file" },
"workbench.edit_saving": { zh: "正在保存…", en: "Saving…" },
"workbench.edit_close": { zh: "关闭", en: "Close" },
"workbench.edit_saved": { zh: "已保存到 {path}", en: "Saved to {path}" },
"workbench.edit_not_file": { zh: "只有本地网页文件可以在这里修改并保存", en: "Only a page opened from a local file can be edited and saved here" },
"workbench.edit_outside": { zh: "这个文件不在当前工作区里,不能保存", en: "This file is outside the current workspace and cannot be saved" },
"workbench.edit_conflict": { zh: "文件在你修改期间已变化,请重新点选后再改", en: "The file changed in the meantime; pick the element again and retry" },
"workbench.edit_failed": { zh: "保存失败,文件没有被改动", en: "Saving failed; the file was not changed" },
"workbench.edit_not_found": { zh: "在文件源码里找不到这个元素,它可能是脚本生成的", en: "The element is not in the file's source; a script may have made it" },
"workbench.edit_ambiguous": { zh: "文件里有多个元素匹配,无法确定改哪一个", en: "More than one element in the file matches, so it is unclear which to change" },
"workbench.edit_unmappable": { zh: "这个文件的结构无法可靠对应到页面,未做修改", en: "The file's structure cannot be matched to the page reliably; nothing was changed" },
"workbench.edit_mismatch": { zh: "文件里的文字和页面上显示的不一致,未做修改", en: "The file's text differs from what the page shows; nothing was changed" },
"workbench.edit_not_plain_text": { zh: "这个元素里还有别的元素,只能改颜色和字号,不能改文字", en: "This element contains other elements; only its colors and size can be changed, not its text" },
"workbench.edit_bad_value": { zh: "这个值不能写进样式", en: "That value cannot be written into a style" },
"workbench.edit_nothing": { zh: "没有改动", en: "Nothing changed" },
};
@@ -204,6 +204,9 @@ export interface PeersSettings {
// Ceilings on chained and simultaneous dispatches; 0 removes a ceiling.
max_dispatch_depth: number;
max_dispatched_sessions: number;
// The user's standing yes for open_projects to add folders inside the session's
// own workspace without a card; it applies at the next call.
open_nested: boolean;
path: string;
applies: "next_turn";
pending: boolean;
@@ -218,6 +221,7 @@ export interface PeersSettingsEdit {
release_projects?: string[];
max_dispatch_depth?: number;
max_dispatched_sessions?: number;
open_nested?: boolean;
}
export type PeerRemember = "project" | "none";
+40
View File
@@ -123,6 +123,11 @@ export interface HostPort {
answerBrowserLogin(id: string, username: string, password: string): void;
/** Proceed past the certificate this page was refused for, for this run. */
trustBrowserCertificate(target: string): Promise<boolean>;
/** Whether this shell can let a person point at an element of a page. */
picksBrowserElements(): boolean;
/** Arm the page's picker and wait for the element pointed at, or for why none was. */
pickBrowserElement(target: string, labels: BrowserPickLabels): Promise<BrowserPick>;
cancelBrowserPick(target: string): void;
}
/** Why the shell will not load a typed address: a scheme it never opens, or a
@@ -178,6 +183,22 @@ export interface ViewRect {
height: number;
}
/** What the shell reports of an element the person pointed at. */
export interface PickedElement {
selector: string;
tagName: string;
text?: string;
pageUrl?: string;
style?: Partial<Record<"color" | "backgroundColor" | "fontSize" | "fontWeight", string>>;
}
/** The words the page-side hover card draws; the window owns them. */
export type BrowserPickLabels = Record<"color" | "background" | "font" | "frame" | "shadow", string>;
export type BrowserPickEnd = "escape" | "dismissed" | "timeout" | "hidden" | "navigation" | "gone";
export type BrowserPick = { cancelled: false; element: PickedElement } | { cancelled: true; reason: BrowserPickEnd };
export type BrowserControl = "back" | "forward" | "reload" | "stop";
// The preload bridge. Verbs only: the origin the page was loaded from and the
@@ -217,6 +238,8 @@ interface ElectronBridge {
onBrowserLogin?(listener: (ask: BrowserLogin) => void): () => void;
answerBrowserLogin?(id: string, username: string, password: string): Promise<void>;
trustBrowserCertificate?(target: string): Promise<boolean>;
pickBrowserElement?(target: string, labels: BrowserPickLabels): Promise<BrowserPick>;
cancelBrowserPick?(target: string): Promise<boolean>;
}
const bridge = () => (window as unknown as { reasonixHost?: ElectronBridge }).reasonixHost;
@@ -354,6 +377,16 @@ class ElectronHost implements HostPort {
trustBrowserCertificate(target: string) {
return this.api.trustBrowserCertificate?.(target) ?? Promise.resolve(false);
}
picksBrowserElements() {
return typeof this.api.pickBrowserElement === "function";
}
pickBrowserElement(target: string, labels: BrowserPickLabels): Promise<BrowserPick> {
const gone: BrowserPick = { cancelled: true, reason: "gone" };
return this.api.pickBrowserElement?.(target, labels).catch(() => gone) ?? Promise.resolve(gone);
}
cancelBrowserPick(target: string) {
void this.api.cancelBrowserPick?.(target);
}
}
class BrowserHost implements HostPort {
@@ -454,6 +487,13 @@ class BrowserHost implements HostPort {
trustBrowserCertificate() {
return Promise.resolve(false);
}
picksBrowserElements() {
return false;
}
pickBrowserElement(): Promise<BrowserPick> {
return Promise.resolve({ cancelled: true, reason: "gone" });
}
cancelBrowserPick() {}
}
function pick(): HostPort {
+2 -2
View File
@@ -31,8 +31,8 @@ export type DeliveryFault = "kernel_busy" | "unreachable" | "ui_stalled";
// may not have landed, so the identity is the fault: the kernel was slow, could
// not be reached, or this window's own event loop was too starved to say which.
export class DeliveryError extends Error {
constructor(readonly fault: DeliveryFault) {
super("request unconfirmed: " + fault);
constructor(readonly fault: DeliveryFault, readonly path?: string) {
super("request unconfirmed: " + fault + (path ? " " + path : ""));
this.name = "DeliveryError";
}
}
@@ -324,7 +324,7 @@ export class MockBoundary extends MockShell {
return mockMods(this.modConfig.enabled);
}
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
async peersSettings(): Promise<PeersSettings> {
return { ...this.peerSettings, release_projects: [...this.peerSettings.release_projects] };
@@ -338,6 +338,7 @@ export class MockBoundary extends MockShell {
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
max_dispatch_depth: edit.max_dispatch_depth ?? this.peerSettings.max_dispatch_depth,
max_dispatched_sessions: edit.max_dispatched_sessions ?? this.peerSettings.max_dispatched_sessions,
open_nested: edit.open_nested ?? this.peerSettings.open_nested,
release_projects: keep ? keep.map((path) => this.peerSettings.release_projects.find((p) => p.path === path) ?? { path, name: path.split(/[\\/]/).filter(Boolean).at(-1) ?? path }) : this.peerSettings.release_projects,
pending: true,
};
@@ -1,6 +1,7 @@
import { MockMcp } from "./mock_mcp";
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { ProcessEntry } from "./process";
// Hooks, and what a dry run of one reports back.
export class MockHook extends MockMcp {
@@ -17,6 +18,10 @@ export class MockHook extends MockMcp {
return [];
}
async processes(): Promise<ProcessEntry[]> {
return [];
}
async subagentTranscript(_ref: string): Promise<SubagentTranscript> {
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
}
+2 -7
View File
@@ -28,7 +28,7 @@ import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { RunningWorkPort } from "./running";
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
import type { SessionTailPort } from "./session_tail";
@@ -181,7 +181,7 @@ export interface TrayPrefs {
closeToTray: boolean;
}
export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
export interface AgentPort extends RunningWorkPort, MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
providerSetup(): Promise<ProviderSetup | null>;
saveProviderKey(apiKey: string): Promise<void>;
// The models that answer the job asked about; "chat" when none is named.
@@ -708,11 +708,6 @@ export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, Session
// Ends one running tool call and lets the turn go on; a call that already
// finished is refused (tool_call.not_running).
stopToolCall(callId: string): Promise<void>;
// The sub-agent runs this conversation spawned, running or finished, newest
// first, and one run's conversation a page at a time: a running run is read
// live. A run of another conversation is refused (subagent.not_owned).
subagents(): Promise<SubagentRun[]>;
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
// The person's guidance to a sub-agent that is still running, read at its next
// tool call. Refused with a code when it is not running (subagent.not_running),
// is empty, over the size or rate limit, or is another conversation's.
+19
View File
@@ -0,0 +1,19 @@
/** Where a unit of work stands, in terms every kind of work can be mapped onto. */
export type ProcessState = "pending" | "queued" | "running" | "waiting" | "completed" | "failed" | "stopped" | "interrupted" | "skipped";
/** What kind of work a process is: the owner that produced it. */
export type ProcessKind = "subagent" | "job";
/** One unit of work this session has running now: GET /processes. */
export interface ProcessEntry {
/** "<kind>:<owner id>". */
id: string;
kind: ProcessKind;
/** The process that started this one; absent for a root. */
parent?: string;
name: string;
state: ProcessState;
/** Why it is in this state, when the owner records one. */
cause?: string;
startedAt: number;
}
+14
View File
@@ -0,0 +1,14 @@
import type { ProcessEntry } from "./process";
import type { SubagentRun, SubagentTranscript } from "./subagent";
/** What this conversation has handed off or has running. */
export interface RunningWorkPort {
// The sub-agent runs this conversation spawned, running or finished, newest
// first, and one run's conversation a page at a time: a running run is read
// live. A run of another conversation is refused (subagent.not_owned).
subagents(): Promise<SubagentRun[]>;
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
// What this session has running now across owners, read when asked and never
// pushed: a window that does not open the list pays nothing for it.
processes(): Promise<ProcessEntry[]>;
}
@@ -89,9 +89,19 @@ describe("idempotent reads", () => {
});
vi.stubGlobal("fetch", fetchMock);
const probe = new Probe();
await expect(probe.read("/status")).rejects.toBeInstanceOf(TypeError);
await expect(probe.read("/status")).rejects.toBeInstanceOf(DeliveryError);
expect(await probe.read("/status")).toEqual({ n: 2 });
});
it("names an unreachable kernel and the read, not the browser's bare fetch failure", async () => {
vi.stubGlobal("fetch", async () => {
throw new TypeError("Failed to fetch");
});
const err = (await new Probe().read("/status").catch((e) => e)) as DeliveryError;
expect(err).toBeInstanceOf(DeliveryError);
expect(err.fault).toBe("unreachable");
expect(err.path).toBe("/status");
});
});
describe("a decision submit", () => {
+13 -2
View File
@@ -142,11 +142,22 @@ export class SseHttp {
// A read the caller can abandon: it is not shared with another caller's, so
// abandoning it frees its connection instead of leaving it to run out.
protected async getUntil<T>(path: string, signal: AbortSignal): Promise<T> {
const res = await fetch(this.base + path, { credentials: "same-origin", signal });
const res = await SseHttp.reach(path, () => fetch(this.base + path, { credentials: "same-origin", signal }), signal);
if (!res.ok) await SseHttp.fail(path, res);
return (await res.json()) as T;
}
// A transport failure on a read is the kernel being unreachable, and the
// browser's own "Failed to fetch" names neither that nor which read it was.
private static async reach(path: string, send: () => Promise<Response>, signal?: AbortSignal): Promise<Response> {
try {
return await send();
} catch (e) {
if (e instanceof TypeError && !signal?.aborted) throw new DeliveryError("unreachable", path);
throw e;
}
}
private startRead<T>(path: string): Promise<T> {
const flight = this.fetchRead<T>(path);
const slot: ReadSlot = { flight };
@@ -159,7 +170,7 @@ export class SseHttp {
}
private async fetchRead<T>(path: string): Promise<T> {
const res = await fetch(this.base + path, { credentials: "same-origin" });
const res = await SseHttp.reach(path, () => fetch(this.base + path, { credentials: "same-origin" }));
if (!res.ok) await SseHttp.fail(path, res);
return (await res.json()) as T;
}
@@ -2,6 +2,7 @@ import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, Wor
import type { DiscardRequest, GitCommitFiles, GitHistory, GitRepos, PushPlan, PushRequest, PushResult } from "./workspace";
import type { JobOutput, ToolOutputPage } from "./session";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { ProcessEntry } from "./process";
import { SseFeedback } from "./sse_feedback";
import { host } from "./host";
import { HttpError } from "./http_error";
@@ -82,6 +83,9 @@ export class SseWorkspace extends SseFeedback {
subagents() {
return this.get<SubagentRun[]>("/subagents");
}
processes() {
return this.get<ProcessEntry[]>("/processes");
}
subagentTranscript(ref: string, after = 0, limit = 0) {
const query = [after ? `after=${after}` : "", limit ? `limit=${limit}` : ""].filter(Boolean).join("&");
return this.get<SubagentTranscript>("/subagents/" + encodeURIComponent(ref) + "/transcript" + (query ? "?" + query : ""));
@@ -324,6 +324,9 @@ the picker and keyboard navigation.
1 CSS px in both themes at 100% and 125% device scale.
- The compact account button has no row divider; the footer owns the divider
above all three controls.
- Inside the workbench the file tree is the left column and the open file the right, in the dock as
well as full width. Choosing a file leaves the tree open; choosing any other surface takes the
whole column and folds it. Below 760px the same rule holds with the tree folded by default.
- The docked workbench uses the divider's remembered width. Pointer dragging and
keyboard resizing share that width; there is no intermediate width preset.
- Full-workbench view preserves the divider width and keeps the conversation and
@@ -1747,3 +1750,13 @@ A sub-agent row, by the list's own width:
- Hover waits briefly before opening and closing so the pointer can cross to
the card. Click and keyboard activation also work; Escape restores focus.
The portaled card follows its anchor and stays inside the viewport.
## Display math
- A display formula scrolls sideways when it is wider than the column, which
makes its vertical overflow hidden as well. KaTeX draws raised exponents, tall
delimiters and big-operator limits past the line box, so the block carries
`.6em` of padding above and below and only `.2em` of margin: the clip edge
sits outside everything a formula draws, and the gap between blocks is
unchanged to the eye. Padding on one side only leaves the other flush with
the clip.
+2 -3
View File
@@ -649,8 +649,7 @@
.app[data-browser="off"] .pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { visibility: hidden; transition-property: visibility; transition-duration: 0s; transition-delay: var(--t-panel) }
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr) }
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-explorer { display: none }
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] { grid-template-columns: minmax(0, 1fr) }
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] .workbench-canvas { display: none }
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-canvas { grid-column: 1 }
.bpanel { flex: 1; display: flex; flex-direction: column; min-height: 0 }
.bbar { flex: none; display: flex; flex-direction: column; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--hair) }
.bbar .vtabs { overflow-x: auto }
@@ -3551,7 +3550,7 @@
}
.md .katex { font-size: 1.02em }
.md .katex-display { margin: .6em 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px }
.md .katex-display { margin: .2em 0; overflow-x: auto; overflow-y: hidden; padding: .6em 0 }
.md-tw { margin: 0 0 .7em; overflow-x: auto }
@@ -0,0 +1,3 @@
.workbench-tree-resize { position: absolute; top: 0; bottom: 0; right: 0; width: 6px; z-index: 3; cursor: col-resize; touch-action: none; }
.workbench-tree-resize:hover, .workbench-tree-resize[data-on] { background: linear-gradient(to right, transparent 2px, var(--accent) 2px, var(--accent) 4px, transparent 4px); }
.workbench-tree-resize:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import app from "./app.css?raw";
const rule = (selector: string): string => {
const at = app.indexOf(selector + " {");
return at < 0 ? "" : app.slice(at, app.indexOf("}", at));
};
const em = (value: string): number => (value.endsWith("em") ? parseFloat(value) : NaN);
describe("a display formula in a message", () => {
const block = rule(".md .katex-display");
it("scrolls sideways when it is too wide", () => {
expect(block).toMatch(/overflow-x:\s*auto/);
});
it("leaves room above and below for what a raised exponent or a tall delimiter draws past the line", () => {
expect(block).toMatch(/overflow-y:\s*hidden/);
const pad = block.match(/padding:\s*([\d.]+em)\s+0/);
expect(pad, "vertical padding in em on both sides").not.toBeNull();
expect(em(pad![1])).toBeGreaterThanOrEqual(0.55);
});
it("is not given a bottom-only allowance that leaves the top flush with the clip edge", () => {
expect(block).not.toMatch(/padding-bottom/);
});
});
@@ -42,6 +42,7 @@
@import "./sessionpin.css" layer(legacy);
@import "./treerows.css" layer(legacy);
@import "./workbench-changes.css" layer(legacy);
@import "./explorer-resize.css" layer(legacy);
@import "./agentrun.css" layer(legacy);
@import "./gitgraph.css" layer(legacy);
@import "./modelmenu.css" layer(legacy);
+17 -5
View File
@@ -1142,6 +1142,17 @@ body { background: var(--page) !important; }
.studio-browser-note > button { flex: none; margin-left: auto; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer; font: var(--w-med) var(--fs-micro)/1.45 var(--ui); }
.studio-browser-note > button:hover { color: var(--text); background: var(--overlay); }
.studio-browser-error { border-top: 0; border-bottom: 1px solid var(--border); color: #c78a82; }
.belement { flex: none; display: grid; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--hair); background: var(--surface); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); }
.belement-head { display: flex; align-items: center; gap: 10px; }
.belement-head code { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.belement-head button, .belement-foot button { flex: none; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer; font: var(--w-med) var(--fs-micro)/1.45 var(--ui); }
.belement-head button:hover, .belement-foot button:hover:not(:disabled) { color: var(--text); background: var(--overlay); }
.belement-foot button:disabled { cursor: default; opacity: .5; }
.belement-field { display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; gap: 8px; }
.belement-field input { min-width: 0; padding: 3px 6px; border: 1px solid var(--border); border-radius: var(--r-xs); background: var(--page); color: var(--text); font: inherit; }
.belement-field input:disabled { opacity: .6; }
.belement-foot { display: flex; align-items: center; gap: 10px; }
.belement-foot > p { padding: 0; border: 0; background: transparent; }
.workbench { width: 100%; height: 100%; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface); color: var(--text); }
.pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { padding: 8px 8px 8px 2px; border-left: 0; background: transparent; }
.pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] .workbench-tabs { height: calc(var(--pane-bar-h) - 9px); }
@@ -1177,12 +1188,13 @@ body { background: var(--page) !important; }
.workbench-tab:hover .workbench-tab-close, .workbench-tab[aria-selected="true"] .workbench-tab-close { opacity: 1; }
.workbench-tab-close:hover { color: var(--text); }
.workbench-tabs svg { width: 13px; height: 13px; flex: none; }
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) clamp(190px, 25%, 300px); grid-template-rows: minmax(0, 1fr); }
.workbench-canvas { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: min(var(--wb-tree-w, clamp(190px, 36%, 260px)), 70%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.workbench-canvas { grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.workbench-content > .studio-browser, .workbench-content > .bpanel { width: 100%; height: 100%; min-width: 0; }
.workbench-canvas .bbar > .vtabs { display: none; }
.workbench-explorer { min-width: 0; display: flex; flex-direction: column; border-left: 1px solid var(--hair); background: var(--page); }
.workbench-explorer { position: relative; grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--hair); background: var(--page); }
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); text-transform: uppercase; }
.workbench-explorer-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workbench-explorer-head small { color: var(--faint); font-size: var(--fs-micro); }
.workbench-explorer-head small { margin-left: auto; }
.workbench-open-editor {
@@ -1251,9 +1263,9 @@ body { background: var(--page) !important; }
.workbench-error.workbench-close-error { flex: none; font-size: var(--fs-meta); white-space: pre-line; overflow-wrap: anywhere; }
.workbench-error { padding: 8px 12px; border-bottom: 1px solid var(--err); color: var(--err-ink); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
@media (max-width: 760px) {
.workbench-body { grid-template-columns: minmax(0, 1fr); }
.workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr); }
.workbench-body:not([data-files]) .workbench-explorer { display: none; }
.workbench-body[data-files] .workbench-canvas { display: none; }
.workbench-body:not([data-files]) .workbench-canvas { grid-column: 1; }
}
/* Settings is one stable layer in the current window. */
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import css from "../design/workbench.css?raw";
const rule = (selector: string): string => {
const at = css.indexOf(selector + " {");
return at < 0 ? "" : css.slice(at, css.indexOf("}", at));
};
describe("the changes section's head in a narrow explorer", () => {
const head = rule(".workbench-explorer .workbench-changes > .workbench-explorer-head");
it("wraps its buttons onto a second line instead of overflowing sideways", () => {
expect(head).toMatch(/flex-wrap:\s*wrap/);
expect(head).toMatch(/height:\s*auto/);
});
it("keeps the title and the file count whole rather than breaking them a character at a time", () => {
const labels = rule(".workbench-explorer .workbench-changes > .workbench-explorer-head > :is(span, small, .workbench-section-title)");
expect(labels).toMatch(/flex:\s*none/);
expect(labels).toMatch(/white-space:\s*nowrap/);
});
});
+9 -12
View File
@@ -5,8 +5,7 @@ import { keepSame } from "./stable";
import { usePaneCallbacks } from "./usePaneCallbacks";
import { watchTreeWake } from "./treewake";
import { usePinnedSessions } from "./usePinnedSessions";
import { AutoArchiveNotice } from "./AutoArchiveNotice";
import { LevelUpNotice } from "./LevelUpNotice";
import { useWindowNotices } from "./useWindowNotices";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import { reason } from "../i18n/kernel";
import { t } from "../i18n";
@@ -20,7 +19,7 @@ import { Nav } from "./Nav";
import { useTreeWindow, type TreePaging } from "./treewindow";
import { useNavRail } from "./navrail";
import { feedbackEntryTab } from "./feedbackentry";
import { ErrorBar } from "./ErrorBar";
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { noteFault } from "./fault";
import { ToastStack } from "./ToastStack";
import { useLinkRouting } from "./links";
@@ -292,7 +291,7 @@ export function App({ hub }: { hub: HubPort }) {
// Asked on open, on focus (at most every 30 s) and every 10 min while a report
// is unsettled, never otherwise; a failed refresh stays silent and backs off.
useEffect(() => startUpdateWatch(machinePort), [machinePort]);
useFeedbackUnread(machineRt ? machinePort : null, machineRt?.id ?? "", feedbackUnread, setFeedbackUnread, feedback !== null);
useFeedbackUnread(!FEEDBACK_RETIRED && machineRt ? machinePort : null, machineRt?.id ?? "", feedbackUnread, setFeedbackUnread, feedback !== null);
useFirstRunRead(activePort, setSetup, setWelcomed);
@@ -481,11 +480,8 @@ export function App({ hub }: { hub: HubPort }) {
// A failed request floats above the composer of the pane in front, where the
// eye already is; with no pane on screen it takes the main area's corner.
const activePaneShown = !!activeRuntime && panePorts.has(activeRuntime.id);
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={() => setFeedback("send")} /> : null), [error]);
const bar = useMemo(
() => errorBar ?? <LevelUpNotice port={machineRt ? machinePort : null} tick={feedback === null ? 0 : 1} fallback={<AutoArchiveNotice tree={tree} />} />,
[errorBar, tree, machineRt, machinePort, feedback],
);
const reportError = useMemo(() => (FEEDBACK_RETIRED ? undefined : () => setFeedback("send")), []);
const { errorBar, note, bar } = useWindowNotices({ error, setError, onReport: reportError, tree, port: machineRt ? machinePort : null, feedbackOpen: feedback !== null });
const activeWorkspace = tree.find((ws) => !activeRuntime?.host && workspaceContains(ws, activeRuntime?.root)) ?? tree[0];
const newSessionRoot = activeWorkspace?.root;
const startConversation = useNewConversation(active, runtimes, newSessionRoot, openPane, openRemotePane);
@@ -704,13 +700,14 @@ export function App({ hub }: { hub: HubPort }) {
terminalPulse={terminalRequests[rt.id] ?? 0}
viewRequest={viewRequests[rt.id]}
sessionRef={sessionRefs[rt.id]}
alert={rt.id === active ? bar : undefined}
alert={rt.id === active && !errorBar ? note : undefined}
failure={rt.id === active ? errorBar : undefined}
connect={first.connect}
needsProject={needsProject}
onOpenProject={openProject}
onKeepHere={keepHere}
onSettings={openSettings}
onFeedback={openFeedback}
onFeedback={FEEDBACK_RETIRED ? undefined : openFeedback}
onOpenSession={openSessionFor(rt.id)}
theme={scheme}
dockW={shownDockW}
@@ -754,7 +751,7 @@ export function App({ hub }: { hub: HubPort }) {
<ToastStack onFeedback={() => setFeedback("send")} port={machinePort} idle={!running} look={look} onLook={onLook} />
{feedback && activePort && (
{!FEEDBACK_RETIRED && feedback && activePort && (
<Feedback port={machinePort} tab={feedback} onClose={() => setFeedback(null)} onError={setError} onUnread={setFeedbackUnread} unread={feedbackUnread} />
)}
@@ -0,0 +1,97 @@
import { useState } from "react";
import { t } from "../i18n";
import type { PickedElement } from "../port/host";
import type { SourceEdit, StyleProperty } from "./htmlsource";
import type { PageEditFailure, PageEditor } from "./pageedit";
const FAILED: Record<PageEditFailure, () => string> = {
not_found: () => t("workbench.edit_not_found"),
ambiguous: () => t("workbench.edit_ambiguous"),
unmappable: () => t("workbench.edit_unmappable"),
mismatch: () => t("workbench.edit_mismatch"),
not_plain_text: () => t("workbench.edit_not_plain_text"),
bad_value: () => t("workbench.edit_bad_value"),
outside: () => t("workbench.edit_outside"),
conflict: () => t("workbench.edit_conflict"),
failed: () => t("workbench.edit_failed"),
};
const STYLES: { property: StyleProperty; label: () => string }[] = [
{ property: "color", label: () => t("workbench.edit_color") },
{ property: "backgroundColor", label: () => t("workbench.edit_background") },
{ property: "fontSize", label: () => t("workbench.edit_font_size") },
];
interface Props {
element: PickedElement;
/** The disk path of the file the page was opened from, or "" for any other page. */
filePath: string;
editor?: PageEditor;
onSaved: () => void;
onClose: () => void;
}
export function BrowserElementEdit({ element: picked, filePath, editor, onSaved, onClose }: Props) {
// What the file holds now: a save moves it, so the next one is measured from there.
const [element, setElement] = useState(picked);
const [text, setText] = useState(picked.text ?? "");
const [styles, setStyles] = useState<Partial<Record<StyleProperty, string>>>(picked.style ?? {});
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const editable = !!filePath && !!editor;
const changes = (): SourceEdit[] => {
const out: SourceEdit[] = [];
if (element.text && text !== element.text) out.push({ kind: "text", before: element.text, after: text });
for (const { property } of STYLES) {
const value = (styles[property] ?? "").trim();
if (value && value !== element.style?.[property]) out.push({ kind: "style", property, value });
}
return out;
};
const save = async () => {
const edits = changes();
if (!editor || !edits.length) {
setNote({ kind: "error", text: t("workbench.edit_nothing") });
return;
}
setBusy(true);
setNote(null);
const out = await editor(filePath, { selector: element.selector, tagName: element.tagName }, edits);
setBusy(false);
if (out.saved) {
setElement({ ...element, text: element.text ? text : element.text, style: { ...element.style, ...Object.fromEntries(edits.flatMap((e) => (e.kind === "style" ? [[e.property, e.value]] : []))) } });
setNote({ kind: "ok", text: t("workbench.edit_saved", { path: out.path }) });
onSaved();
} else {
setNote({ kind: "error", text: FAILED[out.code]() });
}
};
return (
<form className="belement" aria-label={t("workbench.edit_picked_element")} data-action-submit="browser.edit-save" onSubmit={(event) => { event.preventDefault(); void save(); }}>
<header className="belement-head">
<code title={element.selector}>{element.selector}</code>
<button type="button" data-action="browser.edit-close" onClick={onClose}>{t("workbench.edit_close")}</button>
</header>
{!editable && <p className="studio-browser-note">{t("workbench.edit_not_file")}</p>}
{element.text !== undefined && element.text !== "" && (
<label className="belement-field">
<span>{t("workbench.edit_text")}</span>
<input value={text} disabled={!editable || busy} onChange={(event) => setText(event.target.value)} spellCheck={false} />
</label>
)}
{STYLES.map(({ property, label }) => (
<label className="belement-field" key={property}>
<span>{label()}</span>
<input value={styles[property] ?? ""} disabled={!editable || busy} onChange={(event) => setStyles((v) => ({ ...v, [property]: event.target.value }))} spellCheck={false} />
</label>
))}
<div className="belement-foot">
<button type="submit" data-action="browser.edit-save" disabled={!editable || busy}>{busy ? t("workbench.edit_saving") : t("workbench.edit_save")}</button>
{note && <p role={note.kind === "error" ? "alert" : "status"} className={note.kind === "error" ? "studio-browser-error" : "studio-browser-note"}>{note.text}</p>}
</div>
</form>
);
}
+67 -5
View File
@@ -1,14 +1,16 @@
import { useEffect, useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react";
import { ArrowLeft, ArrowRight, ExternalLink, Globe, MousePointerClick, RotateCw } from "lucide-react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, BrowserTab } from "../port/port";
import { host, type BrowserControl, type BrowserLoadFailure, type BrowserRefusal } from "../port/host";
import { host, type BrowserControl, type BrowserLoadFailure, type BrowserPickEnd, type BrowserRefusal, type PickedElement } from "../port/host";
import { SWAP_MARK } from "./swap";
import { occluded, visibleBox } from "./occlusion";
import { BrowserFailure } from "./BrowserFailure";
import { AgentPageNote } from "./AgentPageNote";
import { readPageTabs } from "./browser_open";
import { BrowserElementEdit } from "./BrowserElementEdit";
import type { PageEditor } from "./pageedit";
const START = "reasonix://start";
@@ -181,13 +183,20 @@ export function localFilePath(url: string): string {
type OpenDocument = (path: string) => Promise<void>;
export function AgentBrowserPanel(props: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument }) {
export function AgentBrowserPanel(props: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
if (host().drawsBrowserViews()) return <AgentBrowserView {...props} />;
const current = props.tabs.find((tab) => tab.active) ?? props.tabs[0];
return <div className="bpanel">{current && <AgentPageNote tab={current} />}</div>;
}
function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument }) {
const PICK_ENDED: Partial<Record<BrowserPickEnd, () => string>> = {
timeout: () => t("workbench.pick_ended_timeout"),
hidden: () => t("workbench.pick_ended_hidden"),
navigation: () => t("workbench.pick_ended_navigation"),
gone: () => t("workbench.pick_ended_gone"),
};
function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument, editPage }: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
const slot = useRef<HTMLDivElement>(null);
const [picked, setPicked] = useState("");
const current = tabs.find((tab) => tab.target === picked) ?? tabs.find((tab) => tab.active) ?? tabs[0];
@@ -196,6 +205,9 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
const [refused, setRefused] = useState<BrowserRefusal>("");
const [docFailed, setDocFailed] = useState("");
const [frame, setFrame] = useState("");
const [picking, setPicking] = useState(false);
const [element, setElement] = useState<PickedElement | null>(null);
const [pickEnd, setPickEnd] = useState("");
const target = current?.target ?? "";
const failure = failures[target];
@@ -276,6 +288,41 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
};
}, [shown, target, failure]);
// A pick belongs to the page it was armed on: another page, or another
// address on the same one, leaves it nothing to point at.
useEffect(() => {
setElement(null);
setPickEnd("");
if (!picking) return;
setPicking(false);
if (target) host().cancelBrowserPick(target);
}, [target, current?.url]);
const pick = () => {
if (!target) return;
if (picking) {
host().cancelBrowserPick(target);
return;
}
setPicking(true);
setPickEnd("");
const armed = target;
void host()
.pickBrowserElement(armed, {
color: t("workbench.edit_color"),
background: t("workbench.edit_background"),
font: t("workbench.edit_font_size"),
frame: t("workbench.pick_ended_gone"),
shadow: t("workbench.pick_ended_gone"),
})
.then((answer) => {
setPicking(false);
if (answer.cancelled) {
setPickEnd(PICK_ENDED[answer.reason]?.() ?? "");
return;
}
setElement(answer.element);
});
};
const control = (action: BrowserControl) => target && host().controlBrowserView(target, action);
const open = (to: string) => {
if (!target) return;
@@ -308,6 +355,9 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="back" aria-label={t("workbench.back")} title={t("workbench.back")} onClick={() => control("back")}><ArrowLeft size={16} /></button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="forward" aria-label={t("workbench.forward")} title={t("workbench.forward")} onClick={() => control("forward")}><ArrowRight size={16} /></button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="reload" aria-label={t("common.reload")} title={t("common.reload")} onClick={() => control("reload")}><RotateCw size={16} /></button>
{host().picksBrowserElements() && (
<button className="bbtn" data-action="browser.pick" aria-pressed={picking} disabled={!target || !!failure} aria-label={t("workbench.pick_element")} title={t("workbench.pick_element")} onClick={pick}><MousePointerClick size={16} /></button>
)}
<button className="bbtn" data-action="browser.external" disabled={!documentPath && !/^https?:\/\//i.test(current?.url ?? "")} aria-label={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} title={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} onClick={() => (documentPath ? openDocument() : current && void host().openExternal(current.url))}><ExternalLink size={16} /></button>
<form className="baddr" data-action="browser.navigate" data-target={current?.id} onSubmit={go}>
<input data-action="browser.navigate" data-target={current?.id} value={address} spellCheck={false} aria-label={t("workbench.address")} onChange={(event) => { setAddress(event.target.value); setRefused(""); }} />
@@ -316,6 +366,18 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
</div>
{documentPath && <p className="studio-browser-note" data-action="browser.document-hint">{t("workbench.opens_this_file_with_the")}</p>}
{docFailed && <p className="studio-browser-error" role="alert">{docFailed}</p>}
{picking && <p className="studio-browser-note" role="status">{t("workbench.picking_element")}</p>}
{pickEnd && <p className="studio-browser-note" role="status">{pickEnd}</p>}
{element && (
<BrowserElementEdit
key={element.selector}
element={element}
filePath={localFilePath(current?.url ?? "")}
editor={editPage}
onSaved={() => control("reload")}
onClose={() => setElement(null)}
/>
)}
{refused && (
<p className="studio-browser-error" role="alert">
{refused === "network_file" ? t("workbench.a_network_share_cannot_be") : t("workbench.enter_a_valid_http_or")}
@@ -337,6 +399,6 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
);
}
export function BrowserPanel(props: ManualProps | { tabs: BrowserTab[]; shown: boolean; onOpenDocument?: OpenDocument }) {
export function BrowserPanel(props: ManualProps | { tabs: BrowserTab[]; shown: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
return "tabs" in props ? <AgentBrowserPanel {...props} /> : <ManualBrowserPanel {...props} />;
}
@@ -1,3 +1,4 @@
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import { ChangedFilesSetting } from "./ChangedFilesSetting";
@@ -28,7 +29,7 @@ export function ChatDisplaySettings({ port }: { port: AgentPort }) {
</SectionCard>
<SectionCard title={t("settings.notices_card")}>
<NewAnswerSection />
<FeedbackLevelNoticeSetting port={port} />
{!FEEDBACK_RETIRED && <FeedbackLevelNoticeSetting port={port} />}
</SectionCard>
</>
);
+39 -2
View File
@@ -1,25 +1,31 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JobEntry, JobOutput } from "../port/port";
import type { ProcessEntry } from "../port/process";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { StudioIcon } from "./StudioIcon";
import { agentsIn } from "./panels/derive";
import { AgentDeck } from "./AgentDeck";
import { AgentTranscript } from "./panels/AgentTranscript";
import { JobDeck } from "./JobDeck";
import { ProcessDeck } from "./ProcessDeck";
import { JobOutputView } from "./panels/JobOutputView";
import type { Task } from "./panels/Agents";
import { useDismiss } from "./dismiss";
export type Deck = "" | "agents" | "jobs";
export type Deck = "" | "agents" | "jobs" | "processes";
/** What is running on this session's behalf but is not in the transcript: work
* handed to a sub-agent, and processes left running in the background. The
* readings sit at the right end of the run rail, beside the other numbers
* about this turn, and each opens on hover the way the context reading does —
* one gesture for every summary on this row. */
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void }) {
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, onReadProcesses }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void; onReadProcesses?: () => Promise<ProcessEntry[]> }) {
const agentsBox = useRef<HTMLDivElement>(null);
const jobsBox = useRef<HTMLDivElement>(null);
const processBox = useRef<HTMLDivElement>(null);
const [processes, setProcesses] = useState<ProcessEntry[]>([]);
const [processError, setProcessError] = useState("");
const [viewing, setViewing] = useState("");
const [viewingJob, setViewingJob] = useState("");
const viewed = viewing ? tasks.find((x) => x.id === viewing) : undefined;
@@ -38,6 +44,16 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
};
useDismiss(open === "agents", agentsBox, shut);
useDismiss(open === "jobs", jobsBox, shut);
useDismiss(open === "processes", processBox, shut);
useEffect(() => {
if (open !== "processes" || !onReadProcesses) return;
let current = true;
onReadProcesses().then(
(rows) => { if (current) { setProcesses(rows); setProcessError(""); } },
(e: unknown) => { if (current) { setProcesses([]); setProcessError(reason(e)); } },
);
return () => { current = false; };
}, [open, onReadProcesses, tasks, jobs]);
const liveAgents = useMemo(() => agentsIn(tasks.filter((x) => x.running)), [tasks]);
const liveJobs = useMemo(() => jobs.filter((j) => j.status === "running").length, [jobs]);
// A failure stays loud until the list has been opened on it.
@@ -79,6 +95,27 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
{viewingJob && onReadJob && jobs.some((j) => j.id === viewingJob) && (
<JobOutputView job={jobs.find((j) => j.id === viewingJob)!} read={onReadJob} onClose={() => setViewingJob("")} onCancel={onCancelJob} />
)}
{onReadProcesses && (
<div ref={processBox} className="studio-deck-anchor" data-open={open === "processes" ? "" : undefined}>
<button
type="button"
className="studio-deckchip"
data-action="deck.processes"
aria-label={t("subagent.process.chip")}
title={t("subagent.process.chip")}
data-live={liveAgents || liveJobs ? "" : undefined}
aria-expanded={open === "processes"}
aria-haspopup="dialog"
onClick={() => onOpen((was) => (was === "processes" ? "" : "processes"))}
>
<StudioIcon name="play" />
<span>{t("subagent.process.chip")}</span>
</button>
<div className="studio-deck-pop" role="dialog" aria-label={t("subagent.process.chip")}>
<ProcessDeck rows={processes} error={processError} />
</div>
</div>
)}
{jobs.length > 0 && (
<div ref={jobsBox} className="studio-deck-anchor" data-open={open === "jobs" ? "" : undefined}>
<button
@@ -0,0 +1,113 @@
import { useLayoutEffect, useRef } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { t } from "../i18n";
const KEY = "rx-wb-tree-w";
const MIN = 150;
const MAX = 560;
const VAR = "--wb-tree-w";
const clamp = (v: number) => Math.min(MAX, Math.max(MIN, Math.round(v)));
function saved(): number | null {
try {
const n = Number(localStorage.getItem(KEY));
return Number.isFinite(n) && n > 0 ? clamp(n) : null;
} catch {
return null;
}
}
function keep(w: number | null) {
try {
if (w === null) localStorage.removeItem(KEY);
else localStorage.setItem(KEY, String(w));
} catch {
/* the width only fails to be remembered */
}
}
/** The explorer's width is the reader's call about their own screen, so it is
* kept in this browser and absent until they drag; without it the stylesheet's
* clamp decides. */
export function ExplorerResize() {
const bar = useRef<HTMLDivElement>(null);
const host = () => bar.current?.closest<HTMLElement>(".workbench-body") ?? null;
const explorer = () => bar.current?.closest<HTMLElement>(".workbench-explorer") ?? null;
const apply = (w: number | null) => {
const body = host();
if (!body) return;
if (w === null) body.style.removeProperty(VAR);
else body.style.setProperty(VAR, `${w}px`);
bar.current?.setAttribute("aria-valuenow", String(Math.round(explorer()?.getBoundingClientRect().width ?? 0)));
};
useLayoutEffect(() => {
const w = saved();
if (w !== null) apply(w);
}, []);
const drag = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
const el = e.currentTarget;
const from = explorer()?.getBoundingClientRect().width ?? 0;
const x0 = e.clientX;
let now = from;
el.setPointerCapture(e.pointerId);
el.dataset.on = "";
const move = (ev: PointerEvent) => {
now = clamp(from + ev.clientX - x0);
apply(now);
};
const up = () => {
el.removeEventListener("pointermove", move);
el.removeEventListener("pointerup", up);
el.removeEventListener("pointercancel", up);
delete el.dataset.on;
keep(clamp(now));
};
el.addEventListener("pointermove", move);
el.addEventListener("pointerup", up);
el.addEventListener("pointercancel", up);
};
const reset = () => {
apply(null);
keep(null);
};
const key = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Home") {
e.preventDefault();
reset();
return;
}
const dir = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
if (!dir) return;
e.preventDefault();
const next = clamp((explorer()?.getBoundingClientRect().width ?? 0) + dir * (e.shiftKey ? 48 : 12));
apply(next);
keep(next);
};
return (
<div
ref={bar}
className="workbench-tree-resize"
role="separator"
aria-orientation="vertical"
aria-label={t("workbench.resize_the_file_tree")}
aria-valuemin={MIN}
aria-valuemax={MAX}
tabIndex={0}
onPointerDown={drag}
onDoubleClick={reset}
onKeyDown={key}
data-action-pointerdown="workbench.tree-resize"
data-action-dblclick="workbench.tree-resize"
data-action-keydown="workbench.tree-resize"
/>
);
}
@@ -1,6 +1,9 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
// These tests describe the feedback channel while it exists; the shipped build has it retired.
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
import "./testkit";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
+4 -2
View File
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import { money } from "../i18n/format";
import type { ContextBreakdown, JobEntry, JobOutput, McpEntry, SessionUsage } from "../port/port";
import type { CostQuote } from "../port/wire";
import type { ProcessEntry } from "../port/process";
import { cacheRate as rateOf } from "../state/overview";
import { SessionOverviewCard } from "./SessionOverviewCard";
import type { CurrencyAmount, Metrics } from "../state/session_types";
@@ -20,7 +21,7 @@ import { MASK, type Wallet } from "./wallet";
import type { SpeedSummary } from "./speed";
/** The numbers about this turn, in one row under the composer. */
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onReadProcesses, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
tps: number;
trail: number[];
running: boolean;
@@ -40,6 +41,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
onSettings: (section?: string) => void;
onCancelJob: (id: string) => Promise<void>;
onReadJob?: (id: string) => Promise<JobOutput>;
onReadProcesses?: () => Promise<ProcessEntry[]>;
onOpenRun?: (call: string) => Promise<boolean>;
onStopCall?: (callId: string) => void;
onClearJobs?: (ids: string[]) => void;
@@ -115,7 +117,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
{cost.length > 0 && turnCost.some((m) => m.amount > 0) && <span className="studio-meter-cost"><span>{t("composer.this_turn")}</span>{hideAmounts ? <b>{MASK}</b> : turnCost.map((m) => <b key={m.currency}>{money(m.amount, m.currency)}</b>)}</span>}
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} title={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("common.insufficient_balance")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("composer.view_wallet_balance") : `${t("composer.view_wallet_balance")}, ${t("common.insufficient_balance")}`} onClick={() => onSettings("spend")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onReadProcesses={onReadProcesses} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
</div>
);
}
+8 -5
View File
@@ -1,3 +1,4 @@
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { t } from "../i18n";
import { FeedbackBadge } from "./feedbackentry";
import { useDestinations, type Mark } from "./navdest";
@@ -59,11 +60,13 @@ export function Nav({ at, onGo, onFiles, onHome, onRail, onFeedback, onNew, feed
{[HOME, ...destinations].map(btn)}
</div>
<div className="navgrp foot">
<button className="navbtn" data-action="feedback.open" onClick={onFeedback}>
<StudioIcon name="feedback" aria-hidden="true" />
<span className="navhint" role="tooltip">{t("common.send_feedback")}</span>
<FeedbackBadge unread={feedbackUnread} />
</button>
{!FEEDBACK_RETIRED && (
<button className="navbtn" data-action="feedback.open" onClick={onFeedback}>
<StudioIcon name="feedback" aria-hidden="true" />
<span className="navhint" role="tooltip">{t("common.send_feedback")}</span>
<FeedbackBadge unread={feedbackUnread} />
</button>
)}
{FOOT.map(btn)}
</div>
</nav>
+4 -1
View File
@@ -103,7 +103,7 @@ const totalsOf = (st: SessionStatus) => ({
incompleteReason: st.sessionCostQuote?.incompleteReason,
});
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert, failure }: PaneProps) {
const [s, dispatch] = useReducer(reduce, initialState);
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
@@ -200,6 +200,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
}, [pacer]);
const readJob = useCallback((id: string) => port.jobOutput(id), [port]);
const readProcesses = useCallback(() => port.processes(), [port]);
const refreshStatus = useStatusRefresh(port, applyStatus, statusRead);
const [wallet, refreshWallet] = useWallet(port, accountOf(status?.modelRef));
@@ -672,6 +673,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
onCancelJob={(id) => cancelJob(port, id, notify, refreshStatus).then(() => {})}
onReadJob={readJob}
onReadProcesses={readProcesses}
onOpenRun={openRun} onStopCall={(id) => void port.stopToolCall(id).catch(notify)}
onClearJobs={(ids) => setClearedJobs((was) => new Set([...was, ...ids]))}
onOpenJob={jobsLive.open}
@@ -682,6 +684,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
both are dismissed one at a time, so nothing else bounds how many can
be on screen at once. */}
<div className="composenotes">
{failure}
{s.error && (
<ErrorBar text={s.error} onDismiss={() => dispatch({ kind: "__error", text: "" } as never)} onReport={onFeedback} />
)}
@@ -43,7 +43,7 @@ describe("messages between sessions", () => {
await userEvent.type(box, "迁移完成");
await userEvent.click(button);
expect(send).toHaveBeenCalledWith("s-2", "迁移完成");
expect(await screen.findByText(/对方的用户批准后才会读到/)).toBeTruthy();
expect(await screen.findByText(/对方的模型会直接读到/)).toBeTruthy();
expect((box as HTMLTextAreaElement).value).toBe("");
});
@@ -47,6 +47,13 @@ export function PeerSettings({ port, settings, onSaved }: { port: AgentPort; set
</span>
<Switch data-action="peers.dispatch" on={state.dispatch} busy={off || !state.enabled} label={t("remote.let_the_model_hand_a")} onClick={() => void save({ dispatch: !state.dispatch })} />
</div>
<div className="lrow">
<span className="tx">
<span className="lb">{t("remote.open_nested_folders_without_asking")}</span>
<span className="ds">{t("remote.open_nested_folders_without_asking_ds")}</span>
</span>
<Switch data-action="peers.open-nested" on={state.open_nested} busy={off || !state.enabled || !state.dispatch} label={t("remote.open_nested_folders_without_asking")} onClick={() => void save({ open_nested: !state.open_nested })} />
</div>
{!state.available && <div className="peernote" role="status">{t("remote.not_supported_in_this_session")}</div>}
{state.pending && <div className="peernote" role="status">{t("remote.saved_takes_effect_from_the")}</div>}
<div className="peernote">{t("remote.these_switches_are_kept_in")}<code>{state.path}</code></div>
@@ -0,0 +1,60 @@
import { t } from "../i18n";
import type { ProcessEntry } from "../port/process";
import { jobElapsed } from "./panels/jobElapsed";
function stateLabel(state: string): string {
switch (state) {
case "pending": return t("subagent.process.state.pending");
case "queued": return t("subagent.process.state.queued");
case "running": return t("subagent.process.state.running");
case "waiting": return t("subagent.process.state.waiting");
case "completed": return t("subagent.process.state.completed");
case "failed": return t("subagent.process.state.failed");
case "stopped": return t("subagent.process.state.stopped");
case "interrupted": return t("subagent.process.state.interrupted");
case "skipped": return t("subagent.process.state.skipped");
default: return state;
}
}
function causeLabel(cause: string): string {
switch (cause) {
case "slots": return t("subagent.process.cause.slots");
case "writers": return t("subagent.process.cause.writers");
case "claim": return t("subagent.process.cause.claim");
case "held": return t("subagent.process.cause.held");
case "adopted": return t("subagent.process.cause.adopted");
case "budget": return t("subagent.process.cause.budget");
case "parked": return t("subagent.process.cause.parked");
default: return cause;
}
}
function kindLabel(kind: string): string {
switch (kind) {
case "subagent": return t("subagent.process.kind.subagent");
case "job": return t("subagent.process.kind.job");
default: return kind;
}
}
/** What this session has running now, one row per process. State and cause are
* the kernel's words for it; a value this page has no wording for is shown as
* it came rather than guessed at. */
export function ProcessDeck({ rows, error }: { rows: ProcessEntry[]; error: string }) {
if (error) return <div className="empty" role="alert">{error}</div>;
if (rows.length === 0) return <div className="empty">{t("subagent.process.empty")}</div>;
return (
<div className="job-deck">
{rows.map((p) => (
<div className="job-deck-row" key={p.id} data-status={p.state}>
<div className="job-deck-line">
<b className="job-deck-name">{p.name}</b>
<span className="job-deck-time">{[kindLabel(p.kind), stateLabel(p.state), jobElapsed(Date.now() - p.startedAt)].join(" · ")}</span>
</div>
{p.cause && <span className="job-deck-tail">{causeLabel(p.cause)}</span>}
</div>
))}
</div>
);
}
+14 -9
View File
@@ -1,3 +1,4 @@
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { SidebarTools } from "./SidebarTools";
import { FeedbackBadge, feedbackEntryTab } from "./feedbackentry";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -216,10 +217,12 @@ export function Sidebar({
run: () => onSettings("providers") },
{ id: "usage", label: t("session.wallet_and_usage"), icon: "wallet", keywords: "usage cost token 用量 费用",
run: () => onSettings("spend") },
{ id: "feedback", label: t("common.send_feedback"), icon: "feedback", keywords: "feedback report bug idea suggestion 反馈 报告 问题 建议 意见",
run: () => onFeedback("send") },
{ id: "feedback-mine", label: t("session.my_feedback"), icon: "feedback", keywords: "feedback receipt status my reply 反馈 回执 进展 我的 回复",
status: feedbackUnread > 0 ? t("common.n_to_review", { n: feedbackUnread }) : undefined, run: () => onFeedback("mine") },
...(FEEDBACK_RETIRED ? [] : [
{ id: "feedback", label: t("common.send_feedback"), icon: "feedback" as const, keywords: "feedback report bug idea suggestion 反馈 报告 问题 建议 意见",
run: () => onFeedback("send") },
{ id: "feedback-mine", label: t("session.my_feedback"), icon: "feedback" as const, keywords: "feedback receipt status my reply 反馈 回执 进展 我的 回复",
status: feedbackUnread > 0 ? t("common.n_to_review", { n: feedbackUnread }) : undefined, run: () => onFeedback("mine") },
]),
{ id: "community", label: t("common.community_and_contributors"), icon: "globe", keywords: "community discord qq group contributors 社区 交流群 加群 贡献者",
run: () => onSettings("versions:community") },
{ id: "settings", label: t("common.settings"), icon: "settings", keywords: "settings preferences 设置 偏好",
@@ -382,11 +385,13 @@ export function Sidebar({
{!navShown && <div className="railfoot">
<UpdateNotice onOpen={() => onSettings("versions")} />
<SidebarTools wallet={wallet} approval={approval} online={online} onSettings={onSettings} onFiles={onFiles} />
<button className="studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackEntryTab(feedbackUnread))}>
<span aria-hidden="true"><StudioIcon name="feedback" /></span>
<b>{t("common.send_feedback")}</b>
<FeedbackBadge unread={feedbackUnread} />
</button>
{!FEEDBACK_RETIRED && (
<button className="studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackEntryTab(feedbackUnread))}>
<span aria-hidden="true"><StudioIcon name="feedback" /></span>
<b>{t("common.send_feedback")}</b>
<FeedbackBadge unread={feedbackUnread} />
</button>
)}
</div>}
</div>
</>
@@ -1,5 +1,8 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
// These tests describe the feedback channel while it exists; the shipped build has it retired.
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { clearToasts, pushNote, pushOffer, pushToast } from "../state/toasts";
import { ToastStack } from "./ToastStack";
+14 -11
View File
@@ -1,3 +1,4 @@
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { useLayoutEffect, useState } from "react";
import { t } from "../i18n";
import type { AgentPort, Appearance } from "../port/port";
@@ -84,17 +85,19 @@ export function ToastStack({
key={toast.id}
actions={
<>
<button
className="act ghost"
data-action="toast.report"
onClick={() => {
reportProblem(toast.text);
onFeedback();
dismissToast(toast.id);
}}
>
{t("app.report_this_problem")}
</button>
{!FEEDBACK_RETIRED && (
<button
className="act ghost"
data-action="toast.report"
onClick={() => {
reportProblem(toast.text);
onFeedback();
dismissToast(toast.id);
}}
>
{t("app.report_this_problem")}
</button>
)}
<button
className="act ghost"
data-action="toast.dismiss"
@@ -45,14 +45,14 @@ export function WorkbenchGit({ port, workspaceRoot, changes, repos, failure = ""
{failure}
{onRetry && <button type="button" className="btn sm" data-action="workbench.repos-reload" onClick={onRetry}>{t("common.read_again")}</button>}
</p>
<RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} />
<RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} wrote={wrote} />
</>
);
}
if (repos.length === 0) return null;
const several = repos.length > 1 || repos[0].dir !== "";
if (!several) {
return <RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} />;
return <RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} wrote={wrote} />;
}
return (
<div className="workbench-git" data-repos={repos.length}>
@@ -75,14 +75,14 @@ function Folder({ port, workspaceRoot, row, wrote, current, onOpen, remote }: {
<span>{row.branch}</span>
<small>{changes.length}</small>
</button>
{open && <RepoSection port={port} workspaceRoot={workspaceRoot} repo={row.dir} changes={changes} current={current} onOpen={onOpen} onChanged={reload} remote={remote} />}
{open && <RepoSection port={port} workspaceRoot={workspaceRoot} repo={row.dir} changes={changes} current={current} onOpen={onOpen} onChanged={reload} remote={remote} wrote={wrote} />}
</section>
);
}
function RepoSection({ port, workspaceRoot, repo, changes, current, onOpen, onChanged, remote, onOpenPage, branch }: {
function RepoSection({ port, workspaceRoot, repo, changes, current, onOpen, onChanged, remote, onOpenPage, branch, wrote }: {
port: AgentPort; workspaceRoot?: string; repo: string; changes: WorkspaceChange[]; current: string; onOpen: (path: string) => void; onChanged?: () => void; remote: boolean;
onOpenPage?: () => void; branch?: string;
onOpenPage?: () => void; branch?: string; wrote: number;
}) {
const refresh = () => onChanged?.();
const commit = useCommitCard(port, refresh, repo || undefined);
@@ -103,7 +103,7 @@ function RepoSection({ port, workspaceRoot, repo, changes, current, onOpen, onCh
below={remote ? null : push.card}
onOpenPage={onOpenPage}
/>
{!remote && <WorkbenchHistory port={port} repo={repo || undefined} branch={branch} />}
{!remote && <WorkbenchHistory port={port} repo={repo || undefined} branch={branch} stamp={`${wrote}:${changes.length}`} />}
</>
);
}
@@ -25,26 +25,37 @@ const x = (lane: number) => PAD + lane * COL;
type Read = { k: "idle" } | { k: "loading" } | { k: "ready"; history: GitHistory } | { k: "failed"; why: string };
// The commit graph beside the changes: what the project's history looks like,
// read from git on request and never changed from here. It reads when opened and
// again on demand, so a closed section costs nothing.
export function WorkbenchHistory({ port, repo, branch }: { port: AgentPort; repo?: string; branch?: string }) {
// read from git and never changed from here. It reads when opened and again when
// stamp moves (the agent wrote, the changes or the branch moved), so a closed
// section costs nothing and an open one does not go on showing a past tip.
export function WorkbenchHistory({ port, repo, branch, stamp = "" }: { port: AgentPort; repo?: string; branch?: string; stamp?: string }) {
const [openId, setOpenId] = useState("");
const [shown, setShown] = useState(true);
const [read, setRead] = useState<Read>({ k: "idle" });
const latest = useRef(0);
const load = useCallback(async () => {
const seen = useRef<string | null>(null);
const load = useCallback(async (quiet = false) => {
const id = ++latest.current;
setRead({ k: "loading" });
if (!quiet) setRead({ k: "loading" });
try {
const history = await port.workspaceGitLog(100, repo);
if (id === latest.current) setRead({ k: "ready", history });
} catch (e) {
if (id === latest.current) setRead({ k: "failed", why: reason(e) });
if (id === latest.current && !quiet) setRead({ k: "failed", why: reason(e) });
}
}, [port, repo]);
const key = `${branch ?? ""}
${stamp}`;
useEffect(() => {
if (shown && read.k === "idle") void load();
}, [shown, read.k, load]);
if (!shown) return;
if (read.k === "idle") {
seen.current = key;
void load();
} else if (seen.current !== key) {
seen.current = key;
void load(true);
}
}, [shown, read.k, load, key]);
useEffect(() => () => { latest.current++; }, []);
const history = read.k === "ready" ? read.history : null;
@@ -405,12 +405,9 @@ describe("WorkbenchPanel", () => {
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
// A file opened from the explorer steps the list aside rather than closing it,
// so closing that file comes back to the list. Folding the column instead
// leaves someone who opened one file with no workbench at all. With no tabs
// the launcher draws a "文件" card as well as the toolbar toggle, so the
// The launcher draws a "文件" card as well as the toolbar toggle, so the
// toolbar button is named by its action rather than its label.
it("returns to the explorer when a file opened from it is closed", async () => {
it("keeps the explorer beside a file opened from it, and after that file is closed", async () => {
const user = userEvent.setup();
const onCloseManual = vi.fn();
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={onCloseManual} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
@@ -420,18 +417,14 @@ describe("WorkbenchPanel", () => {
await user.click(await screen.findByRole("button", { name: "README.md" }));
expect(screen.getByRole("tab", { name: "README.md" })).toBeTruthy();
// The list stepped aside for the file.
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await user.click(screen.getByRole("button", { name: "关闭 README.md" }));
expect(onCloseManual).not.toHaveBeenCalled();
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
// The list was stepped aside for that file, not closed on behalf of the one
// behind it: closing the file leaves the other tab selected and alone, so the
// list must stay out of its way and the column must not fold.
it("does not bring the explorer back over a tab that is still open", async () => {
it("leaves the explorer as it was when the file picked from it is closed over another tab", async () => {
const user = userEvent.setup();
const onCloseManual = vi.fn();
const page = { id: "b1", target: "t1", url: "https://example.com", title: "My page", active: false };
@@ -439,17 +432,14 @@ describe("WorkbenchPanel", () => {
await user.click(container.querySelector<HTMLElement>('[data-action="workbench.files"]')!);
await user.click(await screen.findByRole("button", { name: "README.md" }));
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await user.click(screen.getByRole("button", { name: "关闭 README.md" }));
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
expect(onCloseManual).not.toHaveBeenCalled();
expect(screen.getByRole("tab", { name: "My page" }).getAttribute("aria-selected")).toBe("true");
});
// Closing every browser tab at once while a file from the explorer is still
// open leaves that file behind, so the column stays and the list stays away.
// The batch goes through the same rule as a single close, which is the point.
it("keeps the column when every browser tab is closed at once beside an explorer file", async () => {
const user = userEvent.setup();
const onCloseManual = vi.fn();
@@ -470,7 +460,7 @@ describe("WorkbenchPanel", () => {
expect(screen.queryByRole("tab", { name: "Agent page" })).toBeNull();
expect(screen.getByRole("tab", { name: "README.md" })).toBeTruthy();
expect(onCloseManual).not.toHaveBeenCalled();
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
it("shows a page the person opened beside the agent's, while the agent's tab stays active", () => {
@@ -518,13 +508,13 @@ describe("WorkbenchPanel", () => {
localStorage.removeItem("rx-show-hidden-files");
});
it("shows the file that was picked from the list rather than leaving the list over it", async () => {
it("shows the file picked from the list and keeps the list beside it", async () => {
const user = userEvent.setup();
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
await user.click(screen.getAllByRole("button", { name: "文件" })[0]);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await user.click(await screen.findByRole("button", { name: "README.md" }));
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe("true");
});
+24 -26
View File
@@ -55,6 +55,7 @@ const DocumentPreview = lazy(() => import("./document/DocumentPreview"));
const CodeEditor = lazy(() => import("./CodeEditor"));
import { treeRows } from "./workbench_tree";
import { ExplorerResize } from "./ExplorerResize";
import { AgentRunView } from "./AgentRunView";
import { JobRunView } from "./JobRunView";
import { useAgentTabs, type AgentRequest } from "./useAgentTabs";
@@ -137,9 +138,6 @@ export function WorkbenchPanel({
[directories, setDirectories] = useState<string[]>([]),
[openFiles, setOpenFiles] = useState<string[]>([]);
const [showFiles, setShowFiles] = useState(false);
// The file picked out of the explorer, if that is how it was opened: closing
// it comes back to the list it was stepped aside from.
const fromExplorer = useRef("");
const [launcher, setLauncher] = useState(false);
const newTab = useRef<HTMLButtonElement>(null);
const toggleLauncher = () => { supersede(); setLauncher((open) => !open); };
@@ -160,10 +158,10 @@ export function WorkbenchPanel({
useOnBump(toggleFiles, toggleExplorer);
const [query, setQuery] = useState(""),
[selected, setSelected] = useState("");
// Docked, the file list hides the canvas a page is drawn in, so choosing any
// surface steps the list aside.
// The list and the canvas sit side by side, so a file stays beside its tree;
// any other surface takes the whole column and steps the list aside.
const select = useCallback((key: string) => {
setShowFiles(false);
if (!key.startsWith("file:")) setShowFiles(false);
setLauncher(false);
setFailed("");
setSelected(key);
@@ -333,11 +331,11 @@ export function WorkbenchPanel({
// source view is one click away.
const filePath = active?.kind === "file" ? active.path : "";
useEffect(() => {
onActiveFile?.(shown && !launcher && !showFiles && filePath ? filePath : undefined);
onActiveFile?.(shown && !launcher && filePath ? filePath : undefined);
return () => onActiveFile?.(undefined);
}, [filePath, shown, launcher, showFiles, onActiveFile]);
}, [filePath, shown, launcher, onActiveFile]);
const textMenu = useWorkbenchTextMenu({
path: filePath, mode, scope, shown: shown && !launcher && !showFiles,
path: filePath, mode, scope, shown: shown && !launcher,
onQuote: onQuoteSelection, onError: setFailed,
});
const grantedFile = parseGrantedFile(filePath);
@@ -359,7 +357,7 @@ export function WorkbenchPanel({
const unpreviewableText = !hasDocument && errorCode === "workspace.file_unreadable";
const revision = file?.revision;
useEffect(() => {
if (mode !== "preview" || !previewable || !shown || showFiles || launcher || previewTab || (!revision && !fileFailed && !failed)) return;
if (mode !== "preview" || !previewable || !shown || launcher || previewTab || (!revision && !fileFailed && !failed)) return;
if (missing) {
setMode("file");
return;
@@ -368,7 +366,7 @@ export function WorkbenchPanel({
setFailed(reason(e));
setMode("file");
});
}, [mode, previewable, shown, showFiles, launcher, previewTab, revision, fileFailed, missing, failed, filePath, openPreview, tabs]);
}, [mode, previewable, shown, launcher, previewTab, revision, fileFailed, missing, failed, filePath, openPreview, tabs]);
useEffect(() => {
if (mode === "preview" && revision) syncPreview(filePath, revision);
}, [mode, filePath, revision, syncPreview]);
@@ -391,8 +389,7 @@ export function WorkbenchPanel({
useEffect(() => {
strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}, [activeKey, surfaces.length]);
// Picking a file is asking to read it. Docked, the list and the file share one
// column, so the list steps aside; side by side it stays where it is.
// Picking a file is asking to read it; the list stays where it is.
const [recentFiles, setRecentFiles] = useState(() => readRecentFiles(workspaceRoot ?? ""));
const openFile = useCallback((path: string) => {
setRecentFiles(rememberFile(workspaceRoot ?? "", path));
@@ -402,11 +399,8 @@ export function WorkbenchPanel({
n.delete(`file:${path}`);
return n;
});
// A file picked while the explorer is shown leaves that list stepped aside
// rather than closed, so closing this file comes back to the list.
fromExplorer.current = showFiles ? path : "";
pick(`file:${path}`);
}, [pick, showFiles, workspaceRoot]);
}, [pick, workspaceRoot]);
useEffect(() => { if (fileRequest) { openFile(fileRequest.path); fileRequest.consumed?.(); } }, [fileRequest, openFile]);
// A rendered document's own links open beside it and its images load from the
// tree. Held stable per document, so typing re-renders the text alone.
@@ -459,7 +453,7 @@ export function WorkbenchPanel({
if (openingBrowser.current === page) openingBrowser.current = null;
}
};
const { close: closeTabs, closing, closeFailure } = useTabClose({ port, surfaces, selected, showFiles: showFiles || launcher, fromExplorer, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, setShowFiles, setLauncher, onCloseManual, onCloseSelected: () => setFailed("") });
const { close: closeTabs, closing, closeFailure } = useTabClose({ port, surfaces, selected, showFiles: showFiles || launcher, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, setLauncher, onCloseManual, onCloseSelected: () => setFailed("") });
const close = (targets: Surface[]) => { supersede(); return terminals.guard(targets, closeTabs); };
const dismissTabMenu = useCallback(() => setTabMenu(null), []);
const [tabMenu, setTabMenu] = useState<{ surface: Surface; x: number; y: number; anchor: HTMLElement } | null>(null);
@@ -617,7 +611,10 @@ export function WorkbenchPanel({
{failed}
</div>
)}
{(launcher || !active) && (
{!launcher && !active && showFiles && (
<WorkbenchState icon="file" title={t("workbench.pick_a_file")} detail={t("workbench.pick_a_file_detail")} />
)}
{(launcher || (!active && !showFiles)) && (
<WorkbenchLauncher
key={String(launcher)} focus={launcher}
changes={changes.length}
@@ -628,22 +625,22 @@ export function WorkbenchPanel({
)}
<div className="workbench-content" ref={canvas} hidden={launcher || !active} data-action-contextmenu="workbench.quote-selection" {...textMenu.readerProps}>
<ManualBrowsers ids={browsers} active={active?.kind === "manual" ? active.id : ""} onAddress={noteHost} onExternal={onExternal} scheme={scheme} />
<WorkbenchTerminals port={port} hook={terminals} active={active?.kind === "terminal" ? active.id : ""} shown={shown && !launcher && !showFiles}
<WorkbenchTerminals port={port} hook={terminals} active={active?.kind === "terminal" ? active.id : ""} shown={shown && !launcher}
scheme={scheme} onQuote={onQuoteSelection} onClose={(targets) => void close(targets)} />
{active?.kind === "browser" && (
<AgentBrowserPanel
tabs={[active.tab]}
shown={shown && !launcher}
showTabs={false}
showTabs={false} editPage={preview.edit}
onOpenDocument={(path) => port.openDocument(path)}
/>
)}
{active?.kind === "changes" && (
<WorkbenchChangesPage port={port} changes={changes} branch={gitRepos.repos.find((r) => r.dir === "")?.branch} shown={shown && !launcher && !showFiles}
<WorkbenchChangesPage port={port} changes={changes} branch={gitRepos.repos.find((r) => r.dir === "")?.branch} shown={shown && !launcher}
remote={remote} onOpen={(path) => { openFile(path); setMode("diff"); }} onChanged={onTreeChanged} />
)}
{active?.kind === "job" && <JobRunView key={active.id} port={port} id={active.id} name={active.name} shown={shown && !launcher && !showFiles} />}
{active?.kind === "agent" && <AgentRunView key={active.ref} port={port} run={active} shown={shown && !launcher && !showFiles} onShowCall={onShowCall} />}
{active?.kind === "job" && <JobRunView key={active.id} port={port} id={active.id} name={active.name} shown={shown && !launcher} />}
{active?.kind === "agent" && <AgentRunView key={active.ref} port={port} run={active} shown={shown && !launcher} onShowCall={onShowCall} />}
{active?.kind === "file" && (
<>
<WorkbenchFileBar
@@ -673,7 +670,7 @@ export function WorkbenchPanel({
)}
{mode === "document" || unpreviewableText ? (
<Suspense fallback={<div className="workbench-empty">{t("common.loading")}</div>}>
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher}
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !launcher}
view={documentState(documentStates, filePath)}
refresh={JSON.stringify([changeKey, glance, running, wrote])}
onOpenExternal={!remote && port.opensFiles() && /\.(pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(filePath) ? () => {
@@ -684,7 +681,7 @@ export function WorkbenchPanel({
</Suspense>
) : mode === "preview" ? (
previewTab ? (
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} />
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} editPage={preview.edit} />
) : (
<div className="workbench-empty">{t("common.loading")}</div>
)
@@ -720,6 +717,7 @@ export function WorkbenchPanel({
</div>
</main>
<aside className="workbench-explorer">
<ExplorerResize />
<WorkbenchExplorerHead
port={port}
count={files.length + directories.length}
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import "./testkit";
import { host, type BrowserControl, type BrowserPick, type BrowserPickLabels } from "../port/host";
import type { BrowserTab } from "../port/port";
import { AgentBrowserPanel } from "./BrowserPanel";
import type { PageEditor } from "./pageedit";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const file: BrowserTab = { id: "b1", target: "v1", title: "Demo", url: "file:///work/site/a.html", active: true };
const web: BrowserTab = { id: "b2", target: "v2", title: "Web", url: "https://example.com/", active: false };
const picked: BrowserPick = {
cancelled: false,
element: { selector: "#note", tagName: "p", text: "hello", style: { color: "#111111", backgroundColor: "#FFFFFF", fontSize: "16px" } },
};
let pick: Mock<(target: string, labels: BrowserPickLabels) => Promise<BrowserPick>>;
let cancel: Mock<(target: string) => void>;
let control: Mock<(target: string, action: BrowserControl) => void>;
beforeEach(() => {
const shell = host();
vi.spyOn(shell, "drawsBrowserViews").mockReturnValue(true);
vi.spyOn(shell, "picksBrowserElements").mockReturnValue(true);
vi.spyOn(shell, "showBrowserView").mockImplementation(() => {});
vi.spyOn(shell, "hideBrowserView").mockImplementation(() => {});
pick = vi.fn(async () => picked);
cancel = vi.fn();
control = vi.fn();
vi.spyOn(shell, "pickBrowserElement").mockImplementation(pick);
vi.spyOn(shell, "cancelBrowserPick").mockImplementation(cancel);
vi.spyOn(shell, "controlBrowserView").mockImplementation(control);
});
const pickButton = () => screen.getByRole("button", { name: "点选页面元素" });
describe("editing a picked element of a local page", () => {
it("offers the picker only where the shell can pick", () => {
vi.spyOn(host(), "picksBrowserElements").mockReturnValue(false);
render(<AgentBrowserPanel tabs={[file]} shown />);
expect(screen.queryByRole("button", { name: "点选页面元素" })).toBeNull();
});
it("saves the changed fields only, and reloads the page that was edited", async () => {
const editor: PageEditor = vi.fn(async () => ({ saved: true as const, path: "a.html" }));
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
expect(pick).toHaveBeenCalledWith("v1", expect.objectContaining({ color: expect.any(String) }));
const form = await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
fireEvent.change(screen.getByLabelText("文字颜色"), { target: { value: "#ff0000" } });
fireEvent.submit(form);
await waitFor(() => expect(editor).toHaveBeenCalledTimes(1));
expect(editor).toHaveBeenCalledWith("/work/site/a.html", { selector: "#note", tagName: "p" }, [
{ kind: "text", before: "hello", after: "bye" },
{ kind: "style", property: "color", value: "#ff0000" },
]);
expect(await screen.findByText("已保存到 a.html")).toBeTruthy();
expect(control).toHaveBeenCalledWith("v1", "reload");
});
it("measures a second save from what the first one wrote", async () => {
const editor: PageEditor = vi.fn(async () => ({ saved: true as const, path: "a.html" }));
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
const form = await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
fireEvent.submit(form);
await screen.findByText("已保存到 a.html");
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "again" } });
fireEvent.submit(form);
await waitFor(() => expect(editor).toHaveBeenCalledTimes(2));
expect((editor as ReturnType<typeof vi.fn>).mock.calls[1][2]).toEqual([{ kind: "text", before: "bye", after: "again" }]);
});
it("says why a save was refused and leaves the page alone", async () => {
const editor: PageEditor = vi.fn(async () => ({ saved: false as const, code: "mismatch" as const }));
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
const form = await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
fireEvent.submit(form);
expect((await screen.findByRole("alert")).textContent).toBe("文件里的文字和页面上显示的不一致,未做修改");
expect(control).not.toHaveBeenCalled();
});
it("does not call the editor when nothing was changed", async () => {
const editor: PageEditor = vi.fn();
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
fireEvent.submit(await screen.findByRole("form", { name: "修改所选元素" }));
expect((await screen.findByRole("alert")).textContent).toBe("没有改动");
expect(editor).not.toHaveBeenCalled();
});
it("shows a page that is not a local file but will not save to it", async () => {
const editor: PageEditor = vi.fn();
render(<AgentBrowserPanel tabs={[web]} shown editPage={editor} />);
fireEvent.click(pickButton());
await screen.findByRole("form", { name: "修改所选元素" });
expect(screen.getByText("只有本地网页文件可以在这里修改并保存")).toBeTruthy();
expect((screen.getByRole("button", { name: "保存到文件" }) as HTMLButtonElement).disabled).toBe(true);
});
it("reports why a pick ended without an element, and nothing for Escape", async () => {
pick.mockResolvedValueOnce({ cancelled: true, reason: "navigation" });
render(<AgentBrowserPanel tabs={[file]} shown />);
fireEvent.click(pickButton());
expect(await screen.findByText("页面已跳转或重新加载,点选已取消")).toBeTruthy();
pick.mockResolvedValueOnce({ cancelled: true, reason: "escape" });
fireEvent.click(pickButton());
await waitFor(() => expect(screen.queryByText("页面已跳转或重新加载,点选已取消")).toBeNull());
expect(screen.queryByRole("status")).toBeNull();
});
it("puts the picker away when the button is pressed again", async () => {
let end: (answer: BrowserPick) => void = () => {};
pick.mockImplementationOnce(() => new Promise<BrowserPick>((done) => { end = done; }));
render(<AgentBrowserPanel tabs={[file]} shown />);
fireEvent.click(pickButton());
expect(await screen.findByText("在页面上点一个元素,按 Esc 取消")).toBeTruthy();
fireEvent.click(pickButton());
expect(cancel).toHaveBeenCalledWith("v1");
await act(async () => end({ cancelled: true, reason: "dismissed" }));
expect(screen.queryByText("在页面上点一个元素,按 Esc 取消")).toBeNull();
});
it("drops the picked element when another page is shown", async () => {
render(<AgentBrowserPanel tabs={[file, web]} shown />);
fireEvent.click(pickButton());
await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.click(screen.getByRole("tab", { name: "Web" }));
await waitFor(() => expect(screen.queryByRole("form", { name: "修改所选元素" })).toBeNull());
});
});
@@ -19,8 +19,10 @@ it("reports actual tab selection and clears it when the canvas is hidden or its
fireEvent.click(screen.getAllByRole("button", { name: "one.md" })[0]);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getByRole("button", { name: "文件" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getByRole("button", { name: "新建标签" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith(undefined));
fireEvent.click(screen.getByRole("button", { name: "文件" }));
fireEvent.click(screen.getByRole("button", { name: "新建标签" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getAllByRole("button", { name: "关闭 one.md" })[0]);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("other/one.md"));
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ExplorerResize } from "./ExplorerResize";
afterEach(() => { cleanup(); localStorage.clear(); });
function mount() {
const view = render(
<div className="workbench-body">
<aside className="workbench-explorer">
<ExplorerResize />
</aside>
</div>,
);
const body = view.container.querySelector<HTMLElement>(".workbench-body")!;
const aside = view.container.querySelector<HTMLElement>(".workbench-explorer")!;
aside.getBoundingClientRect = () => ({ width: Number.parseFloat(body.style.getPropertyValue("--wb-tree-w")) || 220 }) as DOMRect;
return { body, bar: screen.getByRole("separator", { name: "调整文件树宽度" }) };
}
describe("the file tree's width handle", () => {
it("leaves the stylesheet's width alone until it is used", () => {
const { body } = mount();
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
});
it("widens with the arrow key, remembers it, and clamps at the ends", () => {
const { body, bar } = mount();
fireEvent.keyDown(bar, { key: "ArrowRight", shiftKey: true });
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("268px");
expect(localStorage.getItem("rx-wb-tree-w")).toBe("268");
for (let i = 0; i < 40; i++) fireEvent.keyDown(bar, { key: "ArrowRight", shiftKey: true });
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("560px");
for (let i = 0; i < 40; i++) fireEvent.keyDown(bar, { key: "ArrowLeft", shiftKey: true });
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("150px");
});
it("restores the remembered width on the next mount", () => {
localStorage.setItem("rx-wb-tree-w", "333");
expect(mount().body.style.getPropertyValue("--wb-tree-w")).toBe("333px");
});
it("returns to the default on Home and on a double click", () => {
const { body, bar } = mount();
fireEvent.keyDown(bar, { key: "ArrowRight" });
fireEvent.keyDown(bar, { key: "Home" });
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
expect(localStorage.getItem("rx-wb-tree-w")).toBeNull();
fireEvent.keyDown(bar, { key: "ArrowRight" });
fireEvent.doubleClick(bar);
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
});
it("follows a pointer drag and keeps the width where it was released", () => {
const { body, bar } = mount();
bar.setPointerCapture = () => {};
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 220 });
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 300 });
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("300px");
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 300 });
expect(localStorage.getItem("rx-wb-tree-w")).toBe("300");
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 500 });
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("300px");
});
it("ignores a stored width that is not a number", () => {
localStorage.setItem("rx-wb-tree-w", "wide");
expect(mount().body.style.getPropertyValue("--wb-tree-w")).toBe("");
});
});
@@ -1,5 +1,8 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
// These tests describe the feedback channel while it exists; the shipped build has it retired.
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
@@ -1,5 +1,8 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
// These tests describe the feedback channel while it exists; the shipped build has it retired.
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { Nav } from "./Nav";
import { ErrorBar } from "./ErrorBar";
afterEach(cleanup);
describe("the retired feedback channel", () => {
it("is retired in the shipped build", () => {
expect(FEEDBACK_RETIRED).toBe(true);
});
it("leaves no feedback entry on the nav rail", () => {
render(<Nav at="" onGo={vi.fn()} onFiles={vi.fn()} onHome={vi.fn()} onRail={vi.fn()} onFeedback={vi.fn()} onNew={vi.fn()} feedbackUnread={3} shown />);
expect(document.querySelector('[data-action="feedback.open"]')).toBeNull();
});
it("offers no way to report an error from its bar", () => {
render(<ErrorBar text="boom" onDismiss={vi.fn()} />);
expect(screen.queryByRole("button", { name: /报告|report/i })).toBeNull();
});
});
@@ -68,6 +68,8 @@ it.each(["rail", "sidebar", "palette"])("opens only the focused pane's files fro
expect(filesShown(background)).toBe(false);
expect(focused().querySelectorAll(".workbench-tab")).toHaveLength(0);
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
expect(filesShown()).toBe(true);
await openFiles();
expect(filesShown()).toBe(false);
await openFiles();
expect(filesShown()).toBe(true);
@@ -111,14 +113,15 @@ it("does not replay a Files request into a newly focused session", async () => {
await palette("文件");
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
const previous = focused();
const kept = filesShown(previous);
fireEvent.click(document.querySelector('.ptab[data-pane="r1"]')!);
expect(focused()).not.toBe(previous);
expect(filesShown()).toBe(false);
await palette("文件");
expect(filesShown()).toBe(true);
expect(filesShown(previous)).toBe(false);
expect(filesShown(previous)).toBe(kept);
fireEvent.click(document.querySelector('.ptab[data-pane="r2"]')!);
expect(filesShown()).toBe(false);
expect(filesShown()).toBe(kept);
});
const railFiles = () => within(document.querySelector<HTMLElement>(".nav")!).getByRole("button", { name: "文件" });
@@ -101,7 +101,7 @@ it("keeps a superseded successful user tab owned while a separate preview claim
});
describe("opening an HTML file from the explorer", () => {
it.each(["Files", "launcher"])("does not start a hidden preview after a late file read and newer %s choice", async (choice) => {
it.each(["Files", "launcher"])("starts a preview only while its file is in view after a late read and newer %s choice", async (choice) => {
const user = userEvent.setup();
const { port, open } = setup();
let read!: (file: Awaited<ReturnType<MockPort["workspaceFile"]>>) => void;
@@ -113,11 +113,14 @@ describe("opening an HTML file from the explorer", () => {
if (choice === "Files") await user.click(container.querySelector('[data-action="workbench.files"]')!);
else await user.click(screen.getByRole("button", { name: "新建标签" }));
await act(async () => read({ path: "index.html", content: "<h1>Late</h1>", revision: "r1" }));
if (choice === "Files") {
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
return;
}
expect(open).not.toHaveBeenCalled();
if (choice === "Files") expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
else expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
if (choice === "Files") await user.click(screen.getByRole("tab", { name: "index.html" }).querySelector(".workbench-tab-pick")!);
else await user.keyboard("{Escape}");
expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
await user.keyboard("{Escape}");
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
});
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { patchSource } from "./htmlsource";
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Demo</title>
<style>h1 > b { color: red }</style>
</head>
<body>
<!-- <h1 id="ghost">not an element</h1> -->
<h1 id="title" class="big">Hello <b>world</b></h1>
<p id="note">Plain &amp; simple</p>
<p data-testid="styled" style="color: red; margin: 0">Styled</p>
<ul><li>one<li>two</ul>
<script>if (1 < 2) document.title = "<h1 id='x'>"</script>
</body>
</html>
`;
describe("patchSource", () => {
it("replaces the text of an element and leaves every other byte alone", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "text", before: "Plain & simple", after: "Fish & <chips>" });
expect(out).toEqual({ ok: true, content: PAGE.replace("Plain &amp; simple", "Fish &amp; &lt;chips&gt;") });
});
it("refuses text whose current words are not the ones the person saw", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "text", before: "something else", after: "x" });
expect(out).toEqual({ ok: false, code: "mismatch" });
});
it("refuses text edits on an element that has children of its own", () => {
const out = patchSource(PAGE, { selector: "#title", tagName: "h1" }, { kind: "text", before: "Hello world", after: "Bye" });
expect(out).toEqual({ ok: false, code: "not_plain_text" });
});
it("refuses an element whose end tag is implied", () => {
const out = patchSource(PAGE, { selector: "li:first-child", tagName: "li" }, { kind: "text", before: "one", after: "1" });
expect(out).toEqual({ ok: false, code: "unmappable" });
});
it("adds an inline style where the element has none", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value: "#336699" });
expect(out).toEqual({ ok: true, content: PAGE.replace('<p id="note">', '<p style="color: #336699" id="note">') });
});
it("replaces one declaration of an existing style and keeps the rest", () => {
const out = patchSource(PAGE, { selector: '[data-testid="styled"]', tagName: "p" }, { kind: "style", property: "color", value: "blue" });
expect(out).toEqual({ ok: true, content: PAGE.replace('style="color: red; margin: 0"', 'style="color: blue; margin: 0"') });
});
it("appends a declaration the style did not have, writing camelCase as CSS", () => {
const out = patchSource(PAGE, { selector: '[data-testid="styled"]', tagName: "p" }, { kind: "style", property: "backgroundColor", value: "#fff" });
expect(out).toEqual({ ok: true, content: PAGE.replace('style="color: red; margin: 0"', 'style="color: red; margin: 0; background-color: #fff"') });
});
it("refuses a value that could leave the declaration", () => {
for (const value of ['red"; x="', "red; display: none", "a{b}", "<b>", "x\ny"]) {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value });
expect(out).toEqual({ ok: false, code: "bad_value" });
}
});
it("does not read markup inside comments or scripts as elements", () => {
const out = patchSource(PAGE, { selector: "#ghost", tagName: "h1" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "not_found" });
const next = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(next.ok).toBe(true);
});
it("refuses a selector naming a different tag than the pick reported", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "div" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "not_found" });
});
it("refuses a selector that names more than one element", () => {
const out = patchSource(PAGE, { selector: "p", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "ambiguous" });
});
it("refuses a page the parser rebuilt beyond what the source says", () => {
const table = "<table><tr><td id=c>x</td></tr></table>";
const ok = patchSource(table, { selector: "#c", tagName: "td" }, { kind: "style", property: "color", value: "red" });
expect(ok).toEqual({ ok: true, content: '<table><tr><td style="color: red" id=c>x</td></tr></table>' });
const broken = patchSource("<b><p id=q>y</b>", { selector: "#q", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(broken).toEqual({ ok: false, code: "unmappable" });
});
it("refuses an invalid selector instead of throwing", () => {
const out = patchSource(PAGE, { selector: "###", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "not_found" });
});
});
+206
View File
@@ -0,0 +1,206 @@
/** What a person can change on a picked element of a page that is a file. */
export type SourceEdit =
| { kind: "text"; before: string; after: string }
| { kind: "style"; property: StyleProperty; value: string };
export type StyleProperty = "color" | "backgroundColor" | "fontSize" | "fontWeight";
/** Why a source could not take the edit. Each answer is the file's, not the page's:
* the page may look fine while the file cannot say where an element lives. */
export type PatchFailure = "not_found" | "ambiguous" | "unmappable" | "mismatch" | "not_plain_text" | "bad_value";
export type Patched = { ok: true; content: string } | { ok: false; code: PatchFailure };
export interface Target {
selector: string;
tagName: string;
}
interface Tag {
name: string;
start: number;
end: number;
/** The first end tag or start tag after this one, which closes its text. */
next?: Token;
}
interface Token {
closing: boolean;
name: string;
start: number;
}
const RAW_TEXT = new Set(["script", "style", "textarea", "title"]);
// Elements the parser makes without a tag in the source.
const IMPLIED = new Set(["html", "head", "body", "tbody"]);
const CSS_NAME: Record<StyleProperty, string> = {
color: "color",
backgroundColor: "background-color",
fontSize: "font-size",
fontWeight: "font-weight",
};
// The source's start tags in document order, found without building a tree: a
// comment, a doctype and the body of a raw-text element hold no tags of the
// page's. A scan that cannot tell is reported as null, never guessed at.
function scan(source: string): { starts: Tag[]; tokens: Token[] } | null {
const starts: Tag[] = [];
const tokens: Token[] = [];
let at = 0;
while (at < source.length) {
const open = source.indexOf("<", at);
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0) return null;
at = close + 3;
continue;
}
if (source[open + 1] === "!" || source[open + 1] === "?") {
const close = source.indexOf(">", open);
if (close < 0) return null;
at = close + 1;
continue;
}
const match = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(source.slice(open, open + 64));
if (!match) {
at = open + 1;
continue;
}
const end = tagEnd(source, open + match[0].length);
if (end < 0) return null;
const name = match[2].toLowerCase();
const token: Token = { closing: match[1] === "/", name, start: open };
tokens.push(token);
if (!token.closing) starts.push({ name, start: open, end });
at = end;
if (!token.closing && RAW_TEXT.has(name)) {
const close = new RegExp(`</${name}[\\s/>]`, "i").exec(source.slice(at));
if (!close) return null;
at += close.index;
}
}
for (const tag of starts) {
const index = tokens.findIndex((token) => token.start === tag.start);
tag.next = tokens[index + 1];
}
return { starts, tokens };
}
// The offset just past the ">" that ends a tag, honouring quoted values.
function tagEnd(source: string, from: number): number {
let quote = "";
for (let i = from; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === quote) quote = "";
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === ">") {
return i + 1;
}
}
return -1;
}
// Which source tag made each element of the parsed page. The parser may add
// elements the source never wrote (html, head, body, tbody); anything else it
// added, or dropped, means the source cannot be read off the page by position.
function align(elements: Element[], starts: Tag[]): Map<Element, Tag> | null {
const out = new Map<Element, Tag>();
let i = 0;
for (const el of elements) {
const name = el.localName.toLowerCase();
if (starts[i] && starts[i].name === name) {
out.set(el, starts[i++]);
} else if (!IMPLIED.has(name)) {
return null;
}
}
return i === starts.length ? out : null;
}
const collapse = (text: string) => text.replace(/\s+/g, " ").trim();
const escapeText = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escapeAttr = (text: string) => text.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
// A value that stays inside one declaration. A rejected one is refused rather
// than cleaned: what was typed is not what would be saved.
const SAFE_VALUE = /^[^;{}<>"\r\n\\]{1,80}$/;
function declarations(style: string): [string, string][] {
const out: [string, string][] = [];
let depth = 0;
let quote = "";
let from = 0;
const flush = (to: number) => {
const part = style.slice(from, to);
const colon = part.indexOf(":");
if (colon > 0) out.push([part.slice(0, colon).trim(), part.slice(colon + 1).trim()]);
};
for (let i = 0; i < style.length; i++) {
const c = style[i];
if (quote) {
if (c === quote) quote = "";
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === "(") {
depth++;
} else if (c === ")") {
depth = Math.max(0, depth - 1);
} else if (c === ";" && depth === 0) {
flush(i);
from = i + 1;
}
}
flush(style.length);
return out;
}
function withStyle(el: Element, tag: Tag, source: string, property: StyleProperty, value: string): Patched {
const name = CSS_NAME[property];
const had = declarations(el.getAttribute("style") ?? "");
const index = had.findIndex(([key]) => key.toLowerCase() === name);
const next: [string, string][] = index >= 0 ? had.map((d, i) => (i === index ? [d[0], value] : d)) : [...had, [name, value]];
const written = ` style="${escapeAttr(next.map(([k, v]) => `${k}: ${v}`).join("; "))}"`;
const body = source.slice(tag.start, tag.end);
const attr = /\sstyle\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i.exec(body);
if (attr) {
const from = tag.start + attr.index;
return { ok: true, content: source.slice(0, from) + written + source.slice(from + attr[0].length) };
}
const at = tag.start + 1 + tag.name.length;
return { ok: true, content: source.slice(0, at) + written + source.slice(at) };
}
function withText(el: Element, tag: Tag, source: string, edit: { before: string; after: string }): Patched {
if (el.children.length > 0 || RAW_TEXT.has(tag.name)) return { ok: false, code: "not_plain_text" };
if (collapse(el.textContent ?? "") !== collapse(edit.before)) return { ok: false, code: "mismatch" };
const close = tag.next;
if (!close || !close.closing || close.name !== tag.name) return { ok: false, code: "unmappable" };
return { ok: true, content: source.slice(0, tag.end) + escapeText(edit.after) + source.slice(close.start) };
}
/** Applies one edit to the source of a page. The element the person picked is
* found again in the file, not trusted from the page: the page may have been
* changed by its own scripts since the file was written. */
export function patchSource(source: string, target: Target, edit: SourceEdit): Patched {
if (edit.kind === "style" && !SAFE_VALUE.test(edit.value)) return { ok: false, code: "bad_value" };
const scanned = scan(source);
if (!scanned) return { ok: false, code: "unmappable" };
const doc = new DOMParser().parseFromString(source, "text/html");
let found: Element[];
try {
found = [...doc.querySelectorAll(target.selector)];
} catch {
return { ok: false, code: "not_found" };
}
const el = found[0];
if (!el || el.localName.toLowerCase() !== target.tagName.toLowerCase()) return { ok: false, code: "not_found" };
if (found.length > 1) return { ok: false, code: "ambiguous" };
const tags = align([...doc.querySelectorAll("*")], scanned.starts);
const tag = tags?.get(el);
if (!tag) return { ok: false, code: "unmappable" };
return edit.kind === "text" ? withText(el, tag, source, edit) : withStyle(el, tag, source, edit.property, edit.value);
}
@@ -1,5 +1,8 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
// These tests describe the feedback channel while it exists; the shipped build has it retired.
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { HttpError } from "../port/http_error";
import type { AgentPort } from "../port/port";
import { insideWorkspace, pageEditor } from "./pageedit";
const PAGE = '<!doctype html><html><body><p id="a">hello</p></body></html>';
const target = { selector: "#a", tagName: "p" };
function portWith(over: Partial<Record<"workspaceFile" | "saveWorkspaceFile", unknown>> = {}) {
const save = vi.fn(async (file: { content: string }) => file);
const port = {
workspaces: async () => ({ current: "/work/site" }),
workspaceFile: vi.fn(async (path: string) => ({ path, content: PAGE, revision: "r1" })),
saveWorkspaceFile: save,
...over,
} as unknown as AgentPort;
return { port, save };
}
describe("insideWorkspace", () => {
it("names a file under the workspace by its relative path", () => {
expect(insideWorkspace("/work/site", "/work/site/pages/a.html")).toBe("pages/a.html");
expect(insideWorkspace("C:\\work\\site\\", "c:/Work/Site/a.html")).toBe("a.html");
});
it("answers nothing for a path outside, beside, or escaping the workspace", () => {
expect(insideWorkspace("/work/site", "/work/site2/a.html")).toBe("");
expect(insideWorkspace("/work/site", "/elsewhere/a.html")).toBe("");
expect(insideWorkspace("/work/site", "/work/site/../x/a.html")).toBe("");
expect(insideWorkspace("/work/site", "/work/site")).toBe("");
});
});
describe("pageEditor", () => {
it("writes every edit in one save, against the revision it read", async () => {
const { port, save } = portWith();
const out = await pageEditor(port)("/work/site/a.html", target, [
{ kind: "text", before: "hello", after: "bye" },
{ kind: "style", property: "color", value: "red" },
]);
expect(out).toEqual({ saved: true, path: "a.html" });
expect(save).toHaveBeenCalledTimes(1);
expect(save.mock.calls[0][0]).toEqual({
path: "a.html",
revision: "r1",
content: '<!doctype html><html><body><p style="color: red" id="a">bye</p></body></html>',
});
});
it("saves nothing when any one edit cannot be placed", async () => {
const { port, save } = portWith();
const out = await pageEditor(port)("/work/site/a.html", target, [
{ kind: "style", property: "color", value: "red" },
{ kind: "text", before: "not what is there", after: "x" },
]);
expect(out).toEqual({ saved: false, code: "mismatch" });
expect(save).not.toHaveBeenCalled();
});
it("does not touch a file outside the workspace", async () => {
const { port, save } = portWith();
const out = await pageEditor(port)("/tmp/a.html", target, [{ kind: "style", property: "color", value: "red" }]);
expect(out).toEqual({ saved: false, code: "outside" });
expect(port.workspaceFile).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it("tells a file that changed under the page apart from a write that failed", async () => {
const changed = new HttpError(409, "changed", { code: "workspace.file_changed" }, true);
const a = portWith({ saveWorkspaceFile: vi.fn().mockRejectedValue(changed) });
expect(await pageEditor(a.port)("/work/site/a.html", target, [{ kind: "style", property: "color", value: "red" }])).toEqual({ saved: false, code: "conflict" });
const b = portWith({ saveWorkspaceFile: vi.fn().mockRejectedValue(new Error("disk")) });
expect(await pageEditor(b.port)("/work/site/a.html", target, [{ kind: "style", property: "color", value: "red" }])).toEqual({ saved: false, code: "failed" });
});
});
+47
View File
@@ -0,0 +1,47 @@
import { HttpError } from "../port/http_error";
import type { AgentPort } from "../port/port";
import { patchSource, type PatchFailure, type SourceEdit, type Target } from "./htmlsource";
export type PageEditFailure = PatchFailure | "outside" | "conflict" | "failed";
export type PageEditOutcome = { saved: true; path: string } | { saved: false; code: PageEditFailure };
/** Writes edits made on a page back to the file the page was opened from. */
export type PageEditor = (diskPath: string, target: Target, edits: SourceEdit[]) => Promise<PageEditOutcome>;
const flat = (path: string) => path.replaceAll("\\", "/");
/** The workspace-relative path of a disk path inside the workspace, or "". */
export function insideWorkspace(root: string, disk: string): string {
const base = flat(root).replace(/\/+$/, "");
const path = flat(disk);
const windows = /^[A-Za-z]:/.test(base);
const head = `${base}/`;
const inside = windows ? path.toLowerCase().startsWith(head.toLowerCase()) : path.startsWith(head);
if (!inside) return "";
const rest = path.slice(head.length);
return rest && !rest.split("/").some((part) => part === ".." || part === "") ? rest : "";
}
export function pageEditor(port: AgentPort): PageEditor {
return async (disk, target, edits) => {
try {
const root = (await port.workspaces()).current;
const path = root ? insideWorkspace(root, disk) : "";
if (!path) return { saved: false, code: "outside" };
const file = await port.workspaceFile(path);
let content = file.content;
for (const edit of edits) {
const next = patchSource(content, target, edit);
if (!next.ok) return { saved: false, code: next.code };
content = next.content;
}
if (content === file.content) return { saved: true, path };
await port.saveWorkspaceFile({ ...file, content });
return { saved: true, path };
} catch (e) {
const code = e instanceof HttpError ? e.reason?.code : "";
return { saved: false, code: code === "workspace.file_changed" ? "conflict" : "failed" };
}
};
}
@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import "./testkit";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import type { RuntimeView } from "../port/hub";
afterEach(cleanup);
function mount(extra: Record<string, unknown>) {
return render(<Pane port={new MockPort()} rt={{ id: "p1", root: "/w", name: "w" } as RuntimeView}
title="w" active visible sideHost={null} side={false} onFocus={() => {}} onReport={() => {}}
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
onOpenProject={() => {}} onKeepHere={() => {}} theme="dark" dockW={560} dockMax={880} onDockW={() => {}} {...extra} />);
}
describe("where a refused action's reason sits in the pane", () => {
it("is in the stack above the composer, not in the corner layer the composer paints over", async () => {
const view = mount({ failure: <div className="errbar" role="alert" data-testid="refusal">这个文件夹里有正在运行的对话</div> });
const refusal = await view.findByTestId("refusal");
expect(refusal.parentElement?.classList.contains("composenotes")).toBe(true);
expect(refusal.closest(".cmpalert")).toBeNull();
});
it("leaves a note, which has no business over the composer, in the corner layer", async () => {
const view = mount({ alert: <div className="errbar" data-kind="note" role="status" data-testid="note">升级了</div> });
const note = await view.findByTestId("note");
expect(note.closest(".cmpalert")).not.toBeNull();
expect(note.closest(".composenotes")).toBeNull();
});
});
+4 -1
View File
@@ -78,6 +78,9 @@ export interface PaneProps {
onDockW: (w: number) => void;
manualBrowser?: boolean;
onManualBrowser?: (on: boolean) => void;
// The window's failed-request notice, handed only to the pane in front.
// The window's passing notes, handed only to the pane in front.
alert?: ReactNode;
// The window's refused-action reason, handed only to the pane in front; it joins
// the stack above the composer rather than a corner the composer paints over.
failure?: ReactNode;
}
@@ -25,7 +25,7 @@ function Projects({ port, hub }: { port: AgentPort; hub: Parameters<typeof PeerP
}
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
});
const ws = (root: string, name: string, remembered = true): TreeWorkspace => ({ root, name, remembered, sessions: [] });
@@ -18,7 +18,7 @@ afterEach(() => {
});
const base: PeersSettings = {
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8,
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false,
path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true,
};
@@ -25,7 +25,7 @@ afterEach(() => {
});
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
});
function portWith(initial: PeersSettings) {
@@ -37,6 +37,7 @@ function portWith(initial: PeersSettings) {
...current,
enabled: edit.enabled ?? current.enabled,
dispatch: edit.dispatch ?? current.dispatch,
open_nested: edit.open_nested ?? current.open_nested,
release_projects: edit.release_projects ? current.release_projects.filter((p) => edit.release_projects!.includes(p.path)) : current.release_projects,
pending: true,
};
@@ -82,6 +83,27 @@ describe("the session messages switches", () => {
expect(save).toHaveBeenCalledExactlyOnceWith({ dispatch: true });
});
it("offers opening folders inside the project without asking, off until the user turns it on", async () => {
const { port, save } = portWith(settings({ enabled: true, dispatch: true }));
render(<Messages port={port} />);
const name = "不询问直接打开本项目内的文件夹";
const sw = await screen.findByRole("switch", { name });
expect(sw.getAttribute("aria-checked")).toBe("false");
expect(screen.getByText(/项目之外的文件夹仍然会询问/)).toBeTruthy();
await userEvent.click(sw);
expect(save).toHaveBeenCalledExactlyOnceWith({ open_nested: true });
await waitFor(() => expect(screen.getByRole("switch", { name }).getAttribute("aria-checked")).toBe("true"));
});
it("holds the open-folders switch inert until the model may hand off tasks", async () => {
const { port, save } = portWith(settings({ enabled: true, dispatch: false }));
render(<Messages port={port} />);
const sw = await screen.findByRole("switch", { name: "不询问直接打开本项目内的文件夹" });
expect((sw as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(sw);
expect(save).not.toHaveBeenCalled();
});
it("names the file the switches live in, and shows a refusal beside them", async () => {
const { port } = portWith(settings({ enabled: true }));
vi.spyOn(port, "savePeersSettings").mockRejectedValue(new HttpError(500, "x", { code: "peer.settings_unwritable", params: { detail: "/home/me/.reasonix/config.toml" } }));
+2 -1
View File
@@ -1,3 +1,4 @@
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { StudioIcon } from "./StudioIcon";
import type { ReactNode } from "react";
import { MODS_SHOWN } from "./modsgate";
@@ -154,7 +155,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "ext", anchor: "mods", title: "Claude Code mod", scope: "machine", apply: "on-open", hidden: !MODS_SHOWN, keywords: ["mod", "钩子", "hook", "Claude Code", "完全信任", "插件", "扩展"] },
{ section: "session", anchor: "progress-watch", title: "panels.long_stretches_without_progress", scope: "machine", apply: "immediate", keywords: ["暂停", "无进展", "空转", "卡住", "循环", "轮次", "token"] },
{ section: "display", anchor: "changed-files", title: "settings.file_changes", scope: "machine", apply: "immediate", keywords: ["改动", "变更", "文件", "已更改", "药丸", "输入框"] },
{ section: "display", anchor: "feedback-level-notices", title: "feedback.level_notices", scope: "machine", apply: "immediate", keywords: ["等级", "晋级", "升级", "反馈", "提示"] },
{ section: "display", anchor: "feedback-level-notices", title: "feedback.level_notices", scope: "machine", apply: "immediate", hidden: FEEDBACK_RETIRED, keywords: ["等级", "晋级", "升级", "反馈", "提示"] },
{ section: "session", anchor: "turn-narration", title: "settings.narration_between_tool_calls", scope: "machine", apply: "immediate", keywords: ["叙述", "总结", "小轮", "工具之间", "详细", "简短", "思路", "监控", "narration"] },
{ section: "display", anchor: "fold-work", title: "settings.fold_the_work_in_between", scope: "machine", apply: "immediate", keywords: ["折叠", "中间过程", "工具调用", "思考", "最后一句", "收起", "摘要"] },
// Written to the user file only; the kernel's background sweep reads it on each pass.
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import "./testkit";
import { DeckChips, type Deck } from "./DeckChips";
import type { JobEntry } from "../port/port";
import type { ProcessEntry } from "../port/process";
import { HttpError } from "../port/http_error";
afterEach(cleanup);
const JOBS: JobEntry[] = [{ id: "j1", kind: "bash", label: "npm run build", status: "running", startedAt: Date.now() - 5000 }];
const ROWS: ProcessEntry[] = [
{ id: "job:j1", kind: "job", name: "npm run build", state: "running", startedAt: Date.now() - 5000 },
{ id: "subagent:sa_1", kind: "subagent", parent: "turn:t3", name: "audit", state: "queued", cause: "writers", startedAt: Date.now() - 2000 },
];
function Host({ read, jobs = JOBS }: { read?: () => Promise<ProcessEntry[]>; jobs?: JobEntry[] }) {
const [open, setOpen] = useState<Deck>("");
return <DeckChips tasks={[]} jobs={jobs} open={open} onOpen={(next) => setOpen(next)} onReadProcesses={read} />;
}
const chip = () => document.querySelector<HTMLButtonElement>('[data-action="deck.processes"]')!;
it("reads nothing until the list is opened", async () => {
const read = vi.fn().mockResolvedValue(ROWS);
render(<Host read={read} />);
await act(async () => { await Promise.resolve(); });
expect(read).not.toHaveBeenCalled();
await userEvent.click(chip());
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
});
it("shows each process with its state and the cause the kernel gave", async () => {
render(<Host read={vi.fn().mockResolvedValue(ROWS)} />);
await userEvent.click(chip());
const dialog = await screen.findByRole("dialog", { name: "进程" });
expect(dialog.textContent).toContain("npm run build");
expect(dialog.textContent).toContain("运行中");
expect(dialog.textContent).toContain("audit");
expect(dialog.textContent).toContain("排队中");
expect(dialog.textContent).toContain("等写入名额");
});
it("does not poll while open, and reads again when the jobs it was built from change", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const read = vi.fn().mockResolvedValue(ROWS);
const { rerender } = render(<Host read={read} />);
await userEvent.click(chip());
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
await act(async () => { vi.advanceTimersByTime(120_000); });
expect(read).toHaveBeenCalledTimes(1);
rerender(<Host read={read} jobs={[...JOBS, { id: "j2", kind: "bash", label: "go test", status: "running", startedAt: Date.now() }]} />);
await waitFor(() => expect(read).toHaveBeenCalledTimes(2));
} finally { vi.useRealTimers(); }
});
it("says so when the read fails and keeps no stale rows", async () => {
const read = vi.fn().mockResolvedValueOnce(ROWS).mockRejectedValueOnce(new HttpError(500, "disk gone", { code: "process.read_failed", params: { detail: "disk gone" } }));
render(<Host read={read} />);
await userEvent.click(chip());
await screen.findByText("audit");
await userEvent.click(chip());
await userEvent.click(chip());
await waitFor(() => expect(screen.queryByText("audit")).toBeNull());
expect(screen.getByRole("alert").textContent).toContain("读取进程列表失败:disk gone");
});
it("offers no process chip where the window cannot read processes", () => {
render(<Host />);
expect(chip()).toBeNull();
});
@@ -2,6 +2,9 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
// These tests describe the feedback channel while it exists; the shipped build has it retired.
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
import "./testkit";
import { ErrorBar } from "./ErrorBar";
import { ToastStack } from "./ToastStack";
@@ -9,6 +9,7 @@ export const SETTING_SCREEN: Record<string, string> = {
"agent.persona.include_runtime_context": "persona:persona",
"peers.enabled": "peers:peers",
"peers.dispatch": "peers:peers",
"peers.open_nested": "peers:peers",
"peers.release_projects": "peers:peer-projects",
"desktop.default_tool_approval_mode": "tools:approval",
"agent.plan_approval": "session:plan-approval",
@@ -2,6 +2,9 @@
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
// These tests describe the feedback channel while it exists; the shipped build has it retired.
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
import "./testkit";
import { App } from "./App";
import { MockHub } from "../port/mock_hub";
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
import { host } from "../port/host";
import type { AgentPort, BrowserTab } from "../port/port";
import { preparePage } from "./browser_open";
import { pageEditor, type PageEditor } from "./pageedit";
import { UNREAD_TABS } from "./BrowserPanel";
const HTML = /\.html?$/i;
@@ -40,6 +41,8 @@ export interface HtmlPreview {
tabFor(path: string): BrowserTab | undefined;
/** Puts the file in a page beside the agent's, or finds the one already there. */
open(path: string, revision: string | undefined): Promise<void>;
/** Writes an edit made on a previewed page back to its file; none for a remote workspace. */
edit: PageEditor | undefined;
/** Reloads the page when the file on disk is no longer the one it loaded. */
sync(path: string, revision: string): void;
}
@@ -112,5 +115,7 @@ export function useHtmlPreview(port: AgentPort, all: BrowserTab[], remote: boole
[owned],
);
return { tabs, pending: claim !== null, can, tabFor, open, sync };
const edit = useMemo(() => (remote ? undefined : pageEditor(port)), [port, remote]);
return { tabs, pending: claim !== null, can, tabFor, open, sync, edit };
}
+5 -14
View File
@@ -10,10 +10,10 @@ interface Props {
port: AgentPort; surfaces: Surface[]; selected: string; showFiles: boolean;
setBrowsers: Setter<string[]>; setOpenFiles: Setter<string[]>; setAgents: Setter<RunTab[]>; setDismissed: Setter<Set<string>>;
setSelected: Setter<string>; onCloseManual: () => void; onCloseSelected: () => void;
fromExplorer?: { current: string }; setShowFiles?: Setter<boolean>; setLauncher?: Setter<boolean>;
setLauncher?: Setter<boolean>;
}
export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, onCloseManual, onCloseSelected, fromExplorer, setShowFiles, setLauncher }: Props) {
export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, onCloseManual, onCloseSelected, setLauncher }: Props) {
const current = useRef({ surfaces, selected, showFiles });
current.current = { surfaces, selected, showFiles };
const pending = useRef(false);
@@ -42,19 +42,10 @@ export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers,
if (closed.has(current.current.selected)) onCloseSelected();
setSelected((v) => closed.has(v) ? "" : v);
setCloseFailure(failures.join("\n"));
// A file picked out of the explorer leaves that list stepped aside, not
// closed. When that close is the one that would fold the column — nothing
// left behind, the list not open — the list comes back instead. Any other
// close keeps the previous rule, so the list never lands over a tab that
// is still open.
// Closing the last page with no list open leaves the column with something
// to offer rather than folding it away.
if (!current.current.showFiles && current.current.surfaces.every((s) => closed.has(keyOf(s)))) {
if (fromExplorer?.current && closed.has(`file:${fromExplorer.current}`)) {
fromExplorer.current = "";
setLauncher?.(false);
setShowFiles?.(true);
} else if (targets.some((s) => s.kind !== "file")) {
// Closing the last page leaves the column with something to offer
// rather than folding it away: the same place a new tab starts from.
if (targets.some((s) => s.kind !== "file")) {
setLauncher?.(true);
} else {
onCloseManual();
@@ -0,0 +1,25 @@
import { FEEDBACK_RETIRED } from "../feedbackchannel";
import { useMemo, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { AgentPort } from "../port/port";
import type { TreeWorkspace } from "../port/hub";
import { AutoArchiveNotice } from "./AutoArchiveNotice";
import { ErrorBar } from "./ErrorBar";
import { LevelUpNotice } from "./LevelUpNotice";
/** What the window says about itself. A refused action's reason is the error bar
* and takes the place of the passing notes, which fill it when there is none. */
export function useWindowNotices({ error, setError, onReport, tree, port, feedbackOpen }: {
error: string;
setError: Dispatch<SetStateAction<string>>;
onReport?: () => void;
tree: TreeWorkspace[];
port: AgentPort | null;
feedbackOpen: boolean;
}): { errorBar: ReactNode; note: ReactNode; bar: ReactNode } {
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={onReport} /> : null), [error, setError, onReport]);
const note = useMemo(
() => <LevelUpNotice port={FEEDBACK_RETIRED ? null : port} tick={feedbackOpen ? 1 : 0} fallback={<AutoArchiveNotice tree={tree} />} />,
[tree, port, feedbackOpen],
);
return { errorBar, note, bar: errorBar ?? note };
}
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import { WorkbenchHistory } from "./WorkbenchHistory";
afterEach(cleanup);
const tip = (id: string) => ({ repo: true, truncated: false, commits: [{ id, parents: [], subject: `commit ${id}`, author: "a", at: 0, refs: [] }] });
function mount(stamp: string) {
const port = new MockPort();
const log = vi.spyOn(port, "workspaceGitLog").mockResolvedValue(tip("aaaaaaa1111"));
const view = render(<WorkbenchHistory port={port} branch="main" stamp={stamp} />);
return { port, log, view };
}
describe("the commit history of an open section", () => {
it("reads once when opened and not again while nothing moved", async () => {
const { port, log, view } = mount("0:0");
await screen.findByText("commit aaaaaaa1111");
view.rerender(<WorkbenchHistory port={port} branch="main" stamp="0:0" />);
expect(log).toHaveBeenCalledTimes(1);
});
it("shows a commit made after it was opened, without a reload and without blanking the list", async () => {
const { port, log, view } = mount("0:0");
await screen.findByText("commit aaaaaaa1111");
log.mockResolvedValue(tip("bbbbbbb2222"));
view.rerender(<WorkbenchHistory port={port} branch="main" stamp="1:0" />);
expect(screen.queryByText("commit aaaaaaa1111")).not.toBeNull();
await waitFor(() => expect(screen.queryByText("commit bbbbbbb2222")).not.toBeNull());
expect(log).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("status")).toBeNull();
});
it("reads again when the branch moves", async () => {
const { port, log, view } = mount("0:0");
await screen.findByText("commit aaaaaaa1111");
await act(async () => view.rerender(<WorkbenchHistory port={port} branch="topic" stamp="0:0" />));
expect(log).toHaveBeenCalledTimes(2);
});
it("keeps the list it has when a quiet re-read fails", async () => {
const { port, log, view } = mount("0:0");
await screen.findByText("commit aaaaaaa1111");
log.mockRejectedValue(new Error("busy"));
await act(async () => view.rerender(<WorkbenchHistory port={port} branch="main" stamp="1:0" />));
expect(screen.queryByText("commit aaaaaaa1111")).not.toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
});
Loaded 100 of 623 files, more files were not shown because too many files have changed in this diff. Show more