mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
297
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b47f614f | ||
|
|
50c27314e4 | ||
|
|
1b0b593f66 | ||
|
|
ed9a4ad1c9 | ||
|
|
31d39290ec | ||
|
|
245b85ec13 | ||
|
|
f43d985bda | ||
|
|
bcd54401f5 | ||
|
|
2f3ef27558 | ||
|
|
6e91c80dac | ||
|
|
c70464535b | ||
|
|
54f4f5a8b6 | ||
|
|
c88d1715d4 | ||
|
|
8078d62b03 | ||
|
|
944c61f9e6 | ||
|
|
3f98212608 | ||
|
|
2ce0c3f97a | ||
|
|
406bf96f1e | ||
|
|
3eadd288f9 | ||
|
|
8ddda45b60 | ||
|
|
1f285edf60 | ||
|
|
81e0438de3 | ||
|
|
4d094ce1f9 | ||
|
|
66d144dd90 | ||
|
|
a88c474cbd | ||
|
|
0c000c7ad4 | ||
|
|
2c8eb49a14 | ||
|
|
2a9743d790 | ||
|
|
96f261b398 | ||
|
|
4af9d05744 | ||
|
|
336cbc3c22 | ||
|
|
f28360eadc | ||
|
|
49abf8c521 | ||
|
|
f26e1c1359 | ||
|
|
965f331798 | ||
|
|
51db70d463 | ||
|
|
d91111647b | ||
|
|
50a235f0f7 | ||
|
|
dc409fc506 | ||
|
|
9e0c35100c | ||
|
|
5910c8ae10 | ||
|
|
08f01f2d79 | ||
|
|
d226841381 | ||
|
|
97e4675986 | ||
|
|
dd6b73380d | ||
|
|
c4fa9c73f7 | ||
|
|
3da8e6b23e | ||
|
|
069e5b54f3 | ||
|
|
cbeed94933 | ||
|
|
1de856a0f6 | ||
|
|
edcbc47c06 | ||
|
|
17f816d11c | ||
|
|
643902d35a | ||
|
|
8aa66a6521 | ||
|
|
447cc6ae39 | ||
|
|
b67222e1a3 | ||
|
|
75d8b75051 | ||
|
|
0363720bd2 | ||
|
|
6693788cc4 | ||
|
|
bc9d7a16ad | ||
|
|
5375b9c8f8 | ||
|
|
60938d36d9 | ||
|
|
d3139f5a4f | ||
|
|
cebdb37431 | ||
|
|
8951095309 | ||
|
|
01e775312e | ||
|
|
2cf8510775 | ||
|
|
e2e642a488 | ||
|
|
61d2cfd5a8 | ||
|
|
721b61eb40 | ||
|
|
70d164489f | ||
|
|
598de997fa | ||
|
|
dcd4632f8f | ||
|
|
84e70760b7 | ||
|
|
4014c87a38 | ||
|
|
f3bcb117f7 | ||
|
|
4f80c12e2b | ||
|
|
8b58ce9eed | ||
|
|
f02ef7d620 | ||
|
|
79430cf8cf | ||
|
|
e22b515af1 | ||
|
|
8ba8c1cf72 | ||
|
|
34eb44fcd0 | ||
|
|
957bdaa323 | ||
|
|
2f28235ab6 | ||
|
|
7c56994e82 | ||
|
|
84d16a1508 | ||
|
|
36044d1515 | ||
|
|
1a11a6691d | ||
|
|
119f392248 | ||
|
|
d4d7ef809e | ||
|
|
74b4c5d21b | ||
|
|
d70edac126 | ||
|
|
f9c6f37b78 | ||
|
|
ae0f9f8fee | ||
|
|
057d8094ba | ||
|
|
ab3f88ac9b | ||
|
|
137a5831e9 | ||
|
|
4d3c09211b | ||
|
|
2bf3bd7b7e | ||
|
|
9e74ed1806 | ||
|
|
c013a4f20b | ||
|
|
113523c5f8 | ||
|
|
0f13cf54c5 | ||
|
|
5c99c45ded | ||
|
|
92ca1b2180 | ||
|
|
dd9cf72abf | ||
|
|
6cd443fc94 | ||
|
|
34c17c5abf | ||
|
|
ae0af1d5ed | ||
|
|
ce73697390 | ||
|
|
3ed0deb61d | ||
|
|
f91eba7de2 | ||
|
|
89fbc59761 | ||
|
|
c9c258067d | ||
|
|
d235c1fce8 | ||
|
|
03725a6939 | ||
|
|
ff5242bcfc | ||
|
|
00ecd029d4 | ||
|
|
74772ab76a | ||
|
|
a67c05616d | ||
|
|
a280324b78 | ||
|
|
ddc01a3793 | ||
|
|
764fad7299 | ||
|
|
45f7b33165 | ||
|
|
fc4c0e8e5b | ||
|
|
4025ef24dc | ||
|
|
ed1c127d65 | ||
|
|
a94ba07bab | ||
|
|
cdb7d2318a | ||
|
|
b39058d42b | ||
|
|
d91e7f8d17 | ||
|
|
d31d8daf07 | ||
|
|
8b1895f05d | ||
|
|
c52d0c325c | ||
|
|
24c3fe0f87 | ||
|
|
adee10bd33 | ||
|
|
971346ff56 | ||
|
|
58a6432e55 | ||
|
|
035453edfb | ||
|
|
92c7f9d107 | ||
|
|
338264c6b7 | ||
|
|
905eb76143 | ||
|
|
fea2a123f6 | ||
|
|
7dad28cf6b | ||
|
|
00d6b85d34 | ||
|
|
95428e8f17 | ||
|
|
ec49147dee | ||
|
|
2234a3d68b | ||
|
|
f2a2da4120 | ||
|
|
2a035ffdb1 | ||
|
|
152db8582e | ||
|
|
3d624951db | ||
|
|
047b9e96b0 | ||
|
|
a21d8d8b64 | ||
|
|
3219135e7e | ||
|
|
674712eadf | ||
|
|
0154163c84 | ||
|
|
e906f7b4d5 | ||
|
|
8d7a55e8ad | ||
|
|
c73eb851bf | ||
|
|
92b2e46f69 | ||
|
|
4cddc3390c | ||
|
|
e453fe539e | ||
|
|
20d807cb80 | ||
|
|
c85b77bee2 | ||
|
|
da001ded22 | ||
|
|
3d71e57313 | ||
|
|
fb99e0effd | ||
|
|
6d700b071b | ||
|
|
b09691402d | ||
|
|
b3ec1e2e87 | ||
|
|
896552483e | ||
|
|
e6ba328ad4 | ||
|
|
ba8900aba9 | ||
|
|
57ca36e635 | ||
|
|
5fdd45af4b | ||
|
|
cc629b6424 | ||
|
|
157ca8533c | ||
|
|
c190fe4bf4 | ||
|
|
6943f966da | ||
|
|
de67ca98e8 | ||
|
|
436fa1754a | ||
|
|
cf745b383c | ||
|
|
790943ac97 | ||
|
|
6f68a14a9d | ||
|
|
032e9b771d | ||
|
|
089a348753 | ||
|
|
d87dc94c26 | ||
|
|
7c35e9bfab | ||
|
|
e667bbcd59 | ||
|
|
9057c3211e | ||
|
|
b6dbbde594 | ||
|
|
065d5200da | ||
|
|
8e14cdff21 | ||
|
|
74e594faed | ||
|
|
6a93a3d01e | ||
|
|
f821e2be49 | ||
|
|
8ce6be77d8 | ||
|
|
e60cc114e8 | ||
|
|
bbe38032c3 | ||
|
|
bb43735206 | ||
|
|
1571fe64dc | ||
|
|
fc4626cf94 | ||
|
|
cbff25b46b | ||
|
|
33b05e79d8 | ||
|
|
cacc7c6789 | ||
|
|
3f61753054 | ||
|
|
92d8795b31 | ||
|
|
576e89b111 | ||
|
|
f249ab3e0e | ||
|
|
4618349369 | ||
|
|
27dbf237af | ||
|
|
83fde1922f | ||
|
|
8bebe521d6 | ||
|
|
eacf0618e8 | ||
|
|
8c311b02bd | ||
|
|
7b775ed15e | ||
|
|
64c5ff3273 | ||
|
|
291d02c884 | ||
|
|
f40ab35660 | ||
|
|
b96ad582a6 | ||
|
|
42c505b50e | ||
|
|
5c38676052 | ||
|
|
58e2da8db2 | ||
|
|
4b483d3897 | ||
|
|
085f20c576 | ||
|
|
3e10e6faf6 | ||
|
|
8f1fe5965d | ||
|
|
f1fa8538d5 | ||
|
|
4d2c7cd4d0 | ||
|
|
863ed17e70 | ||
|
|
a2aef0b953 | ||
|
|
15de9ec334 | ||
|
|
ec207aabf7 | ||
|
|
bec46d99ab | ||
|
|
992e0155a5 | ||
|
|
3b5ef3dff1 | ||
|
|
33bb46e95a | ||
|
|
14206bc978 | ||
|
|
da0c5c6a9f | ||
|
|
038732325c | ||
|
|
ffe30b62bf | ||
|
|
eb48decd12 | ||
|
|
e7d19e3d65 | ||
|
|
2002ef72ea | ||
|
|
ddba148a36 | ||
|
|
8720820542 | ||
|
|
ec8a91e1e1 | ||
|
|
25d9f7f028 | ||
|
|
aa80cdce2e | ||
|
|
5b4f35502f | ||
|
|
fbdb85dc4e | ||
|
|
006616829a | ||
|
|
b9510a1cc2 | ||
|
|
0d9bae435a | ||
|
|
fbf2495a4c | ||
|
|
724d94af8c | ||
|
|
e24b481e40 | ||
|
|
4840df9825 | ||
|
|
8c4e0ef1e8 | ||
|
|
fadd893983 | ||
|
|
12fb14abb6 | ||
|
|
001d33f232 | ||
|
|
667f2ca166 | ||
|
|
33219e58c5 | ||
|
|
f6c18ac5a3 | ||
|
|
fc80457d14 | ||
|
|
5d99b3a628 | ||
|
|
96d21061db | ||
|
|
7392199b3c | ||
|
|
1d1a8c7ad8 | ||
|
|
fb84c5af25 | ||
|
|
c24492d2c0 | ||
|
|
c1c7d99ecb | ||
|
|
02ff3c3352 | ||
|
|
cc14bcb75e | ||
|
|
14c5dcbcee | ||
|
|
623e792df3 | ||
|
|
a8dc464e9a | ||
|
|
321e3bbaf0 | ||
|
|
5393e818e2 | ||
|
|
aab760b0b2 | ||
|
|
a72134c0ef | ||
|
|
530ccbf688 | ||
|
|
9c6c91da9c | ||
|
|
c7e6ef9a52 | ||
|
|
f3a5a6bcce | ||
|
|
6fe0fd6867 | ||
|
|
e7d50cf0e9 | ||
|
|
e858a46f70 | ||
|
|
eebc6d2a3c | ||
|
|
8709b556f0 | ||
|
|
da051abb2c | ||
|
|
ce29aae765 | ||
|
|
27434c885f | ||
|
|
21d8cc2fa9 |
No files matched your search
@@ -144,7 +144,14 @@ jobs:
|
||||
node perf/jump-ends.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/explorersize.mjs
|
||||
node perf/session-move-to.mjs
|
||||
node perf/rail-search-hold.mjs
|
||||
node perf/session-move.mjs
|
||||
node perf/session-ref.mjs
|
||||
node perf/browser-full.mjs
|
||||
node perf/mod-ui.mjs
|
||||
node perf/codeeditor-size.mjs
|
||||
node perf/action-icons.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/tab-pip.mjs
|
||||
node perf/context-card-hover.mjs
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The host runs a scheduled child through the small schedulerun package. The
|
||||
// command line is not part of what it ships, and importing it again for one
|
||||
// entry point would bring back every command it carries.
|
||||
func TestTheHostDoesNotCarryTheCommandLine(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Skipf("go list is not available: %v", err)
|
||||
}
|
||||
for pkg := range strings.FieldsSeq(string(out)) {
|
||||
if pkg == "reasonix/internal/frontend/cli" {
|
||||
t.Fatal("the studio host depends on the command line package")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"reasonix/internal/ext/modhost"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -33,6 +34,7 @@ import (
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/surface"
|
||||
"reasonix/internal/frontend/remotehost"
|
||||
"reasonix/internal/frontend/schedulerun"
|
||||
"reasonix/internal/frontend/serve"
|
||||
"reasonix/internal/frontend/traystate"
|
||||
"reasonix/internal/platform/account"
|
||||
@@ -48,6 +50,7 @@ import (
|
||||
// Kinds register from init, so a binary builds only what it links. Without
|
||||
// these every Anthropic model answers "unknown kind" at switch time.
|
||||
_ "reasonix/internal/model/anthropic"
|
||||
_ "reasonix/internal/model/gemini"
|
||||
_ "reasonix/internal/model/openai"
|
||||
_ "reasonix/internal/model/responses"
|
||||
)
|
||||
@@ -121,6 +124,10 @@ func main() {
|
||||
if handled, code := update.MaybeRunTreeHandoff(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
// A scheduled run is this same binary started by the in-kernel ticker.
|
||||
if scheduleChildArgs(os.Args[1:]) {
|
||||
os.Exit(schedulerun.Exec(os.Args[3:]))
|
||||
}
|
||||
page := flag.String("page", "", "directory holding the built Studio page")
|
||||
identity := flag.Bool("instance-id", false, "print the Studio instance this data home belongs to, and exit")
|
||||
// The shell around this process knows which build it is; this process does
|
||||
@@ -140,6 +147,8 @@ func main() {
|
||||
if *stripGrants {
|
||||
os.Exit(stripPackageGrants(os.Stdout, os.Stderr, *studioApp))
|
||||
}
|
||||
// Studio's own binary runs a Claude Code mod's module as Node.
|
||||
modhost.SetEmbedder(*studioApp, os.Environ())
|
||||
// Which launches are the same Studio is Reasonix's question, not a shell's:
|
||||
// the answer is the canonicalized data home, and a shell asks for it rather
|
||||
// than working it out from an environment it does not resolve.
|
||||
@@ -250,6 +259,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
// After the socket has drained, never before: a pane torn down under a live
|
||||
// request answers it with a half-closed kernel.
|
||||
defer hub.Shutdown()
|
||||
startScheduleTicker(ctx, lease != nil, hub, logs)
|
||||
|
||||
bound, err := bind(hub.Handler())
|
||||
phases.mark(startupBind)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/frontend/serve"
|
||||
"reasonix/internal/runtime/schedrun"
|
||||
"reasonix/internal/runtime/scheduler"
|
||||
)
|
||||
|
||||
// scheduleChildArgs says argv is a supervised scheduled run: the supervisor
|
||||
// starts this binary as `schedule exec <id>`, and this host has no CLI of its own.
|
||||
func scheduleChildArgs(args []string) bool {
|
||||
return len(args) >= 2 && args[0] == "schedule" && args[1] == "exec"
|
||||
}
|
||||
|
||||
// scheduleEligible is when this kernel may start scheduled runs: it has a
|
||||
// supervised lifetime (a parent that ends it) and something the person can see.
|
||||
// An orphan, or a kernel nobody can see, never spends on anyone's behalf.
|
||||
func scheduleEligible(supervised, iconLive bool, panes int) bool {
|
||||
return supervised && (iconLive || panes > 0)
|
||||
}
|
||||
|
||||
// startScheduleTicker runs the in-kernel driver of scheduled runs. Every kernel
|
||||
// on the machine may run it; the ticker lock lets one tick at a time.
|
||||
func startScheduleTicker(ctx context.Context, supervised bool, hub *serve.Hub, logs io.Writer) {
|
||||
if !supervised {
|
||||
return
|
||||
}
|
||||
loop := &scheduler.Loop{
|
||||
Eligible: func() bool { return scheduleEligible(supervised, (&studioTray{}).IconLive(), len(hub.Runtimes())) },
|
||||
Lock: func() (func(), error) {
|
||||
store, _, err := scheduler.OpenUserStore(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filelock.TryAcquire(filepath.Join(store.Dir(), "scheduler.lock"))
|
||||
},
|
||||
Tick: func(ctx context.Context) (scheduler.Result, error) {
|
||||
store, policy, err := scheduler.OpenUserStore(nil)
|
||||
if err != nil {
|
||||
return scheduler.Result{}, err
|
||||
}
|
||||
return (&scheduler.Scheduler{Store: store, Policy: policy, Runner: &schedrun.Supervisor{Store: store, Policy: policy}}).Tick(ctx)
|
||||
},
|
||||
Done: func(res scheduler.Result, err error) {
|
||||
for _, r := range res.Reports {
|
||||
fmt.Fprintf(logs, "schedule: ran %s: %s %s\n", r.TriggerID, r.State, r.Code)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
fmt.Fprintf(logs, "schedule: skipped %s: %s\n", s.ScheduleID, s.Reason)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "schedule:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
go loop.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAScheduleChildIsRecognisedByItsArguments(t *testing.T) {
|
||||
for args, want := range map[string]bool{"schedule exec sch_x/1": true, "schedule exec": true, "schedule list": false, "exec schedule": false, "": false} {
|
||||
if got := scheduleChildArgs(strings.Fields(args)); got != want {
|
||||
t.Errorf("%q = %v, want %v", args, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An orphan, or a kernel nobody can see, never starts a scheduled run.
|
||||
func TestSchedulesStartOnlyForASupervisedKernelThatCanBeSeen(t *testing.T) {
|
||||
cases := []struct {
|
||||
supervised, icon bool
|
||||
panes int
|
||||
want bool
|
||||
}{
|
||||
{true, true, 0, true}, {true, false, 1, true}, {true, true, 3, true},
|
||||
{true, false, 0, false}, {false, true, 2, false}, {false, false, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := scheduleEligible(c.supervised, c.icon, c.panes); got != c.want {
|
||||
t.Errorf("%+v = %v", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries.
|
||||
_ "reasonix/internal/model/anthropic"
|
||||
_ "reasonix/internal/model/gemini"
|
||||
_ "reasonix/internal/model/openai"
|
||||
_ "reasonix/internal/model/responses"
|
||||
_ "reasonix/internal/tools/builtin"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"browser-fit-live": "electron test/browser_fit_live.js",
|
||||
"image-live": "electron test/image_live.js",
|
||||
"image-pane-live": "electron test/image_pane_live.js",
|
||||
"sidepanel-live": "electron test/sidepanel_live.js",
|
||||
|
||||
@@ -116,7 +116,7 @@ class BrowserViews {
|
||||
}
|
||||
onEvent(method, params);
|
||||
});
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false };
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
|
||||
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
|
||||
this.watchLoads(targetId, entry);
|
||||
let closed = false;
|
||||
@@ -140,6 +140,13 @@ class BrowserViews {
|
||||
return {
|
||||
targetId,
|
||||
send: (method, params) => {
|
||||
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
|
||||
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
|
||||
try {
|
||||
params = scaleInput(method, params, entry.scale);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
|
||||
entry.synthUntil = Date.now() + 250;
|
||||
return this.wake.run(() => contents.debugger.sendCommand(method, params));
|
||||
@@ -164,7 +171,7 @@ class BrowserViews {
|
||||
// which during a turn is constantly, and nearly always the same rectangle:
|
||||
// re-adding the view and setting the bounds it already has is work for
|
||||
// every one of those.
|
||||
if (this.shown === targetId && sameRect(entry.at, at)) return;
|
||||
if (this.shown === targetId && sameRect(entry.panel, at)) return;
|
||||
this.placed++;
|
||||
for (const [id, other] of this.entries) {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
@@ -176,9 +183,48 @@ class BrowserViews {
|
||||
if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.attached = true;
|
||||
entry.panel = at;
|
||||
this.fit(entry);
|
||||
this.keepKeyboard();
|
||||
}
|
||||
|
||||
// fit puts the view where its page is drawn whole and tells the page how it
|
||||
// is scaled; a page parked out of sight is not scaled.
|
||||
fit(entry) {
|
||||
const { at, scale } = placement(entry.panel, entry.override && { width: entry.override.width, height: entry.override.height });
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
this.keepKeyboard();
|
||||
return this.scaled(entry, scale);
|
||||
}
|
||||
|
||||
scaled(entry, scale) {
|
||||
if (!entry.override || entry.scale === scale) return Promise.resolve();
|
||||
entry.scale = scale;
|
||||
return this.override(entry).catch(() => {});
|
||||
}
|
||||
|
||||
override(entry) {
|
||||
const params = entry.scale === 1 ? entry.override : { ...entry.override, scale: entry.scale };
|
||||
return entry.view.webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", params);
|
||||
}
|
||||
|
||||
// emulate records the viewport a page was told to lay out in, which the view
|
||||
// is then fitted to; null is the page going back to the size of its view.
|
||||
async emulate(entry, params) {
|
||||
entry.override = params ? { ...params } : null;
|
||||
if (!params) {
|
||||
entry.scale = 1;
|
||||
if (entry.panel) this.fit(entry);
|
||||
return entry.view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {});
|
||||
}
|
||||
entry.scale = 1;
|
||||
if (entry.panel) {
|
||||
const { at, scale } = placement(entry.panel, entry.override);
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
entry.scale = scale;
|
||||
}
|
||||
return this.override(entry);
|
||||
}
|
||||
|
||||
// Attaching a view can leave nobody holding the keyboard, and the platform
|
||||
@@ -253,7 +299,11 @@ class BrowserViews {
|
||||
putAway(view, entry) {
|
||||
const { height } = this.win.getContentBounds();
|
||||
view.setBounds({ x: 1 - AGENT_VIEWPORT.width, y: height - 1, ...AGENT_VIEWPORT });
|
||||
if (entry) entry.at = null;
|
||||
if (entry) {
|
||||
entry.at = null;
|
||||
entry.panel = null;
|
||||
void this.scaled(entry, 1);
|
||||
}
|
||||
}
|
||||
|
||||
control(targetId, action) {
|
||||
@@ -420,6 +470,42 @@ async function loadResult(contents, url) {
|
||||
}
|
||||
}
|
||||
|
||||
// Where a page's view sits in the rectangle the panel gave it. A page laid out
|
||||
// in a viewport of its own is shown whole: scaled down to fit and never up,
|
||||
// centred, with the view sized to exactly what it draws.
|
||||
function placement(panel, emulated) {
|
||||
if (!emulated) return { at: panel, scale: 1 };
|
||||
const scale = Math.min(1, panel.width / emulated.width, panel.height / emulated.height);
|
||||
const width = Math.max(1, Math.round(emulated.width * scale));
|
||||
const height = Math.max(1, Math.round(emulated.height * scale));
|
||||
const x = panel.x + Math.round((panel.width - width) / 2);
|
||||
const y = panel.y + Math.round((panel.height - height) / 2);
|
||||
return { at: { x, y, width, height }, scale };
|
||||
}
|
||||
|
||||
// Input that carries a point. The page reads it in the viewport it was told it
|
||||
// has, the view draws that viewport scaled, and a point meant for the page lands
|
||||
// where it is drawn only after the same scaling. The mouse is measured to; the
|
||||
// rest are not, and a point that silently lands elsewhere is worse than a
|
||||
// refusal, so they are refused while the page is scaled.
|
||||
const POINTED_INPUT = new Set([
|
||||
"Input.dispatchTouchEvent",
|
||||
"Input.dispatchDragEvent",
|
||||
"Input.emulateTouchFromMouseEvent",
|
||||
"Input.synthesizeTapGesture",
|
||||
"Input.synthesizeScrollGesture",
|
||||
"Input.synthesizePinchGesture",
|
||||
]);
|
||||
|
||||
function scaleInput(method, params, scale) {
|
||||
if (scale === 1) return params;
|
||||
if (method === "Input.dispatchMouseEvent") return { ...params, x: params.x * scale, y: params.y * scale };
|
||||
if (POINTED_INPUT.has(method)) {
|
||||
throw Object.assign(new Error(`${method} is not available while the page is scaled to fit the panel`), { code: "input_unscaled" });
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function sameRect(a, b) {
|
||||
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
@@ -428,4 +514,4 @@ function validRect(rect) {
|
||||
return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS };
|
||||
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS, placement, scaleInput };
|
||||
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
// A page the agent lays out in a viewport of its own (a resize) is shown whole in the panel, not cropped to its
|
||||
// top-left corner: scaled down to fit and centred, with the agent's clicks still landing where they were aimed.
|
||||
// Run with `pnpm browser-fit-live`; it needs no network. SHOTS=<dir> also writes pictures of the window.
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
const { checker } = require("./livekit");
|
||||
|
||||
const PAGE = `<!doctype html><title>wide</title><style>body{margin:0;font:20px sans-serif}.bar{display:flex;justify-content:space-between;background:#1a56db;color:#fff;padding:14px;width:1280px;box-sizing:border-box}.c{width:1280px;height:600px;background:linear-gradient(90deg,#fde68a,#a7f3d0);position:relative}.r{position:absolute;right:10px;top:10px;background:#111;color:#fff;padding:8px}</style><div class=bar><b>LEFT edge</b><b>RIGHT edge</b></div><div class=c><span class=r id=rc onclick="document.title='hit'">right column</span></div>`;
|
||||
const { check, failures } = checker();
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const server = http.createServer((_q, s) => { s.writeHead(200, { "Content-Type": "text/html" }); s.end(PAGE); });
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd;font:14px sans-serif'><div style='position:absolute;left:20px;top:20px'>app chrome</div>");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const page = views.create({ partition: "fit", url: `http://127.0.0.1:${server.address().port}/`, onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
|
||||
const view = views.entries.get(page.targetId).view;
|
||||
await wait(1500);
|
||||
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
|
||||
const shot = (name) => {
|
||||
if (!process.env.SHOTS) return;
|
||||
fs.mkdirSync(process.env.SHOTS, { recursive: true });
|
||||
win.focus();
|
||||
const b = win.getBounds();
|
||||
execFileSync("screencapture", ["-x", "-R", `${b.x},${b.y},${b.width},${b.height}`, `${process.env.SHOTS}/${name}.png`]);
|
||||
};
|
||||
const click = async () => {
|
||||
await evalIn("document.title='reset'");
|
||||
const r = JSON.parse(await evalIn("JSON.stringify(document.getElementById('rc').getBoundingClientRect())"));
|
||||
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
|
||||
await page.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
|
||||
}
|
||||
await wait(250);
|
||||
return evalIn("document.title");
|
||||
};
|
||||
const panel = { x: 380, y: 60, width: 560, height: 620 };
|
||||
views.show(page.targetId, panel);
|
||||
await wait(600);
|
||||
check("no resize: the page fills the panel as before", JSON.stringify(view.getBounds()) === JSON.stringify(panel));
|
||||
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
const fit = view.getBounds();
|
||||
check("a 1280x900 viewport in a 560x620 panel: the view is scaled to 560x394 and centred", fit.width === 560 && fit.height === 394 && fit.x === 380 && fit.y === 173, JSON.stringify(fit));
|
||||
check("the page still lays out at the viewport it was given", (await evalIn("innerWidth")) === 1280);
|
||||
check("an agent click aimed at the right column lands on it", (await click()) === "hit");
|
||||
shot("fit-in-panel");
|
||||
|
||||
views.show(page.targetId, { x: 380, y: 60, width: 400, height: 500 });
|
||||
await wait(600);
|
||||
const narrower = view.getBounds();
|
||||
check("a narrower panel re-fits the view", narrower.width === 400 && narrower.height === 281, JSON.stringify(narrower));
|
||||
check("and the click still lands", (await click()) === "hit");
|
||||
|
||||
views.show(page.targetId, { x: 20, y: 20, width: 1400, height: 1000 });
|
||||
await wait(600);
|
||||
const roomy = view.getBounds();
|
||||
check("a panel larger than the viewport shows it at full size, centred, never enlarged", roomy.width === 1280 && roomy.height === 900 && roomy.x === 80 && roomy.y === 70, JSON.stringify(roomy));
|
||||
check("and the click lands at full size", (await click()) === "hit");
|
||||
|
||||
views.hide();
|
||||
await wait(600);
|
||||
check("parked, the page is not scaled and the click lands", (await click()) === "hit");
|
||||
|
||||
views.show(page.targetId, panel);
|
||||
await wait(600);
|
||||
check("shown again it is fitted again", view.getBounds().width === 560 && (await click()) === "hit");
|
||||
|
||||
await page.send("Emulation.clearDeviceMetricsOverride", {});
|
||||
await wait(600);
|
||||
check("clearing the viewport gives the page its panel back", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560);
|
||||
shot("cleared");
|
||||
|
||||
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
});
|
||||
@@ -2281,6 +2281,55 @@ test("a parked page is attached again when it is shown, and a page already on sc
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
|
||||
});
|
||||
|
||||
test("a page given its own viewport is fitted whole into its panel, centred, and never enlarged", () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let placement;
|
||||
try {
|
||||
({ placement } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const panel = { x: 380, y: 60, width: 560, height: 620 };
|
||||
assert.deepEqual(placement(panel, null), { at: panel, scale: 1 }, "a page without a viewport of its own fills the panel");
|
||||
const wide = placement(panel, { width: 1280, height: 900 });
|
||||
assert.equal(wide.scale, 0.4375);
|
||||
assert.deepEqual(wide.at, { x: 380, y: 173, width: 560, height: 394 });
|
||||
const phone = placement(panel, { width: 390, height: 844 });
|
||||
assert.ok(phone.scale < 1 && phone.at.height <= panel.height && phone.at.width <= panel.width);
|
||||
assert.deepEqual(placement({ x: 20, y: 20, width: 1400, height: 1000 }, { width: 1280, height: 900 }), { at: { x: 80, y: 70, width: 1280, height: 900 }, scale: 1 });
|
||||
});
|
||||
|
||||
test("input that carries a point is scaled with the page or refused, never sent to the wrong place", () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let scaleInput;
|
||||
try {
|
||||
({ scaleInput } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const mouse = { type: "mousePressed", x: 1200, y: 800, button: "left" };
|
||||
assert.equal(scaleInput("Input.dispatchMouseEvent", mouse, 1), mouse, "an unscaled page gets the point as it is");
|
||||
assert.deepEqual(scaleInput("Input.dispatchMouseEvent", mouse, 0.5), { type: "mousePressed", x: 600, y: 400, button: "left" });
|
||||
for (const method of ["Input.dispatchTouchEvent", "Input.dispatchDragEvent", "Input.emulateTouchFromMouseEvent", "Input.synthesizeTapGesture", "Input.synthesizeScrollGesture", "Input.synthesizePinchGesture"]) {
|
||||
const params = { x: 10, y: 20, touchPoints: [{ x: 10, y: 20 }] };
|
||||
assert.equal(scaleInput(method, params, 1), params, `${method} passes on an unscaled page`);
|
||||
assert.throws(() => scaleInput(method, params, 0.5), { code: "input_unscaled" }, `${method} must not reach a scaled page unscaled`);
|
||||
}
|
||||
const key = { type: "keyDown", key: "a" };
|
||||
assert.equal(scaleInput("Input.dispatchKeyEvent", key, 0.5), key, "keys carry no point");
|
||||
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
|
||||
});
|
||||
|
||||
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
|
||||
const file = path.join(dir, "prefs.json");
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.19.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-map-gl": "8.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "3.10.1",
|
||||
@@ -56,14 +56,14 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.7",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^30.1.2",
|
||||
"playwright": "^1.64.0",
|
||||
"jsdom": "^30.1.3",
|
||||
"playwright": "^1.64.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.3",
|
||||
"vitest": "^5.0.3"
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.4"
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶
|
||||
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
|
||||
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
|
||||
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// The row of actions under a reply and the row under the person's own message read as one family: the same
|
||||
// button and the same icon size. A smaller icon under one of them is the kind of slip a reader sees at once.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const size = (el) => el.evaluate((e) => {
|
||||
const b = e.getBoundingClientRect();
|
||||
return `${Math.round(b.width * 10) / 10}x${Math.round(b.height * 10) / 10}`;
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1200, height: 900 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".user-acts");
|
||||
await page.waitForSelector(".call[data-k=\"say\"] .acts");
|
||||
const mine = page.locator(".user-acts .studio-icon");
|
||||
const reply = page.locator(".call[data-k=\"say\"] .acts .studio-icon");
|
||||
check(`${scheme}: both rows were found`, (await mine.count()) > 0 && (await reply.count()) > 0);
|
||||
const replyIcon = await size(reply.first());
|
||||
const replyButton = await size(page.locator(".call[data-k=\"say\"] .acts > button").first());
|
||||
for (let i = 0; i < await mine.count(); i++) {
|
||||
check(`${scheme}: icon ${i + 1} of the message's own row matches the reply's`, await size(mine.nth(i)) === replyIcon, `${await size(mine.nth(i))} vs ${replyIcon}`);
|
||||
}
|
||||
check(`${scheme}: the buttons are the same size`, await size(page.locator(".user-acts > button").first()) === replyButton);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -19,11 +19,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".machpick");
|
||||
await page.waitForSelector('.projects-heading [data-action="workspace.add"]');
|
||||
const current = () => page.evaluate(() => [...document.querySelectorAll(".wsnode[data-current]")].map((el) => el.textContent ?? ""));
|
||||
const was = await current();
|
||||
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-before.png`) });
|
||||
await page.locator(".machpick").first().click();
|
||||
await page.locator('.projects-heading [data-action="workspace.add"]').click();
|
||||
await page.getByRole("dialog", { name: "打开项目", exact: true }).getByRole("button", { name: "选择本机文件夹", exact: true }).click();
|
||||
await page.waitForFunction(() => [...document.querySelectorAll(".wsnode[data-current]")].some((el) => el.textContent?.includes("mock-workspace")), null, { timeout: 5000 }).catch(() => {});
|
||||
const now = await current();
|
||||
check(`${scheme}: the added project becomes the current one`, now.length === 1 && now[0].includes("mock-workspace"), now.join("|").slice(0, 120));
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// The "+" that opens a project folder sits on the machine row where a person
|
||||
// looks for it, and is there before the row is hovered. It was drawn only on
|
||||
// hover once, and two people on 2.39.0 reported that they could not choose a
|
||||
// folder at all. Opacity and hit-testing need a real browser.
|
||||
// The Projects heading's add-folder button stays visible and hit-testable
|
||||
// before hover. Opacity and hit-testing need a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
@@ -18,11 +16,11 @@ try {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".machrow[data-here]");
|
||||
await page.waitForSelector(".projects-heading");
|
||||
await page.mouse.move(width - 2, 790);
|
||||
await page.waitForTimeout(300);
|
||||
const g = await page.evaluate(() => {
|
||||
const row = document.querySelector(".machrow[data-here]");
|
||||
const row = document.querySelector(".projects-heading");
|
||||
const add = row?.querySelector('[data-action="workspace.add"]');
|
||||
if (!add) return null;
|
||||
const r = add.getBoundingClientRect();
|
||||
@@ -32,7 +30,7 @@ try {
|
||||
return { opacity: seen, width: r.width, height: r.height, hits: hit === add || add.contains(hit), shown: row.matches(":hover") };
|
||||
});
|
||||
const where = `${theme} ${width}`;
|
||||
check(`${where}: the machine row has the add-folder button`, g !== null);
|
||||
check(`${where}: the Projects heading has the add-folder button`, g !== null);
|
||||
if (!g) continue;
|
||||
check(`${where}: it is visible without hovering the row`, !g.shown && g.opacity === 1 && g.width > 0 && g.height > 0, JSON.stringify(g));
|
||||
check(`${where}: it takes the pointer`, g.hits);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Full-screen browser: the page takes the whole pane, the conversation is folded away but stays mounted,
|
||||
// and Escape (or the same button) brings the split back.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
|
||||
const SHOTS = process.env.SHOTS_DIR;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
await page.waitForSelector(".workbench-filelist button");
|
||||
const read = () => page.evaluate(() => {
|
||||
const pane = document.querySelector(".pane");
|
||||
const dock = pane.querySelector(':scope > .scroll[data-pane="browser"]');
|
||||
const shown = (el) => !!el && getComputedStyle(el).display !== "none";
|
||||
return {
|
||||
size: pane.getAttribute("data-browser-size"),
|
||||
conversation: shown(pane.querySelector(":scope > .pbody")),
|
||||
composer: shown(pane.querySelector(":scope > .compose")),
|
||||
dockWidth: dock.getBoundingClientRect().width,
|
||||
paneWidth: pane.getBoundingClientRect().width,
|
||||
};
|
||||
});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.mouse.move(1390, 890);
|
||||
await page.waitForTimeout(300);
|
||||
const split = await read();
|
||||
check(`${scheme}: starts split with the conversation shown`, split.size === "split" && split.conversation && split.composer);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/browser-${scheme}-split.png` });
|
||||
await page.locator('[data-action="browser.full"]').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
const full = await read();
|
||||
check(`${scheme}: full hides the conversation and the composer`, full.size === "full" && !full.conversation && !full.composer);
|
||||
check(`${scheme}: full gives the page the whole pane`, full.dockWidth >= full.paneWidth - 1, `${full.dockWidth}px of ${full.paneWidth}px`);
|
||||
await page.mouse.move(1390, 890);
|
||||
await page.waitForTimeout(300);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/browser-${scheme}-full.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
const back = await read();
|
||||
check(`${scheme}: Escape brings the split and the conversation back`, back.size === "split" && back.conversation && back.composer);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -216,14 +216,20 @@ class BenchPort extends MockPort {
|
||||
search_backends: ["searxng", "deepseek_api"], searxng_url: "http://127.0.0.1:8080", search_paid_allowed: [],
|
||||
fetch_backends: ["local", "deepseek_api"], fetch_paid_allowed: [],
|
||||
known_search_backends: ["searxng", "deepseek_api"], known_fetch_backends: ["local", "deepseek_api"], paid_backends: ["deepseek_api"],
|
||||
deepseek_api_ready: query.get("webaccess") !== "noready", path: "/Users/you/.reasonix/config.toml",
|
||||
deepseek_api_ready: query.get("webaccess") !== "noready", video_url_passthrough: query.has("videourl"), path: "/Users/you/.reasonix/config.toml",
|
||||
};
|
||||
}
|
||||
|
||||
async probeSearxng() {
|
||||
const answer = query.get("probe");
|
||||
if (!answer || answer === "ok") return super.probeSearxng();
|
||||
return { ok: false, code: `search.searxng_${answer}`, status: answer === "status" ? 429 : answer === "json_disabled" ? 403 : undefined };
|
||||
}
|
||||
|
||||
async peersSettings() {
|
||||
if (!query.has("peerset")) return super.peersSettings();
|
||||
return {
|
||||
enabled: true, dispatch: true, applies: "next_turn" as const, pending: query.has("pending"), available: true,
|
||||
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn" as const, 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" }],
|
||||
};
|
||||
@@ -244,8 +250,15 @@ class BenchPort extends MockPort {
|
||||
return PREF === null ? look : { ...look, language: PREF };
|
||||
}
|
||||
|
||||
private settingCardSent = false;
|
||||
|
||||
subscribe(on: (e: WireEvent) => void) {
|
||||
this.subs.add(on);
|
||||
const card = query.get("settingcard");
|
||||
if (card && !this.settingCardSent) {
|
||||
this.settingCardSent = true;
|
||||
setTimeout(() => this.feed(settingCardFrame(card)), 400);
|
||||
}
|
||||
return () => this.subs.delete(on);
|
||||
}
|
||||
|
||||
@@ -360,7 +373,7 @@ function storedHistory(): HistoryMessage[] {
|
||||
const msgs: HistoryMessage[] = [];
|
||||
for (let i = 0; i < TURNS; i++) {
|
||||
const pinned = query.get("skill") === "1" && i === TURNS - 1;
|
||||
msgs.push({ role: "user", content: pinned ? SKILL_PIN : `第 ${i} 个问题,麻烦看一下这个文件。` });
|
||||
msgs.push({ role: "user", content: pinned ? SKILL_PIN : `第 ${i} 个问题,麻烦看一下这个文件。`, createdAt: new Date(2026, 9, 17, 18, 33 + i).getTime() });
|
||||
msgs.push({
|
||||
role: "assistant",
|
||||
content: `第 ${i} 段回答,说明刚才那一步做了什么。\n\n- 要点一\n- 要点二\n`,
|
||||
@@ -437,6 +450,11 @@ class BenchHub extends MockHub {
|
||||
);
|
||||
}
|
||||
|
||||
async moveSessionTo(path: string, target: string) {
|
||||
(window.__moves ??= []).push({ path, target });
|
||||
return super.moveSessionTo(path, target);
|
||||
}
|
||||
|
||||
tree(): Promise<TreeWorkspace[]> {
|
||||
const wait = TREE_AT - performance.now();
|
||||
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
|
||||
@@ -445,13 +463,14 @@ class BenchHub extends MockHub {
|
||||
Array.from({ length: WORKSPACES }, (_, w) => ({
|
||||
root: `~/projects/workspace-${w}`,
|
||||
name: `workspace-${w}`,
|
||||
remembered: true,
|
||||
open: w === 0,
|
||||
sessions: Array.from({ length: SESSIONS }, (_, s) => ({
|
||||
sessions: this.placed(`~/projects/workspace-${w}`, Array.from({ length: SESSIONS }, (_, s) => ({
|
||||
path: `/sessions/w${w}-s${s}.jsonl`,
|
||||
name: `w${w}-s${s}`,
|
||||
title: `第 ${s} 次会话,标题是内核起的一句话`,
|
||||
turns: (s % 40) + 1,
|
||||
})),
|
||||
}))),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -470,6 +489,7 @@ createRoot(document.getElementById("root")!).render(<App hub={hub} />);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__moves?: { path: string; target: string }[];
|
||||
// Feeds every open pane, so a split-view measurement costs what two live
|
||||
// conversations really cost.
|
||||
__feed: (ev: WireEvent) => void;
|
||||
@@ -506,3 +526,22 @@ window.__foldCost = () => {
|
||||
fromHistory(msgs);
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// ?settingcard= draws what the kernel sends when the model asks to change a
|
||||
// setting: a preference card, an elevated one, or the notice that points at a
|
||||
// setting only the user may change.
|
||||
function settingCardFrame(kind: string): WireEvent {
|
||||
if (kind === "open") {
|
||||
return { kind: "notice", level: "info", code: "setting_open", text: "助手需要改动 permissions.mode,只有你本人能改。", detail: "permissions.mode" } as WireEvent;
|
||||
}
|
||||
const elevated = kind === "elevated";
|
||||
const id = elevated ? "peers.dispatch" : "ui.show_receipt";
|
||||
return {
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
id: "s1", tool: elevated ? "setting_change_elevated" : "setting_change", subject: id, kind: "tool", fresh: true,
|
||||
allowsSession: !elevated, allowsPersist: false,
|
||||
reason: `助手请求把设置 ${id} 从 false 改为 true。${elevated ? "从下一回合起生效。" : "立即生效。"}\n它给出的理由:要把任务交给另一个项目里的会话。`,
|
||||
},
|
||||
} as WireEvent;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { chromium } from "playwright";
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
// Names the demo data itself carries, and names that are written in Chinese on purpose.
|
||||
const FIXTURE = [
|
||||
/并行会话演示/, /上一次的会话/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
|
||||
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
|
||||
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
|
||||
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Claude Code mods in the window: the band a mod draws above the prompt, a question a mod asks, the settings
|
||||
// block (switch, time limit, each mod's state by code) and the install preview's full-trust listing.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shotDir = process.env.SHOTS;
|
||||
const SHOWN = process.env.MODS_SHOWN === "1";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const band = { kind: "extension_surface", extension: { pluginId: "statusline-clock", surfaceId: "mod-above-prompt", kind: "view", view: { slot: "above_prompt", body: [
|
||||
{ kind: "row", children: [{ kind: "pip", tone: "ok" }, { kind: "text", value: "statusline-clock", tone: "strong" }, { kind: "text", value: "12:30 · 今天还剩 3 个任务", tone: "dim" }, { kind: "button", label: "稍后提醒", actionId: "snooze:5" }] },
|
||||
] } } };
|
||||
const ask = { kind: "extension_surface", extension: { pluginId: "git-guard", surfaceId: "mod-ask", kind: "form", sessionId: "s", generation: 1, form: { title: "git-guard asks", message: "要删除已经合并的分支 old-spike 吗?", fields: [{ key: "answer", label: "回答", kind: "input" }] } } };
|
||||
|
||||
// The pane attaches to its stream a moment after the page is up, and an event fed earlier is lost; feed again until it lands.
|
||||
async function feedUntil(page, ev, seen) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.evaluate((e) => window.__feed(e), ev);
|
||||
if (await seen().then(() => true, () => false)) return;
|
||||
}
|
||||
await seen();
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 420]) {
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 1300 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
|
||||
await feedUntil(page, band, () => page.getByRole("button", { name: "稍后提醒" }).waitFor({ timeout: 500 }));
|
||||
const at = await page.evaluate(() => {
|
||||
const s = document.querySelector(".slotted").getBoundingClientRect();
|
||||
const c = document.querySelector('[aria-label="任务输入"]').getBoundingClientRect();
|
||||
return { above: s.bottom <= c.top + 4, spill: s.right > innerWidth + 1 || document.documentElement.scrollWidth > innerWidth + 1 };
|
||||
});
|
||||
check(`${tag}: the band stands above the text the person types in`, at.above);
|
||||
check(`${tag}: the band does not push the page sideways`, !at.spill);
|
||||
await page.getByRole("button", { name: "稍后提醒" }).focus();
|
||||
check(`${tag}: its button can be reached from the keyboard`, await page.evaluate(() => document.activeElement?.textContent === "稍后提醒"));
|
||||
const ring = await page.evaluate(() => {
|
||||
const b = document.activeElement, rail = document.querySelector(".slotrail");
|
||||
const cs = getComputedStyle(b);
|
||||
const room = Math.min(b.getBoundingClientRect().left - rail.getBoundingClientRect().left, rail.getBoundingClientRect().right - b.getBoundingClientRect().right);
|
||||
return { ring: parseFloat(cs.outlineWidth) + Math.max(0, parseFloat(cs.outlineOffset)), room };
|
||||
});
|
||||
check(`${tag}: the focus ring of the band's button is not cut off by the strip it sits in`, ring.room >= ring.ring - 0.5, `${ring.room}px room for a ${ring.ring}px ring`);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
if (shotDir) { await page.mouse.move(width - 5, 895); await page.waitForTimeout(300); await page.screenshot({ path: `${shotDir}/${scheme}-${width}-band.png`, clip: { x: 0, y: 760, width, height: 540 } }); }
|
||||
|
||||
await feedUntil(page, ask, () => page.waitForSelector('.call[data-k="extension"] .extform', { timeout: 500 }));
|
||||
const card = await page.evaluate(() => {
|
||||
const form = document.querySelector(".extform");
|
||||
return { note: form.querySelector(".why")?.textContent ?? "", tag: document.querySelector('.call[data-k="extension"] .tag')?.textContent ?? "" };
|
||||
});
|
||||
check(`${tag}: a mod's question says whose it is and that answering approves nothing`, card.note.includes("git-guard") && card.note.includes("不会批准任何工具调用或权限") && card.tag.includes("git-guard"), card.note);
|
||||
if (shotDir && width === 1280) await page.locator('.call[data-k="extension"]').last().screenshot({ path: `${shotDir}/${scheme}-ask.png` });
|
||||
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.trim().startsWith("扩展"))?.click());
|
||||
await page.waitForSelector('[data-action="extensions.add"]');
|
||||
if (!SHOWN) {
|
||||
check(`${tag}: the mod settings block is not drawn while the host is hidden`, (await page.locator("#set-mods").count()) === 0);
|
||||
await page.locator('input[aria-label="搜索设置"]').fill("Claude Code mod");
|
||||
await page.waitForTimeout(400);
|
||||
const found = await page.evaluate(() => [...document.querySelectorAll(".prefs-nav *, .prefs-results *")].some((e) => e.children.length === 0 && /Claude Code mod/.test(e.textContent)));
|
||||
check(`${tag}: settings search does not lead to it either`, !found);
|
||||
await page.locator('input[aria-label="搜索设置"]').fill("");
|
||||
} else {
|
||||
await page.waitForSelector("#set-mods");
|
||||
const off = await page.evaluate(() => {
|
||||
const g = document.getElementById("set-mods");
|
||||
return { checked: g.querySelector('[role="switch"]').getAttribute("aria-checked"), rows: g.querySelectorAll(".modlist li").length, why: [...g.querySelectorAll(".modlist .ds")].filter((e) => e.textContent.includes("没有打开")).length };
|
||||
});
|
||||
check(`${tag}: the switch ships off and every mod says why it is not running`, off.checked === "false" && off.rows === 4 && off.why === 4, `${off.rows} mods, ${off.why} reasons`);
|
||||
await page.locator('#set-mods [role="switch"]').click();
|
||||
await page.waitForFunction(() => document.querySelectorAll("#set-mods .modlist li[data-state='running']").length === 1);
|
||||
const on = await page.evaluate(() => {
|
||||
const g = document.getElementById("set-mods");
|
||||
const r = g.getBoundingClientRect();
|
||||
const states = [...g.querySelectorAll(".modlist li")].map((li) => li.getAttribute("data-state"));
|
||||
return { states, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1, marks: [...g.querySelectorAll(".modstate")].every((e) => /^\S+\s+\S+/.test(e.textContent.trim())) };
|
||||
});
|
||||
check(`${tag}: each state shows running, disabled, restarting or not running, with a mark as well as a word`, on.states.join() === "running,disabled,restarting,inactive" && on.marks, on.states.join());
|
||||
check(`${tag}: the block does not spill sideways`, !on.spill);
|
||||
if (shotDir) await page.locator("#set-mods").screenshot({ path: `${shotDir}/${scheme}-${width}-settings.png` });
|
||||
}
|
||||
|
||||
if (width === 1280) {
|
||||
await page.locator('[data-action="extensions.add"]').first().click();
|
||||
const source = page.locator("textarea, input").filter({ hasNot: page.locator("[aria-label]") }).last();
|
||||
await page.locator('textarea[placeholder*="github.com"], input[placeholder*="github.com"]').first().fill("https://github.com/acme/claude-code mod git-guard");
|
||||
await page.locator('[data-action="extensions.inspect"]').first().click();
|
||||
await page.waitForSelector(".modpreview", { timeout: 10000 });
|
||||
const prev = await page.evaluate(() => {
|
||||
const m = document.querySelector(".modpreview");
|
||||
const text = m.textContent;
|
||||
return { trust: text.includes("完全信任"), static: text.includes("不是保证"), imports: text.includes("看不见"), unserved: text.includes("本应用不会触发的事件"), hash: !!m.querySelector("[title]") };
|
||||
});
|
||||
check(`${tag}: the install preview states full trust, that it is a static listing, hidden imports and unserved events`, prev.trust && prev.static && prev.imports && prev.unserved && prev.hash, JSON.stringify(prev));
|
||||
if (shotDir) await page.locator(".cand").first().screenshot({ path: `${shotDir}/${scheme}-preview.png` });
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,64 @@
|
||||
// The rail's search box stays open while it holds a query: pressing a result takes focus off the box, and a
|
||||
// box that folds away on blur moves the list up under the pointer, so the first press never became a click.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shotDir = process.env.SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=2&search=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const state = () => page.evaluate(() => {
|
||||
const box = document.querySelector(".wsfind");
|
||||
const cs = getComputedStyle(box);
|
||||
return { shown: cs.opacity === "1" && cs.position === "relative", top: Math.round(box.getBoundingClientRect().top) };
|
||||
});
|
||||
|
||||
await page.getByRole("searchbox").fill("轮换");
|
||||
await page.waitForSelector(".content-matches li");
|
||||
const hit = page.locator(".content-matches li button[data-action='session.open-match']").first();
|
||||
const before = await hit.boundingBox();
|
||||
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-typed.png`, clip: { x: 0, y: 0, width: 420, height: 520 } });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__events = [];
|
||||
for (const ev of ["mousedown", "mouseup", "click"]) {
|
||||
window.addEventListener(ev, (e) => window.__events.push(`${ev} ${e.target.tagName}[${e.target.closest?.("[data-action]")?.getAttribute("data-action") ?? ""}]`), true);
|
||||
}
|
||||
});
|
||||
await page.mouse.move(before.x + 20, before.y + before.height - 8);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(200);
|
||||
const held = await state();
|
||||
const moved = (await hit.boundingBox()).y - before.y;
|
||||
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-pressed.png`, clip: { x: 0, y: 0, width: 420, height: 520 } });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const events = await page.evaluate(() => window.__events);
|
||||
|
||||
check(`${scheme}: the search box stays open after focus leaves it, because it holds a query`, held.shown);
|
||||
check(`${scheme}: pressing a result does not move the list under the pointer`, Math.abs(moved) < 1, `${moved}px`);
|
||||
check(`${scheme}: the first press on a result becomes a click on that result`, events.some((e) => e.startsWith("click ") && e.endsWith("[session.open-match]")), events.join(" | "));
|
||||
|
||||
await page.getByRole("searchbox").fill("");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.mouse.click(700, 500);
|
||||
await page.waitForTimeout(250);
|
||||
const idle = await state();
|
||||
check(`${scheme}: with no query the box still folds away when it loses focus`, !idle.shown);
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -59,9 +59,10 @@ for (const [ws, sess] of SHAPES) {
|
||||
const after = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
|
||||
|
||||
// 展开一个折叠的工作区,量这一下点击有多久才落地。
|
||||
const twist = page.locator(".wsnode").nth(1).locator("button.twist");
|
||||
const project = page.locator(".wsnode > .wsrow").nth(1);
|
||||
if (await project.getAttribute("aria-expanded") === "true") await project.click();
|
||||
const tExpand = Date.now();
|
||||
await twist.click();
|
||||
await project.click();
|
||||
await page.waitForTimeout(50);
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const expand = Date.now() - tExpand;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// A conversation can be moved to another project from its menu: the dialog says what travels and what does not,
|
||||
// lists the other projects, and asks the kernel exactly once for the one chosen.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shotDir = process.env.SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 420]) {
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&ws=3&sess=3&turns=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const rows = '.sessrow[role="treeitem"][data-target]';
|
||||
await page.waitForSelector(rows);
|
||||
const first = await page.locator(rows).first().getAttribute("data-target");
|
||||
await page.evaluate((sel) => document.querySelector(sel).dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 140, clientY: 300 })), rows);
|
||||
await page.getByRole("menuitem", { name: "移动到项目…" }).click();
|
||||
await page.waitForSelector('[role="dialog"] select');
|
||||
const dlg = await page.evaluate(() => {
|
||||
const d = document.querySelector('[role="dialog"]:has(select)');
|
||||
const card = d.querySelector(".pickcard").getBoundingClientRect();
|
||||
return { options: [...d.querySelectorAll("option")].map((o) => o.textContent), text: d.textContent, fits: card.left >= 0 && card.right <= innerWidth + 1 && card.bottom <= innerHeight + 1, focusIn: d.contains(document.activeElement) };
|
||||
});
|
||||
check(`${tag}: it lists the other projects but not the one it is in`, dlg.options.length === 2 && !dlg.options.some((o) => o.startsWith("workspace-0")), dlg.options.join(" | "));
|
||||
check(`${tag}: it says what travels and what does not`, dlg.text.includes("原项目的权限与目标设置不会跟着走") && dlg.text.includes("路径保持原样"));
|
||||
check(`${tag}: the dialog fits the window and holds focus`, dlg.fits && dlg.focusIn, JSON.stringify({ fits: dlg.fits, focusIn: dlg.focusIn }));
|
||||
if (shotDir) await page.mouse.move(width - 3, 895), await page.waitForTimeout(250), await page.screenshot({ path: `${shotDir}/${scheme}-${width}-dialog.png` });
|
||||
await page.locator('[role="dialog"] select').selectOption({ index: 1 });
|
||||
await page.getByRole("button", { name: "移动", exact: true }).click();
|
||||
await page.waitForFunction(() => (window.__moves ?? []).length === 1);
|
||||
const moves = await page.evaluate(() => window.__moves);
|
||||
check(`${tag}: the kernel is asked once, for this conversation and the chosen project`, moves.length === 1 && moves[0].path === first && moves[0].target.endsWith("workspace-2"), JSON.stringify(moves));
|
||||
await page.waitForFunction(() => !document.querySelector('[role="dialog"]:has(select)'));
|
||||
check(`${tag}: the dialog closes when it is done`, true);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,60 @@
|
||||
// A conversation can be moved one place up or down in its project's list from its menu or with Alt+Arrow,
|
||||
// the new order is the kernel's (it is read again, not patched), and the menu says where a step has nowhere to go.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shotDir = process.env.SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280]) {
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&ws=1&sess=4&turns=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const rows = '.wsnode:has(> .wsrow[data-target="~/projects/workspace-0"]) .sessrow[role="treeitem"][data-target]';
|
||||
const remoteRows = '.sessrow-remote[role="treeitem"][data-target]';
|
||||
const order = () => page.evaluate((sel) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")), rows);
|
||||
await page.waitForSelector(rows);
|
||||
const start = await order();
|
||||
const remoteOrder = () => page.locator(remoteRows).evaluateAll((els) => els.map((el) => el.dataset.target));
|
||||
const remoteStart = await remoteOrder();
|
||||
check(`${tag}: SSH conversations share the list`, remoteStart.length > 0, remoteStart.join());
|
||||
check(`${tag}: the project lists its four conversations`, start.length === 4, start.join());
|
||||
|
||||
const first = page.locator(rows).first();
|
||||
await first.click({ button: "right" });
|
||||
const menu = await page.evaluate(() => {
|
||||
const items = [...document.querySelectorAll('[role="menuitem"][data-action="session.move"]')];
|
||||
return { labels: items.map((b) => b.textContent.trim()), disabled: items.map((b) => b.disabled) };
|
||||
});
|
||||
check(`${tag}: the first conversation can only go down`, menu.labels.join() === "上移,下移" && menu.disabled.join() === "true,false", JSON.stringify(menu));
|
||||
if (shotDir) await page.locator(".session-pop").screenshot({ path: `${shotDir}/${scheme}-menu.png` });
|
||||
await page.locator('[data-action="session.move"][data-value="1"]').click();
|
||||
await page.waitForFunction(([sel, s]) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")).join() !== s, [rows, start.join()]);
|
||||
const moved = await order();
|
||||
check(`${tag}: moving it down puts it second, the rest keep their order`, moved.join() === [start[1], start[0], start[2], start[3]].join(), moved.join());
|
||||
|
||||
await page.locator(rows).nth(1).focus();
|
||||
await page.keyboard.press("Alt+ArrowUp");
|
||||
await page.waitForFunction(([sel, s]) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")).join() === s, [rows, start.join()]);
|
||||
check(`${tag}: Alt+Up on the focused row puts it back first`, (await order()).join() === start.join());
|
||||
check(`${tag}: sorting local conversations preserves the SSH rows`, JSON.stringify(await remoteOrder()) === JSON.stringify(remoteStart));
|
||||
check(`${tag}: sorting keeps focus on the moved row`, await page.locator(rows).first().evaluate((el) => el === document.activeElement));
|
||||
await page.mouse.move(width - 3, 895);
|
||||
if (shotDir) await page.locator(".rail").screenshot({ path: `${shotDir}/${scheme}-rail.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,104 @@
|
||||
// A past conversation is referenced from the message box: "#" lists this
|
||||
// workspace's conversations, a pick becomes a removable chip, a search hit in
|
||||
// the rail can be referenced too, and the size limit is a real control.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const shotDir = process.env.SHOTS;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 420]) {
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=2&search=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
|
||||
const box = page.getByRole("combobox", { name: "任务输入" });
|
||||
await box.click();
|
||||
await box.pressSequentially("接着上次的讨论 #");
|
||||
await page.waitForSelector(".slashmenu .mi");
|
||||
const menu = await page.evaluate(() => {
|
||||
const m = document.querySelector(".slashmenu");
|
||||
const r = m.getBoundingClientRect();
|
||||
const rows = [...m.querySelectorAll(".mi")];
|
||||
return {
|
||||
rows: rows.length,
|
||||
kinds: [...new Set(rows.map((x) => x.querySelector(".rt")?.textContent))],
|
||||
spill: r.left < -1 || r.right > innerWidth + 1,
|
||||
hints: rows.every((x) => !!x.querySelector(".ds")),
|
||||
};
|
||||
});
|
||||
check(`${tag}: # lists past conversations marked as conversations`, menu.rows >= 2 && menu.kinds.length === 1 && menu.kinds[0] === "会话", `${menu.rows} rows ${menu.kinds}`);
|
||||
check(`${tag}: each row says when it was`, menu.hints);
|
||||
check(`${tag}: the menu stays on screen`, !menu.spill);
|
||||
if (shotDir && width === 1280) {
|
||||
const area = await page.evaluate(() => {
|
||||
const a = document.querySelector(".compose").getBoundingClientRect(), m = document.querySelector(".slashmenu").getBoundingClientRect();
|
||||
const x = Math.min(a.left, m.left), y = Math.min(a.top, m.top);
|
||||
return { x, y, width: Math.max(a.right, m.right) - x, height: Math.max(a.bottom, m.bottom) - y };
|
||||
});
|
||||
await page.screenshot({ path: `${shotDir}/${scheme}-menu.png`, clip: area });
|
||||
}
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(".shots .shot");
|
||||
const chip = await page.evaluate(() => {
|
||||
const c = document.querySelector(".shots .shot");
|
||||
const ta = document.querySelector('[aria-label="任务输入"]');
|
||||
const r = c.getBoundingClientRect();
|
||||
return { title: c.querySelector(".nm")?.textContent, sub: c.querySelector(".sz")?.textContent, text: ta.value, spill: r.right > innerWidth + 1, remove: !!c.querySelector(".x") };
|
||||
});
|
||||
check(`${tag}: picking one makes a chip titled with it and takes the # word out of the box`, !!chip.title && chip.text === "接着上次的讨论 " && chip.sub === "历史会话 · 只读资料", `${chip.title} | "${chip.text}"`);
|
||||
check(`${tag}: the chip can be removed and does not spill`, chip.remove && !chip.spill);
|
||||
if (shotDir && width === 1280) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-chip.png` });
|
||||
|
||||
if (width === 1280) {
|
||||
await page.getByRole("searchbox").fill("轮换");
|
||||
await page.waitForSelector(".content-matches li", { timeout: 8000 });
|
||||
const hit = await page.evaluate(() => {
|
||||
const li = document.querySelector(".content-matches li");
|
||||
const [open, ref] = li.querySelectorAll("button");
|
||||
const o = open.getBoundingClientRect(), r = ref.getBoundingClientRect();
|
||||
return { has: !!ref, size: Math.round(r.width) + "x" + Math.round(r.height), label: ref?.getAttribute("aria-label") ?? "", beside: r.left >= o.right - 1 && Math.abs(r.top - o.top) < 2, tall: r.height >= 28 };
|
||||
});
|
||||
check(`${tag}: a search hit offers to reference it, beside the open button`, hit.has && hit.beside && /引用「.+」到输入框/.test(hit.label), `${hit.size} ${hit.label}`);
|
||||
check(`${tag}: that button is at least 28px tall`, hit.tall, hit.size);
|
||||
if (shotDir) await page.locator(".content-matches").screenshot({ path: `${shotDir}/${scheme}-hit.png` });
|
||||
const refButton = page.locator(".content-matches li button[data-action='session.reference-match']").first();
|
||||
await refButton.focus();
|
||||
await page.waitForTimeout(200);
|
||||
const refBox = await refButton.boundingBox();
|
||||
await page.mouse.click(refBox.x + refBox.width / 2, refBox.y + refBox.height / 2);
|
||||
await page.waitForFunction(() => document.querySelectorAll(".shots .shot").length === 2);
|
||||
check(`${tag}: referencing a hit adds a second chip to the message box`, true);
|
||||
}
|
||||
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.trim().startsWith("会话"))?.click());
|
||||
await page.waitForSelector("#set-session-ref", { timeout: 8000 });
|
||||
const s = await page.evaluate(() => {
|
||||
const g = document.getElementById("set-session-ref");
|
||||
const input = g.querySelector("input");
|
||||
const r = g.getBoundingClientRect();
|
||||
return { value: input?.value, unit: g.querySelector("i")?.textContent, restore: g.querySelector('[data-action="session-ref.reset"]')?.disabled, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1 };
|
||||
});
|
||||
check(`${tag}: the size limit is a number field at its default, with nothing to restore`, s.value === "8000" && s.unit === "token" && s.restore === true, `${s.value} ${s.unit}`);
|
||||
check(`${tag}: the setting does not spill sideways`, !s.spill);
|
||||
if (shotDir) await page.locator("#set-session-ref").screenshot({ path: `${shotDir}/${scheme}-${width}-setting.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const sheets = await Promise.all(["tokens", "app", "studio", "sessionquick", "sessionpin"].map(
|
||||
const sheets = await Promise.all(["tokens", "app", "studio", "sessionquick", "sessionpin", "treerows"].map(
|
||||
(name) => readFile(new URL(`../src/styles/${name}.css`, import.meta.url), "utf8"),
|
||||
));
|
||||
const title = "这是什么? @.reasonix/attachments/clipboard-" + "long-title".repeat(20);
|
||||
@@ -14,8 +14,9 @@ try {
|
||||
await page.setContent(`<div class="rail" style="width:${width}px">
|
||||
<div class="kids">
|
||||
<div class="sessrow sessrow-context" tabindex="0" aria-selected="true">
|
||||
<span class="sesstitle" title="${title}"><i class="sesspin"></i><span>${title}</span><em class="sessarch">已归档</em></span>
|
||||
<div class="sessquick"><button>归档</button><button>删除</button></div>
|
||||
<button class="sesspin-action" data-action="session.pin">置顶</button>
|
||||
<span class="sesstitle" title="${title}"><span>${title}</span><em class="sessarch">已归档</em></span>
|
||||
<span class="sessquick"><button data-action="session.archive">归档</button></span>
|
||||
</div>
|
||||
<div class="sessrow sessrow-context" data-unread tabindex="0">
|
||||
<span class="sesstitle" title="短标题"><span>短标题</span></span><span class="unread-dot"></span>
|
||||
@@ -52,12 +53,12 @@ try {
|
||||
assert.ok(row.textHeight <= row.lineHeight + 1);
|
||||
assert.ok(row.fullTitle);
|
||||
}
|
||||
assert.equal(rows[1].top, rows[0].bottom, "no gap between sessions");
|
||||
assert.equal(rows[2].top, rows[1].bottom, "no gap before remote session");
|
||||
assert.equal(rows[3].top, rows[2].bottom, "no gap before earlier version");
|
||||
assert.equal(rows[1].top - rows[0].bottom, 1, "only the compact row margin separates sessions");
|
||||
assert.equal(rows[2].top - rows[1].bottom, 1, "the remote row has the same compact spacing");
|
||||
assert.equal(rows[3].top - rows[2].bottom, 1, "the earlier version has the same compact spacing");
|
||||
assert.ok(rows[0].truncated && rows[2].truncated, "long local and remote titles truncate");
|
||||
if (state !== "idle") {
|
||||
const contained = await page.locator(".sessquick > button").evaluateAll((buttons) => buttons.every((button) => {
|
||||
const contained = await page.locator(".sessquick > button, .sesspin-action").evaluateAll((buttons) => buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const row = button.closest(".sessrow").getBoundingClientRect();
|
||||
return rect.top >= row.top && rect.bottom <= row.bottom && rect.left >= row.left && rect.right <= row.right;
|
||||
@@ -65,7 +66,7 @@ try {
|
||||
assert.ok(contained, "quick actions fit inside the compact row");
|
||||
}
|
||||
}
|
||||
console.log(`${scheme}/${width}: 34px rows, no gaps, single-line ellipsis, hover/focus stable`);
|
||||
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Session rows carry hover Archive and Delete buttons only when this machine turned them on. The reveal is a
|
||||
// width and opacity transition on :hover, so whether the buttons are really visible and hit-testable is
|
||||
// a question for a real browser.
|
||||
// Opt-in session actions put Pin before the title and Archive after it.
|
||||
// Their visibility, geometry and pointer targets need a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
@@ -14,34 +13,56 @@ const check = (name, ok, detail = "") => {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const on of [false, true]) {
|
||||
for (const on of [null, false, true]) {
|
||||
const mode = on === null ? "default" : on ? "on" : "off";
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 800 } });
|
||||
if (on) await ctx.addInitScript(() => localStorage.setItem("rx-session-quick-actions", "on"));
|
||||
if (on !== null) await ctx.addInitScript((enabled) => localStorage.setItem("rx-session-quick-actions", enabled ? "on" : "off"), on);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push(e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".sessrow", { timeout: 20000 });
|
||||
const row = page.locator(".sessrow").first();
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.move(box.x + 40, box.y + box.height / 2);
|
||||
await page.waitForTimeout(300);
|
||||
const quick = page.locator(".sessrow").first().locator(".sessquick");
|
||||
if (!on) {
|
||||
check(`${scheme}: off draws no quick buttons on a hovered row`, (await quick.count()) === 0);
|
||||
} else {
|
||||
const state = await quick.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const [archive, trash] = el.querySelectorAll("button");
|
||||
const hit = (b) => {
|
||||
const q = b.getBoundingClientRect();
|
||||
const at = document.elementFromPoint(q.left + q.width / 2, q.top + q.height / 2);
|
||||
return at === b || b.contains(at);
|
||||
};
|
||||
return { opacity: Number(getComputedStyle(el).opacity), width: r.width, archive: hit(archive), trash: hit(trash) };
|
||||
});
|
||||
check(`${scheme}: on reveals both buttons on a hovered row`, state.opacity === 1 && state.width > 40 && state.archive && state.trash, JSON.stringify(state));
|
||||
for (const kind of ["context", "remote"]) {
|
||||
const tag = `${scheme}/${mode}/${kind}`;
|
||||
const row = page.locator(`.sessrow-${kind}`).first();
|
||||
await row.hover();
|
||||
await page.waitForTimeout(150);
|
||||
const quick = row.locator('[data-action="session.pin"], [data-action="session.archive"]');
|
||||
if (!on) {
|
||||
check(`${tag}: hover draws no quick buttons`, (await quick.count()) === 0);
|
||||
} else {
|
||||
const state = await row.evaluate((el) => {
|
||||
const title = el.querySelector(".sesstitle > span").getBoundingClientRect();
|
||||
const pin = el.querySelector('[data-action="session.pin"]');
|
||||
const archive = el.querySelector('[data-action="session.archive"]');
|
||||
const hit = (button) => {
|
||||
if (!button) return false;
|
||||
const r = button.getBoundingClientRect();
|
||||
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
let opacity = 1;
|
||||
for (let node = button; node && node !== el; node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
|
||||
return opacity === 1 && r.width > 0 && r.height > 0 && !button.disabled && (at === button || button.contains(at));
|
||||
};
|
||||
return { pin: hit(pin), archive: hit(archive), leading: pin.getBoundingClientRect().right <= title.left,
|
||||
trailing: archive.getBoundingClientRect().left >= title.right - 1 };
|
||||
});
|
||||
check(`${tag}: leading Pin and trailing Archive are hit-testable`, state.pin && state.archive && state.leading && state.trailing, JSON.stringify(state));
|
||||
await page.mouse.move(1398, 798);
|
||||
await row.locator('[data-action="session.pin"]').focus();
|
||||
await page.waitForTimeout(150);
|
||||
check(`${tag}: keyboard focus reveals both actions`, await quick.evaluateAll((buttons) => buttons.every((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
let opacity = 1;
|
||||
for (let node = b; node && !node.classList.contains("sessrow"); node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
|
||||
return opacity === 1 && (b === hit || b.contains(hit));
|
||||
})));
|
||||
}
|
||||
await row.click({ button: "right" });
|
||||
check(`${tag}: the menu retains Pin and Archive`, await page.getByRole("menuitem", { name: "置顶会话", exact: true }).count() === 1 && await page.getByRole("menuitem", { name: "归档会话", exact: true }).count() === 1);
|
||||
check(`${tag}: Delete stays in the menu`, await page.getByRole("menuitem", { name: "删除会话", exact: true }).count() === 1);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/sessquick-${on ? "on" : "off"}-${scheme}.png`, clip: { x: 0, y: 0, width: 420, height: 800 } });
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/sessquick-${mode}-${scheme}.png`, clip: { x: 0, y: 0, width: 420, height: 800 } });
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,17 +276,17 @@ await page.goto(`${PAGE}?ws=6&sess=200`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".wsnode", { timeout: 20000 });
|
||||
await page.waitForTimeout(600);
|
||||
const firstWs = page.locator(".wsnode").first();
|
||||
check("大树只列最近的", (await firstWs.locator(".sessrow").count()) === 30, `${await firstWs.locator(".sessrow").count()} 行`);
|
||||
check("大树只列最近的", (await firstWs.locator(".sessrow").count()) === 5, `${await firstWs.locator(".sessrow").count()} 行`);
|
||||
check("有「全部显示」", await firstWs.locator("button.sessmore").isVisible());
|
||||
await firstWs.locator("button.sessmore").click();
|
||||
await page.waitForTimeout(400);
|
||||
check("展开后列全", (await firstWs.locator(".sessrow").count()) === 200, `${await firstWs.locator(".sessrow").count()} 行`);
|
||||
await firstWs.locator("button.twist").click();
|
||||
await firstWs.locator(":scope > .wsrow").click();
|
||||
await page.waitForTimeout(300);
|
||||
check("折叠后收起", (await firstWs.locator(".sessrow").count()) === 0);
|
||||
await page.screenshot({ path: `${SHOTS}/6-大会话树.png` });
|
||||
// Only the workspace in front opens on first load; the next one is unfolded by hand.
|
||||
await page.locator(".wsnode").nth(1).locator(".wsrow").click();
|
||||
const nextProject = page.locator(".wsnode > .wsrow").nth(1);
|
||||
if (await nextProject.getAttribute("aria-expanded") !== "true") await nextProject.click();
|
||||
await page.waitForTimeout(300);
|
||||
const second = page.locator(".wsnode").nth(1).locator(".sessrow").first();
|
||||
await second.click();
|
||||
|
||||
@@ -32,6 +32,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
const wide = [...g.querySelectorAll(".box *")].filter((e) => e.getBoundingClientRect().right > box.right + 1).map((e) => e.className || e.tagName);
|
||||
return {
|
||||
lists,
|
||||
video: g.querySelector('[data-action="web-access.video-url"]')?.getAttribute("aria-checked"),
|
||||
consent: consent.map((b) => [b.getAttribute("aria-checked"), b.disabled]),
|
||||
hint: g.textContent.includes("需要一个带密钥的 DeepSeek 提供方"),
|
||||
applies: g.textContent.includes("之后新开的窗格"),
|
||||
@@ -40,7 +41,8 @@ for (const scheme of ["light", "dark"]) {
|
||||
scroll: g.querySelector(".box").scrollWidth - g.querySelector(".box").clientWidth,
|
||||
};
|
||||
});
|
||||
check(`${tag}: search and fetch are two ordered lists, the paid backend after the free one`, r.lists.length === 2 && r.lists[0][1][0] === "SearXNG" && r.lists[0][1][1] === "DeepSeek 官方搜索" && r.lists[1][1][0] === "本机抓取" && r.lists[1][1][1] === "DeepSeek 官方抓取", JSON.stringify(r.lists));
|
||||
check(`${tag}: search and fetch are two ordered lists, the paid backend after the free one`, r.lists.length === 3 && r.lists[0][1][0] === "SearXNG" && r.lists[0][1][1] === "DeepSeek 官方搜索" && r.lists[1][1][0] === "本机抓取" && r.lists[1][1][1] === "DeepSeek 官方抓取", JSON.stringify(r.lists));
|
||||
check(`${tag}: the video address list is its own third list with one switch, off until the person turns it on`, r.lists[2]?.[0] === "视频地址" && r.lists[2][1].length === 1 && r.lists[2][1][0] === "把视频地址交给模型厂商" && r.video === "false", JSON.stringify([r.lists[2], r.video]));
|
||||
check(`${tag}: no consent is ticked for the person`, r.consent.length === 2 && r.consent.every(([on]) => on === "false"), JSON.stringify(r.consent));
|
||||
check(`${tag}: the consent is ${ready === "noready" ? "inert and says why" : "live"}`, ready === "noready" ? r.consent.every(([, off]) => off) && r.hint : r.consent.every(([, off]) => !off) && !r.hint);
|
||||
check(`${tag}: it says when a change applies and where it is kept`, r.applies && r.path);
|
||||
|
||||
Generated
+192
-219
@@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
'@a2ui/react':
|
||||
specifier: 0.13.0
|
||||
version: 0.13.0([email protected].0([email protected].0))([email protected].0)([email protected])
|
||||
version: 0.13.0([email protected].1([email protected].1))([email protected].1)([email protected])
|
||||
'@a2ui/web_core':
|
||||
specifier: 0.13.0
|
||||
version: 0.13.0
|
||||
@@ -70,13 +70,13 @@ importers:
|
||||
version: 1.2.5
|
||||
'@mantine/charts':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0))(@mantine/[email protected]([email protected].0))([email protected].0([email protected].0))([email protected].0)([email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]))
|
||||
version: 9.7.2(@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1))(@mantine/[email protected]([email protected].1))([email protected].1([email protected].1))([email protected].1)([email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]))
|
||||
'@mantine/core':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
version: 9.7.2(@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
'@mantine/hooks':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2([email protected].0)
|
||||
version: 9.7.2([email protected].1)
|
||||
'@noble/curves':
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
@@ -90,20 +90,20 @@ importers:
|
||||
specifier: 6.14.0
|
||||
version: 6.14.0
|
||||
react:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
specifier: ^19.3.1
|
||||
version: 19.3.1
|
||||
react-dom:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0([email protected].0)
|
||||
specifier: ^19.3.1
|
||||
version: 19.3.1([email protected].1)
|
||||
react-map-gl:
|
||||
specifier: 8.1.3
|
||||
version: 8.1.3([email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
version: 8.1.3([email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/[email protected])([email protected].0)
|
||||
version: 10.1.0(@types/[email protected])([email protected].1)
|
||||
recharts:
|
||||
specifier: 3.10.1
|
||||
version: 3.10.1(@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected])
|
||||
version: 3.10.1(@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected])
|
||||
rehype-highlight:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
@@ -134,10 +134,10 @@ importers:
|
||||
version: 8.0.7
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.3
|
||||
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.7
|
||||
version: 14.6.7(@testing-library/[email protected])
|
||||
specifier: ^14.6.8
|
||||
version: 14.6.8(@testing-library/[email protected])
|
||||
'@types/react':
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
@@ -146,22 +146,22 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2([email protected].3([email protected]))
|
||||
version: 6.1.2([email protected].4([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.2
|
||||
version: 30.1.2(@noble/[email protected])
|
||||
specifier: ^30.1.3
|
||||
version: 30.1.3(@noble/[email protected])
|
||||
playwright:
|
||||
specifier: ^1.64.0
|
||||
version: 1.64.0
|
||||
specifier: ^1.64.1
|
||||
version: 1.64.1
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.3
|
||||
version: 8.3.3([email protected])
|
||||
specifier: ^8.3.4
|
||||
version: 8.3.4([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3([email protected].2(@noble/[email protected]))([email protected].3([email protected]))
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4([email protected].3(@noble/[email protected]))([email protected].4([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -185,8 +185,8 @@ packages:
|
||||
resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
'@asamuzakjp/dom-selector@9.2.4':
|
||||
resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
|
||||
'@asamuzakjp/dom-selector@10.0.3':
|
||||
resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
'@babel/[email protected]':
|
||||
@@ -474,15 +474,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -783,8 +774,8 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@testing-library/[email protected].7':
|
||||
resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
|
||||
'@testing-library/[email protected].8':
|
||||
resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
@@ -1028,10 +1019,10 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
msw: ^2.4.9 || ^3.0.0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
@@ -1039,8 +1030,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
@@ -1083,8 +1074,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
chai@6.3.0:
|
||||
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1396,8 +1387,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.2.3
|
||||
@@ -1527,8 +1518,8 @@ packages:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
|
||||
@@ -1691,13 +1682,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
|
||||
|
||||
[email protected].18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
[email protected].20:
|
||||
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
obug@3.0.0:
|
||||
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1720,18 +1711,18 @@ packages:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected].28:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
[email protected].30:
|
||||
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
@@ -1758,10 +1749,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
|
||||
peerDependencies:
|
||||
react: ^19.3.0
|
||||
react: ^19.3.1
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
@@ -1833,8 +1824,8 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1937,6 +1928,10 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
@@ -1944,8 +1939,8 @@ packages:
|
||||
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
std-env@4.3.0:
|
||||
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
@@ -1969,12 +1964,12 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@6.1.4:
|
||||
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
|
||||
tinybench@6.2.2:
|
||||
resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1995,10 +1990,6 @@ packages:
|
||||
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
@@ -2030,8 +2021,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
[email protected]1.2:
|
||||
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
|
||||
[email protected]2.1:
|
||||
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2102,8 +2093,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2145,20 +2136,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.3
|
||||
'@vitest/browser-preview': 5.0.3
|
||||
'@vitest/browser-playwright': 5.0.4
|
||||
'@vitest/browser-preview': 5.0.4
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.3
|
||||
'@vitest/coverage-v8': 5.0.3
|
||||
'@vitest/ui': 5.0.3
|
||||
'@vitest/coverage-istanbul': 5.0.4
|
||||
'@vitest/coverage-v8': 5.0.4
|
||||
'@vitest/ui': 5.0.4
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -2204,10 +2195,6 @@ packages:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
@@ -2243,14 +2230,14 @@ snapshots:
|
||||
dompurify: 3.4.11
|
||||
markdown-it: 14.3.2
|
||||
|
||||
'@a2ui/[email protected]([email protected].0([email protected].0))([email protected].0)([email protected])':
|
||||
'@a2ui/[email protected]([email protected].1([email protected].1))([email protected].1)([email protected])':
|
||||
dependencies:
|
||||
'@a2ui/markdown-it': 0.2.1(@a2ui/[email protected])
|
||||
'@a2ui/web_core': 0.13.0
|
||||
clsx: 2.1.1
|
||||
markdown-it: 14.3.2
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
zod: 3.25.76
|
||||
|
||||
'@a2ui/[email protected]':
|
||||
@@ -2271,7 +2258,7 @@ snapshots:
|
||||
'@csstools/css-tokenizer': 4.0.2
|
||||
lru-cache: 11.5.3
|
||||
|
||||
'@asamuzakjp/dom-selector@9.2.4':
|
||||
'@asamuzakjp/dom-selector@10.0.3':
|
||||
dependencies:
|
||||
bidi-js: 1.1.0
|
||||
css-tree: 3.2.1
|
||||
@@ -2555,10 +2542,6 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected](@noble/[email protected])':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
|
||||
'@exodus/[email protected](@noble/[email protected])':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
@@ -2572,18 +2555,18 @@ snapshots:
|
||||
'@floating-ui/core': 1.8.0
|
||||
'@floating-ui/utils': 0.2.12
|
||||
|
||||
'@floating-ui/[email protected]([email protected].0([email protected].0))([email protected].0)':
|
||||
'@floating-ui/[email protected]([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.8.0
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
|
||||
'@floating-ui/[email protected]([email protected].0([email protected].0))([email protected].0)':
|
||||
'@floating-ui/[email protected]([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.9([email protected].0([email protected].0))([email protected].0)
|
||||
'@floating-ui/react-dom': 2.1.9([email protected].1([email protected].1))([email protected].1)
|
||||
'@floating-ui/utils': 0.2.12
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
tabbable: 6.5.0
|
||||
|
||||
'@floating-ui/[email protected]': {}
|
||||
@@ -2682,30 +2665,30 @@ snapshots:
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.6.0
|
||||
|
||||
'@mantine/[email protected](@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0))(@mantine/[email protected]([email protected].0))([email protected].0([email protected].0))([email protected].0)([email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]))':
|
||||
'@mantine/[email protected](@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1))(@mantine/[email protected]([email protected].1))([email protected].1([email protected].1))([email protected].1)([email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]))':
|
||||
dependencies:
|
||||
'@mantine/core': 9.7.2(@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
'@mantine/hooks': 9.7.2([email protected].0)
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
recharts: 3.10.1(@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected])
|
||||
'@mantine/core': 9.7.2(@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
'@mantine/hooks': 9.7.2([email protected].1)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
recharts: 3.10.1(@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected])
|
||||
|
||||
'@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)':
|
||||
'@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20([email protected].0([email protected].0))([email protected].0)
|
||||
'@mantine/hooks': 9.7.2([email protected].0)
|
||||
'@floating-ui/react': 0.27.20([email protected].1([email protected].1))([email protected].1)
|
||||
'@mantine/hooks': 9.7.2([email protected].1)
|
||||
clsx: 2.1.1
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react-number-format: 5.4.5([email protected].0([email protected].0))([email protected].0)
|
||||
react-remove-scroll: 2.7.2(@types/[email protected])([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
react-number-format: 5.4.5([email protected].1([email protected].1))([email protected].1)
|
||||
react-remove-scroll: 2.7.2(@types/[email protected])([email protected].1)
|
||||
type-fest: 5.10.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/[email protected]([email protected].0)':
|
||||
'@mantine/[email protected]([email protected].1)':
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
|
||||
'@mapbox/[email protected]': {}
|
||||
|
||||
@@ -2767,7 +2750,7 @@ snapshots:
|
||||
|
||||
'@preact/[email protected]': {}
|
||||
|
||||
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected].0)([email protected]))([email protected].0)':
|
||||
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected].1)([email protected]))([email protected].1)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
@@ -2776,8 +2759,8 @@ snapshots:
|
||||
redux-thunk: 3.1.0([email protected])
|
||||
reselect: 5.2.0
|
||||
optionalDependencies:
|
||||
react: 19.3.0
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].0)([email protected])
|
||||
react: 19.3.1
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].1)([email protected])
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
optional: true
|
||||
@@ -2841,17 +2824,17 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)':
|
||||
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@testing-library/dom': 10.4.1
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
'@types/react-dom': 19.3.0(@types/[email protected])
|
||||
|
||||
'@testing-library/[email protected].7(@testing-library/[email protected])':
|
||||
'@testing-library/[email protected].8(@testing-library/[email protected])':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
||||
@@ -2990,34 +2973,34 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@vis.gl/[email protected]([email protected].0([email protected].0))([email protected].0)':
|
||||
'@vis.gl/[email protected]([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
|
||||
'@vis.gl/[email protected]([email protected])([email protected].0([email protected].0))([email protected].0)':
|
||||
'@vis.gl/[email protected]([email protected])([email protected].1([email protected].1))([email protected].1)':
|
||||
dependencies:
|
||||
'@maplibre/maplibre-gl-style-spec': 19.3.3
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
|
||||
'@vitejs/[email protected]([email protected].3([email protected]))':
|
||||
'@vitejs/[email protected]([email protected].4([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.3([email protected])
|
||||
vite: 8.3.4([email protected])
|
||||
|
||||
'@vitest/[email protected].3([email protected].3([email protected]))':
|
||||
'@vitest/[email protected].4([email protected].4([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.3
|
||||
'@vitest/spy': 5.0.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.2
|
||||
magic-string: 1.4.3
|
||||
optionalDependencies:
|
||||
vite: 8.3.3([email protected])
|
||||
vite: 8.3.4([email protected])
|
||||
|
||||
'@vitest/[email protected].3': {}
|
||||
'@vitest/[email protected].4': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3052,7 +3035,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
chai@6.3.0: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3130,7 +3113,7 @@ snapshots:
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 17.1.2(@noble/[email protected])
|
||||
whatwg-url: 17.2.0(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
@@ -3358,7 +3341,7 @@ snapshots:
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
'@exodus/bytes': 1.16.0(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
@@ -3401,10 +3384,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].2(@noble/[email protected]):
|
||||
[email protected].3(@noble/[email protected]):
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 7.1.3
|
||||
'@asamuzakjp/dom-selector': 9.2.4
|
||||
'@asamuzakjp/dom-selector': 10.0.3
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected])
|
||||
'@exodus/bytes': 1.16.0(@noble/[email protected])
|
||||
@@ -3417,7 +3400,7 @@ snapshots:
|
||||
parse5: 8.0.1
|
||||
saxes: 6.0.0
|
||||
tough-cookie: 6.0.2
|
||||
undici: 8.11.2
|
||||
undici: 8.12.1
|
||||
w3c-xmlserializer: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@@ -3523,7 +3506,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].2:
|
||||
[email protected].3:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
@@ -3935,9 +3918,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].18: {}
|
||||
[email protected].20: {}
|
||||
|
||||
obug@2.1.4: {}
|
||||
obug@3.0.0: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3965,17 +3948,17 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected].0:
|
||||
[email protected].1:
|
||||
dependencies:
|
||||
playwright-core: 1.64.0
|
||||
playwright-core: 1.64.1
|
||||
|
||||
[email protected].28:
|
||||
[email protected].30:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
nanoid: 3.3.20
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3995,23 +3978,23 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0([email protected].0):
|
||||
[email protected].1([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
scheduler: 0.28.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected])([email protected].0([email protected].0))([email protected].0):
|
||||
[email protected]([email protected])([email protected].1([email protected].1))([email protected].1):
|
||||
dependencies:
|
||||
'@vis.gl/react-mapbox': 8.1.3([email protected].0([email protected].0))([email protected].0)
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected])([email protected].0([email protected].0))([email protected].0)
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
'@vis.gl/react-mapbox': 8.1.3([email protected].1([email protected].1))([email protected].1)
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected])([email protected].1([email protected].1))([email protected].1)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -4020,7 +4003,7 @@ snapshots:
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
html-url-attributes: 3.0.1
|
||||
mdast-util-to-hast: 13.2.1
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
remark-parse: 11.0.0
|
||||
remark-rehype: 11.1.2
|
||||
unified: 11.0.5
|
||||
@@ -4029,64 +4012,64 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]([email protected].0([email protected].0))([email protected].0):
|
||||
[email protected]([email protected].1([email protected].1))([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0)([email protected]):
|
||||
[email protected](@types/[email protected])([email protected].1)([email protected]):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.3.0
|
||||
use-sync-external-store: 1.7.0([email protected].0)
|
||||
react: 19.3.1
|
||||
use-sync-external-store: 1.7.0([email protected].1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
redux: 5.0.1
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].0)
|
||||
react: 19.3.1
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].1)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected].0)
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].0)
|
||||
react: 19.3.1
|
||||
react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected].1)
|
||||
react-style-singleton: 2.2.3(@types/[email protected])([email protected].1)
|
||||
tslib: 2.8.1
|
||||
use-callback-ref: 1.3.3(@types/[email protected])([email protected].0)
|
||||
use-sidecar: 1.1.3(@types/[email protected])([email protected].0)
|
||||
use-callback-ref: 1.3.3(@types/[email protected])([email protected].1)
|
||||
use-sidecar: 1.1.3(@types/[email protected])([email protected].1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]):
|
||||
[email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.13.0([email protected](@types/[email protected])([email protected].0)([email protected]))([email protected].0)
|
||||
'@reduxjs/toolkit': 2.13.0([email protected](@types/[email protected])([email protected].1)([email protected]))([email protected].1)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.52.0
|
||||
eventemitter3: 5.0.4
|
||||
immer: 11.1.21
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0([email protected].0)
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected].1)
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].0)([email protected])
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected].1)([email protected])
|
||||
reselect: 5.2.0
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.7.0([email protected].0)
|
||||
use-sync-external-store: 1.7.0([email protected].1)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
@@ -4243,13 +4226,15 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
extend-shallow: 3.0.2
|
||||
|
||||
std-env@4.2.0: {}
|
||||
std-env@4.3.0: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -4272,9 +4257,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
tinybench@6.1.4: {}
|
||||
tinybench@6.2.2: {}
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -4293,10 +4278,6 @@ snapshots:
|
||||
dependencies:
|
||||
tldts: 7.4.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -4342,7 +4323,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]1.2: {}
|
||||
[email protected]2.1: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -4394,24 +4375,24 @@ snapshots:
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected].0):
|
||||
[email protected](@types/[email protected])([email protected].1):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected]([email protected].0):
|
||||
[email protected]([email protected].1):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react: 19.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4447,35 +4428,35 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected].3([email protected]):
|
||||
[email protected].4([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
postcss: 8.5.30
|
||||
rolldown: 1.2.12
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected].3([email protected].2(@noble/[email protected]))([email protected].3([email protected])):
|
||||
[email protected].4([email protected].3(@noble/[email protected]))([email protected].4([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.3([email protected].3([email protected]))
|
||||
chai: 6.2.2
|
||||
'@vitest/mocker': 5.0.4([email protected].4([email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 1.4.2
|
||||
obug: 2.1.4
|
||||
magic-string: 1.4.3
|
||||
obug: 3.0.0
|
||||
picomatch: 4.0.7
|
||||
std-env: 4.2.0
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
std-env: 4.3.0
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.3([email protected])
|
||||
vite: 8.3.4([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
jsdom: 30.1.2(@noble/[email protected])
|
||||
jsdom: 30.1.3(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -4491,14 +4472,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.16.0(@noble/[email protected])
|
||||
|
||||
@@ -86,6 +86,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.wide", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.full", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "browser.address", kind: "view", target: "none", proof: "static" },
|
||||
@@ -94,6 +95,8 @@ export const ACTIONS: UIAction[] = [
|
||||
// The way out of a window whose assets an update replaced underneath it.
|
||||
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.changed-files", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.feedback-level-notices", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "level-up.notice-dismiss", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.fold-work", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.turn-narration", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "changes.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -202,6 +205,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "mcp.retry", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.trust", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.signin", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.upgrade", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.follow", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.unfollow", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.remove", kind: "destructive", target: "entity", proof: "interaction" },
|
||||
@@ -274,6 +278,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -425,10 +432,15 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "remote.hide", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "remote.enable", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "remote.group", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.fold-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.move-to-start", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "session.move-to", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.move-to-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.move-to-target", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.reorder", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
|
||||
// ── Taking a change back ─────────────────────────────────────────────────
|
||||
@@ -484,6 +496,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "remotes.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "remotes.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "remotes.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "remotes.kind", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "remotes.distro", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "share.card", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "device.ask-leave", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "device.leave", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
@@ -554,7 +568,14 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.consent", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.probe", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "web-access.searxng-url", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "trace-export.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "trace-export.endpoint", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "trace-export.header-name", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "trace-export.header-env", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "trace-export.service-name", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.video-url", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -647,9 +668,16 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "skills.update-hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-reload", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-apply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "skills.remove-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "skills.remove-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.remove-reload", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.remove-confirm", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "subagent-claims.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "subagent-steer.max-bytes", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "subagent-steer.per-minute", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "write-lease.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "plan-approval.auto", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "default-mode.plan", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.target", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.text", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "peers.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -665,11 +693,23 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "auto-archive.notice-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "auto-archive.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "auto-archive.days", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// The persona editor edits a draft; only the save reaches the kernel, and the
|
||||
// persona is composed once at boot so the write is read by later sessions.
|
||||
{ id: "persona.prefix", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "persona.suffix", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "persona.preset", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "persona.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// The kernel announces the change on the stream, so no rebuild stands between
|
||||
// the click and the canonical change.
|
||||
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "ask-timeout.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "ask-timeout.seconds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session-ref.tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session-ref.reset", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session.reference-match", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "mods.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "mods.budget", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { EN_WINDOW } from "./en_window";
|
||||
import { EN_GRAPH } from "./en_graph";
|
||||
import { EN_TRAJECTORY } from "./en_trajectory";
|
||||
import { EN_SETTINGS } from "./en_settings";
|
||||
import { EN_PERSONA } from "./en_persona";
|
||||
import { EN_METRICS } from "./en_metrics";
|
||||
import { EN_STORAGE } from "./en_storage";
|
||||
import { EN_USAGE } from "./en_usage";
|
||||
@@ -34,9 +35,12 @@ export const EN: Record<string, string> = {
|
||||
|
||||
"加宽浏览器": "Widen browser",
|
||||
"恢复浏览器分栏": "Restore browser split",
|
||||
"全屏浏览器": "Full-screen browser",
|
||||
"退出全屏": "Exit full screen",
|
||||
"工作台": "Workbench",
|
||||
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
|
||||
...EN_SETTINGS,
|
||||
...EN_PERSONA,
|
||||
...EN_COMMUNITY,
|
||||
...EN_SPONSOR,
|
||||
...EN_METRICS,
|
||||
@@ -212,6 +216,31 @@ export const EN: Record<string, string> = {
|
||||
"输入文件夹路径": "Type the folder's path",
|
||||
"界面大小": "Interface size",
|
||||
"等待浏览器登录…": "Waiting for the browser sign-in…",
|
||||
"等待浏览器授权…": "Waiting for the browser authorization…",
|
||||
"授权更多权限": "Grant more access",
|
||||
"追踪导出": "Trace export",
|
||||
"把每轮对话以 OpenTelemetry 追踪的形式发给你自己的观测系统。默认关闭;只发送结构、耗时、数量和名称,不发送提示词、工具参数、结果或文件路径。": "Send each turn to your own observability stack as OpenTelemetry traces. Off by default; only structure, timing, counts and names are sent, never prompts, tool arguments, results or file paths.",
|
||||
"导出追踪": "Export traces",
|
||||
"已开启,但还没有收集器地址,所以不会发送任何东西。": "On, but there is no collector address yet, so nothing is sent.",
|
||||
"现在是关闭的:开启并填写收集器地址后才会发送。": "Off now: nothing is sent until it is on and has a collector address.",
|
||||
"已开启:之后新开的会话会把追踪发到下面的地址。": "On: sessions opened from now on send traces to the address below.",
|
||||
"收集器地址": "Collector address",
|
||||
"认证请求头名称": "Auth header name",
|
||||
"保存凭据的环境变量": "Environment variable holding the credential",
|
||||
"环境变量已设置;它的值不会显示,也不会写进配置文件。": "The variable is set; its value is never shown or written to the config file.",
|
||||
"环境变量当前没有值:会不带凭据发送。": "The variable has no value now: traces are sent without a credential.",
|
||||
"凭据不写在配置里:这里只填环境变量名,名称必须以 OTEL_ 或 REASONIX_OTEL_ 开头。": "The credential is never in the config: enter only a variable name, which must start with OTEL_ or REASONIX_OTEL_.",
|
||||
"服务名": "Service name",
|
||||
"保存后只对之后新开的会话生效,已开的会话沿用旧设置。这些设置写在用户配置文件里:": "A save applies to sessions opened afterwards; open ones keep the old setting. These settings live in your user config file:",
|
||||
"这个服务器要求更多权限": "This server asks for more access",
|
||||
"这个服务器拒绝了一次调用,并要求更多权限(以下是它自己写的名称):": "This server refused a call and asks for more access (these names are its own words):",
|
||||
"这个服务器没有要求更多权限": "This server has not asked for more access",
|
||||
"这个客户端不能修改追踪导出设置": "This client cannot change the trace export settings",
|
||||
"收集器地址必须是带主机名的 http 或 https 地址": "The collector address must be an http or https address with a host name",
|
||||
"收集器地址里不能带用户名、密码、查询串或片段": "The collector address must not contain a user name, password, query or fragment",
|
||||
"这不是可用的请求头名称": "That is not a usable header name",
|
||||
"凭据环境变量必须以 OTEL_ 或 REASONIX_OTEL_ 开头": "The credential variable must start with OTEL_ or REASONIX_OTEL_",
|
||||
"追踪导出设置没有保存成功": "The trace export settings were not saved",
|
||||
"接受并以后都自动接受这个服务器的改动": "Accept, and accept this server's changes by themselves from now on",
|
||||
"自动接受这个服务器的改动": "Changes from this server are accepted by themselves",
|
||||
"这个服务器的改动会自动接受,并在通知里告诉你。": "This server's changes are accepted by themselves, and you are told in a notice.",
|
||||
@@ -247,12 +276,21 @@ export const EN: Record<string, string> = {
|
||||
"停下的原因:{cause}": "Why it stopped: {cause}",
|
||||
"最近一次判断:{reason}": "Latest assessment: {reason}",
|
||||
"SearXNG 地址": "SearXNG address",
|
||||
"正在测试…": "Testing…",
|
||||
"没能测试这个地址:{detail}": "Could not test this address: {detail}",
|
||||
"连通:这个 SearXNG 能返回 JSON 搜索结果。": "Connected: this SearXNG returns JSON search results.",
|
||||
"这个 SearXNG 没有开启 JSON 格式:在它的 settings.yml 的 search.formats 里加上 json,然后重启它。": "This SearXNG does not allow the JSON format: add json to search.formats in its settings.yml, then restart it.",
|
||||
"连不上这个地址:检查它是否在运行,地址和端口是否写对。": "Cannot reach this address: check that it is running and that the address and port are right.",
|
||||
"这个地址有应答,但不是 SearXNG 的 JSON 搜索结果:确认地址指向 SearXNG 本身,而不是登录页或别的服务。": "This address answers, but not with SearXNG's JSON results: make sure it points at SearXNG itself, not a login page or another service.",
|
||||
"这个地址把请求转到了别处:请直接填 SearXNG 的最终地址。": "This address redirects elsewhere: enter SearXNG's final address directly.",
|
||||
"测试不会连接这个地址:它是链路本地、云元数据或未指定地址。自托管实例请用本机或局域网地址。": "The test will not connect to this address: it is link-local, a cloud metadata endpoint or unspecified. A self-hosted instance belongs on this machine or your local network.",
|
||||
"这个 SearXNG 拒绝了测试搜索(HTTP {status}):稍后重试,或查看它的日志。": "This SearXNG refused the test search (HTTP {status}): try again later, or check its log.",
|
||||
"保存后只对之后新开的窗格生效,已开的窗格沿用旧设置。这些设置写在用户配置文件里:": "A saved change reaches only panes opened afterwards; open panes keep the old settings. These settings are kept in the user config file: ",
|
||||
"这些设置现在是只读的:这个窗口没有被授予编辑权限。": "These settings are read-only now: this window has not been granted editing.",
|
||||
"启用会话间消息": "Turn on session messages",
|
||||
"同一台机器上的会话互相发文字。收到的文字先挂起,等你批准才会交给模型。": "Sessions on this machine send each other text. Incoming text is held until you approve it; only then does the model see it.",
|
||||
"允许模型把任务交给别的会话": "Let the model hand a task to another session",
|
||||
"给模型一个把任务交给另一个项目里会话的工具;对方的用户批准后才会送到。": "Gives the model a tool to hand a task to a session in another project; it is delivered once that session's user approves.",
|
||||
"给模型一个把任务交给另一个项目里会话的工具。打开就是你的总同意:派出的任务和对方的回信不再询问;新会话沿用本会话的模型和权限,不会比本会话更宽。": "Gives the model a tool to hand a task to a session in another project. Turning it on is your standing consent: dispatched tasks and their replies come in without asking, and the new session keeps this session's model and permissions, never more.",
|
||||
"先启用会话间消息。": "Turn on session messages first.",
|
||||
"这些项目收到的消息不再询问,直接交给模型:": "Messages sent to these projects are no longer asked about and go straight to the model:",
|
||||
"已长期放行的项目": "Projects released for good",
|
||||
@@ -375,6 +413,7 @@ export const EN: Record<string, string> = {
|
||||
"失败": "Failed",
|
||||
"OpenAI 兼容": "OpenAI-compatible",
|
||||
"Anthropic 兼容": "Anthropic-compatible",
|
||||
"Gemini 原生(实验)": "Gemini native (experimental)",
|
||||
"扩展": "Extension",
|
||||
"新增": "Added",
|
||||
"已删除": "Deleted",
|
||||
@@ -712,6 +751,7 @@ export const EN: Record<string, string> = {
|
||||
"已运行,但退出码来自命令中的其他片段": "it ran, but the exit code came from another stage of the command",
|
||||
"有改动;没有主机能读作证据的检查": "changed; no check the host could read as proof",
|
||||
"改动后未再检查": "changed, never looked at again",
|
||||
"有 Claude Code mod 在运行;它写了什么不在主机观测范围内": "a Claude Code mod was active; what it wrote is outside what the host observes",
|
||||
"自己申报未验证": "declared unverified by the turn itself",
|
||||
"同一账号的两种接入方式。当前这一种不支持联网搜索;这是协议差异,不是可配置项。": "Two ways in to the same account. This one has no web search — that is a difference between the protocols, not a setting.",
|
||||
"同一账号的两种接入方式。切换后下方的模型列表随之改变。": "Two ways in to the same account. Switching changes the model list below.",
|
||||
@@ -1651,4 +1691,12 @@ export const EN: Record<string, string> = {
|
||||
"收起数据": "Hide data",
|
||||
"图表数据:{title}": "Chart data: {title}",
|
||||
"另有 {n} 行": "and {n} more rows",
|
||||
"请求修改设置": "Asks to change a setting",
|
||||
"每次都会询问": "Asked every time",
|
||||
"这项设置会放宽模型的权限,所以没有「本会话内不再问」,每次都要你亲自确认。": "This setting loosens what the model may do, so there is no \"don't ask again this session\": you confirm it yourself each time.",
|
||||
"打开设置": "Open settings",
|
||||
"视频地址": "Video addresses",
|
||||
"模型可以把你在对话里写的公开视频地址交给模型厂商读取。默认关闭,立即生效。": "The model can hand a public video address you wrote in the conversation to the model vendor to read. Off by default; applies at once.",
|
||||
"把视频地址交给模型厂商": "Hand video addresses to the model vendor",
|
||||
"开启后,该网址会被厂商直接访问:由厂商的服务器去打开它,不是本机下载。只有你自己写在对话里的地址才会交出去,且所选模型的厂商要支持。": "When on, the address is visited directly by the vendor: its servers open it, it is not downloaded here. Only an address you wrote in the conversation is handed over, and the selected model's vendor has to support it.",
|
||||
};
|
||||
@@ -120,6 +120,20 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"需要补充信息": "Needs more info",
|
||||
"维护者已回复": "Answered by us",
|
||||
"已关闭此反馈": "Closed",
|
||||
"已撤回这条反馈。": "The report was withdrawn.",
|
||||
"撤回这条反馈…": "Withdraw this report…",
|
||||
"确认撤回": "Withdraw",
|
||||
"撤回后维护者不再处理它,对应的 #{n} 议题会被关闭,你上传的图片会被删除;GitHub 上已经公开的文字会保留。": "Once withdrawn the maintainers stop working on it, its issue #{n} is closed and the images you uploaded are deleted; text already public on GitHub stays.",
|
||||
"撤回后维护者不再处理它,你上传的图片会被删除。": "Once withdrawn the maintainers stop working on it and the images you uploaded are deleted.",
|
||||
"来自其他设备": "From another device",
|
||||
"你的反馈等级升到了 L{n}「{name}」。": "Your feedback level went up to L{n} \u00b7 {name}.",
|
||||
"再落地 {r} 条升到「{next}」。": "{r} more shipped to reach {next}.",
|
||||
"没能记住这条已读,稍后会再提示。": "That could not be remembered; it will be shown again later.",
|
||||
"等级提示": "Level notices",
|
||||
"你的反馈被采纳、等级提高时,在窗口顶部提示一次,由你点“知道了”关掉。默认关闭;关着时不会提示,也不会补报之前的升级。": "When your feedback ships and your level goes up, a note appears once at the top of the window until you dismiss it. Off by default; while off nothing is said, and earlier level-ups are not announced when you turn it on.",
|
||||
"等级提升时通知我": "Tell me when my level goes up",
|
||||
"不弹窗、不发系统通知,只在窗口顶部的提示条里说一次": "No pop-up and no system notification: one line in the note bar at the top of the window",
|
||||
"已读标记只记在这台电脑上,其他设备上的反馈在这里仍可能显示为新回复。": "Read marks are kept on this computer only, so a report from another device may still show new replies here.",
|
||||
"需要你回复": "Needs your reply",
|
||||
"等你补充信息": "Waiting for your information",
|
||||
"维护者": "Maintainers",
|
||||
@@ -172,6 +186,7 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"较高的额度已经失效。等级和已落地的条数都保留,之后再有反馈落地就会续上。": "Your higher limits have lapsed. Your level and count are kept, and a later report that ships renews them.",
|
||||
"这是上次确认的等级({date}),连上反馈服务后会更新。": "This is the last confirmed level ({date}); it updates once the feedback service is reachable.",
|
||||
"等级暂时无法显示。": "Your level cannot be shown right now.",
|
||||
"等级 {n}": "Level {n}",
|
||||
"这一小时的反馈次数已用完。": "You have used this hour's feedback allowance.",
|
||||
"今天的反馈次数已用完。": "You have used today's feedback allowance.",
|
||||
"这个网络这一小时的提交次数已用完。": "This network has used this hour's submission allowance.",
|
||||
|
||||
@@ -46,13 +46,15 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
|
||||
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
|
||||
"搜索模型「{model}」的格式应为 服务/模型": "Search model “{model}” must be written as service/model",
|
||||
"搜索模型「{model}」所在的连接已被移出": "Search model “{model}”'s connection has been removed",
|
||||
"搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它": "Search model “{model}”'s connection has not been added yet: add that connection first, then pick it",
|
||||
"搜索模型「{model}」已不在配置里": "Search model “{model}” is no longer configured",
|
||||
"搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索": "Search model “{model}” has no native search on its protocol, or search is turned off for it",
|
||||
"搜索模型「{model}」所在的连接没有凭据": "Search model “{model}”'s connection has no credentials",
|
||||
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
|
||||
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
|
||||
"这份反馈现在不接收回复": "This report takes no reply right now",
|
||||
"这份反馈已经有了结论,不能再撤回": "This report already has an outcome and can no longer be withdrawn",
|
||||
"找不到这份反馈": "That report could not be found",
|
||||
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
|
||||
"需要先登录账号": "Sign in to your account first",
|
||||
"这台电脑已关联到另一个账号": "This computer is already linked to another account",
|
||||
@@ -95,6 +97,13 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个窗格由另一台机器驱动,使用它自己的事件流": "This pane is driven from another machine and keeps its own event stream",
|
||||
"这个后台任务已经不在运行了": "That background job is no longer running",
|
||||
"这条调用已经结束了": "This call has already finished",
|
||||
"没有找到这个子代理的记录": "No sub-agent run was found for that reference",
|
||||
"这个子代理不属于当前会话": "That sub-agent belongs to a different conversation",
|
||||
"读取子代理记录失败:{detail}": "Could not read the sub-agent record: {detail}",
|
||||
"这个子代理已经不在运行,读不到新的指示": "That sub-agent is no longer running, so it cannot read new guidance",
|
||||
"指示不能是空的": "Guidance cannot be empty",
|
||||
"这条指示太长了,超过了发给子代理的单条上限": "That guidance is longer than the limit for one message to a sub-agent",
|
||||
"短时间内发给这个子代理的指示太多了,请稍后再发": "Too much guidance was sent to this sub-agent a moment ago; send it again shortly",
|
||||
"你停止了这条调用,它在外部已经做了什么无法确定": "You stopped this call; what it had already done outside the workspace is unknown",
|
||||
"请输入要搜索的内容": "Enter something to search for",
|
||||
"对话内容索引暂时无法回答,稍后再试": "The conversation index cannot answer right now; try again shortly",
|
||||
@@ -108,6 +117,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该会话的待送达已关闭": "This session's pending queue is closed",
|
||||
"该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here",
|
||||
"这份待送达属于另一个运行,暂不能接管": "This pending queue belongs to another run and cannot be taken over here",
|
||||
"待送达的正文读不出来,队列内容未知,已暂停等待检查": "The pending queue's contents could not be read, so they are unknown and the queue is paused for inspection",
|
||||
"该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content",
|
||||
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时
|
||||
"配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
|
||||
@@ -124,7 +134,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"多会话写入设置未能保存:{detail}": "The multi-session write setting was not saved: {detail}",
|
||||
"这台服务器未开放计划批准设置": "This server does not open the plan approval setting",
|
||||
"计划批准设置未能保存:{detail}": "The plan approval setting was not saved: {detail}",
|
||||
"这台服务器未开放新对话起始模式设置": "This server does not open the new-conversation start mode setting",
|
||||
"新对话起始模式设置未能保存:{detail}": "The new-conversation start mode setting was not saved: {detail}",
|
||||
"子代理写入范围设置未能保存:{detail}": "The sub-agent write scope setting was not saved: {detail}",
|
||||
"这台服务器未开放子代理指示上限设置": "This server does not open the sub-agent guidance limit setting",
|
||||
"子代理指示上限设置未能保存:{detail}": "The sub-agent guidance limit setting was not saved: {detail}",
|
||||
"本次请求未说明选择,未做任何修改": "The request did not say which, so nothing changed",
|
||||
"这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写": "This path is in use by another parallel sub-agent or write. This write did not run; choose another path, or wait until it finishes",
|
||||
"无法解析该服务器声明:{detail}": "That server declaration could not be read: {detail}",
|
||||
@@ -242,7 +256,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"请为该机器填写名称": "Give this machine a name",
|
||||
"请填写要连接的地址": "Say which address to dial",
|
||||
"这不是有效的端口号": "That is not a port number",
|
||||
"这不是有效的 WSL 发行版名称": "That is not the name of a WSL distribution",
|
||||
"读不到已安装的 WSL 发行版": "Could not read which WSL distributions are installed",
|
||||
"这台机器的接入方式与表单不同(SSH 或 WSL),不能这样修改它": "This machine is reached another way than this form describes (SSH or WSL), so it cannot be changed like this",
|
||||
"该主机已停用,启用后才能连接": "This machine is turned off; turn it on to connect",
|
||||
"主机 {host} 尚未保存,请先添加该主机再连接": "Host {host} is not saved; add it before connecting",
|
||||
"该机器仍有 {n} 个打开的面板,请先关闭再移除": "This machine still has {n} open panes — close them before removing it",
|
||||
"{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。":
|
||||
"The host key for {host} changed. That machine may have been rebuilt \u2014 or someone may be in the middle. The record is at {file} line {line}; do not connect until you have checked.",
|
||||
@@ -363,7 +381,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这条消息已经处理过了": "That message was already decided",
|
||||
"会话间消息没能完成:{detail}": "The message between sessions did not go through: {detail}",
|
||||
"放行的项目必须是一个绝对路径的文件夹": "A released project must be a folder given as an absolute path",
|
||||
"派发上限是 0 或更大的整数,0 表示不限制": "A dispatch ceiling is a whole number from 0 up; 0 removes the ceiling",
|
||||
"只有批准才能记住,拒绝只对这一条生效": "Only an approval can be remembered; a denial applies to this message alone",
|
||||
"单个钩子的运行时限是 0 到 55000 毫秒,0 表示默认的 10 秒": "A hook's running time is 0 to 55000 milliseconds; 0 keeps the default 10 seconds",
|
||||
"这个客户端不能修改 mod 设置": "This client cannot change the mod settings",
|
||||
"运行 Claude Code mod 的开关还没有开放,现在不能打开": "The switch for running Claude Code mods is not open yet, so it cannot be turned on",
|
||||
"会话间消息的设置没能保存:{detail}": "The messaging settings could not be saved: {detail}",
|
||||
"地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥": "The address moved to another origin, so the saved key is not sent there; enter the key for this address",
|
||||
"请先写下要问的内容": "Write the question first",
|
||||
@@ -503,6 +525,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)": "This skill was not copied from a local folder, so it cannot be updated from one (reinstall a linked or remote skill).",
|
||||
"找不到来源文件夹,可能被移动或删除了": "The source folder cannot be found; it may have been moved or deleted.",
|
||||
"自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新": "The source folder or the installed copy changed after you opened the preview. Review it again before updating.",
|
||||
"这个技能不是你自己的文件,不能在这里卸载(内置技能或由包提供的技能,随包卸载)": "This skill is not a file of yours, so it cannot be removed here (a built-in, or a package's skill, which goes with its package)",
|
||||
"这个技能自你打开列表后已经变了,没有删除任何内容。请重新读取后再试": "This skill changed after you opened the list, and nothing was deleted. Read the list again and retry",
|
||||
"卸载技能失败:{detail}": "Could not remove the skill: {detail}",
|
||||
"其他项目的技能不能在这里卸载": "A skill of another project cannot be removed from here",
|
||||
"已安装的副本被改过,更新会覆盖这些改动,需要你确认": "The installed copy was edited, and updating overwrites those edits; confirm to continue.",
|
||||
"这个技能太大,无法更新(文件数或总大小超过上限)": "This skill is too large to update (too many files or too many bytes).",
|
||||
"更新没有完成,已安装的副本保持原样:{detail}": "The update did not complete and the installed copy was left as it was: {detail}",
|
||||
@@ -539,6 +565,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"运行时已更换,费用显示币种未保存,请重试": "The runtime changed; the display currency was not saved. Try again.",
|
||||
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
|
||||
"文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
|
||||
"等级提示的设置未能保存:{detail}": "The level notice setting was not saved: {detail}",
|
||||
"折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}",
|
||||
"回合间叙述的设置未能保存:{detail}": "The narration setting was not saved: {detail}",
|
||||
"这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。": "Editing the web search and fetch settings is not enabled on this server: only the window on this computer can change them.",
|
||||
@@ -547,9 +574,12 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"SearXNG 地址必须是带主机名的 http(s) 地址,不能含账号、查询串或片段,未做任何修改。": "The SearXNG address must be an http(s) address with a host, without credentials, query or fragment; nothing was changed.",
|
||||
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
|
||||
"提问等待时长未能保存:{detail}": "The question wait time was not saved: {detail}",
|
||||
"引用历史会话的长度上限未能保存:{detail}": "The size limit for referenced conversations was not saved: {detail}",
|
||||
"长度上限超出允许范围,未做任何修改:{detail}": "The size limit is outside the allowed range; nothing was changed: {detail}",
|
||||
"等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
|
||||
"自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}",
|
||||
"天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}",
|
||||
"人设内容超出长度上限,未做任何修改:{detail}": "The persona text is over the length limit; nothing was changed: {detail}",
|
||||
"交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
|
||||
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
|
||||
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
|
||||
@@ -592,4 +622,16 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"The old memory file could not be backed up first, so it was not migrated and was left as it is; the detail gives the reason",
|
||||
"会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发":
|
||||
"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",
|
||||
"该会话正在窗口里打开,先关闭它再移动": "This conversation is open in a window; close it before moving it",
|
||||
"该会话还在被写入,稍后再移动": "This conversation is still being written to; move it a little later",
|
||||
"该会话被另一个进程占用({host} 上的 pid {pid}),结束它之后再移动": "Another process holds this session open (pid {pid} on {host}); end it, then move it",
|
||||
"它已经在这个项目里了": "It is already in this project",
|
||||
"这个会话的记录不完整,不能移动;先让它自己恢复": "This conversation's record is incomplete and cannot be moved; let it recover first",
|
||||
"「运行 Claude Code mod」没有打开,所以没有运行任何 mod": "\"Run Claude Code mods\" is off, so no mod is running",
|
||||
"这台电脑上找不到能运行 mod 的 Node 运行时": "No Node runtime that can run mods was found on this computer",
|
||||
"找到的 Node 运行时版本过低,不能运行这个 mod": "The Node runtime found is too old to run this mod",
|
||||
"这个 mod 的进程崩溃了,正在按退避重新启动": "This mod's process crashed and is being restarted with backoff",
|
||||
"它的某个钩子超过了时间预算,进程已被结束;多次发生后会被停用": "One of its hooks ran past its time budget and the process was ended; after repeats it is disabled",
|
||||
"它的某个钩子抛出了异常": "One of its hooks threw an exception",
|
||||
"它用到了本宿主不支持的功能": "It uses something this host does not support",
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
export const EN_PERSONA: Record<string, string> = {
|
||||
"人设": "Persona",
|
||||
"系统人设": "System persona",
|
||||
"已保存 · 下次会话构建时生效": "Saved · applies at the next session build",
|
||||
"下次会话构建时生效": "Applies at the next session build",
|
||||
"系统人设决定 agent 的身份:前缀写在系统提示词最前面,后缀写在内置指令之后。保存不会立即重建会话;新会话及现有会话下一次重建(例如切换模型)时生效。":
|
||||
"The system persona shapes the agent's identity: the prefix leads the system prompt and the suffix follows the built-in guidance. Saving does not rebuild a session immediately. Changes apply to new sessions and the next rebuild of an existing session, such as a model switch.",
|
||||
"无法读取人设设置。": "Couldn't read the persona settings.",
|
||||
"预设": "Presets",
|
||||
"严谨工程师": "Rigorous engineer",
|
||||
"简洁": "Concise",
|
||||
"教学导师": "Mentor",
|
||||
"严苛评审": "Critic",
|
||||
"先验证再下结论,只做最少的正确改动,并说明剩余风险。": "Verify before concluding, make the smallest correct change, and state the residual risk.",
|
||||
"回答简短直接,不铺垫,优先代码与要点。": "Answer briefly and directly, skip the preamble, prefer code and bullets.",
|
||||
"像老师一样说明取舍与原理,把决定权留给你。": "Explain trade-offs and reasoning like a teacher, and leave the decision to you.",
|
||||
"以审查者的立场找问题:先指出失效方式和证据,再谈通过。": "Review adversarially: name the failure mode and the evidence before approving.",
|
||||
"未应用预设;直接填写下面的两段即可。": "No preset applied; fill in the two boxes below.",
|
||||
"已修改预设内容,按下面的字段保存。": "The preset text has been edited; save the fields below.",
|
||||
"系统提示词开头": "Start of the system prompt",
|
||||
"作为系统提示词的第一段,写在内置指令之前;留空表示不添加。": "Leads the system prompt, ahead of the built-in guidance; leave it empty to add nothing.",
|
||||
"第一方指令之后": "After the first-party guidance",
|
||||
"紧跟在内置指令之后、运行时上下文之前;留空表示不添加。": "Follows the built-in guidance and precedes the runtime context; leave it empty to add nothing.",
|
||||
"不注入可选运行时上下文": "Do not inject optional runtime context",
|
||||
"关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
|
||||
"写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.",
|
||||
"已保存,下次会话构建时生效": "Saved; applies at the next session build",
|
||||
};
|
||||
@@ -44,7 +44,15 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"尚未添加远程机器。添加后,其工作区将与本地工作区并列显示在左栏。":
|
||||
"No remote machines yet. Add one and its workspaces appear in the sidebar beside your local ones.",
|
||||
"添加机器": "Add a machine",
|
||||
"WSL 发行版": "WSL distribution",
|
||||
"SSH 主机": "SSH host",
|
||||
"连接类型": "Connection type",
|
||||
"发行版": "Distribution",
|
||||
"{name}(WSL 1,不支持)": "{name} (WSL 1, not supported)",
|
||||
"内核运行在 Studio 保持的一个会话里:关闭 Studio 后它会随发行版一起停止,对话保存在磁盘上,下次连接时继续。模型凭据使用发行版自己的配置。": "The kernel runs in a session Studio keeps open: when Studio closes, it stops with the distribution. Conversations are kept on disk and continue on the next connect. Model credentials come from the distribution's own configuration.",
|
||||
"未设置默认工作区": "No default workspace",
|
||||
"默认连接目录:{path}": "Default connection directory: {path}",
|
||||
"连接目录:{path}": "Connected directory: {path}",
|
||||
"跟随 ssh_config": "Follows ssh_config",
|
||||
"{n} 条转发": "{n} forwards",
|
||||
"{n} 个面板": "{n} panes",
|
||||
|
||||
@@ -407,6 +407,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"无法读取多会话写入设置。": "Could not read the multi-session write setting.",
|
||||
"无法读取计划批准设置。": "Could not read the plan approval setting.",
|
||||
"计划批准": "Plan approval",
|
||||
"新对话的起始模式": "How new conversations start",
|
||||
"新建对话时先进入哪种模式。只影响起点,不会改动已有的对话。": "Which mode a new conversation opens in. It only sets the starting point and never changes a conversation that already exists.",
|
||||
"新对话默认进入计划模式": "Start new conversations in plan mode",
|
||||
"新建的对话先做计划,你批准后才动手。输入框里的模式按钮仍可随时切换;已有的对话保持它自己的模式。默认关闭,只写在你自己的用户配置里,项目配置不能替你改。": "A new conversation plans first and acts only after you approve. The mode button in the composer still switches it at any time, and a conversation that already exists keeps its own mode. Off by default, written only to your own user config; a project config cannot change it for you.",
|
||||
"无法读取新对话的起始模式设置。": "Could not read the setting for how new conversations start.",
|
||||
"计划模式提交计划后,默认等你点批准才开始执行。长时间无人值守的任务可以改成自动批准。": "After Plan mode submits a plan, it waits for your approval by default. For long unattended runs it can be set to approve automatically.",
|
||||
"自动批准计划": "Approve plans automatically",
|
||||
"计划提交后不再等你点批准,直接开始执行,对话里会留下一条说明。只放行这一次点击:工具批准、沙箱和拒绝规则照常生效。默认关闭,只写在你自己的用户配置里,项目配置不能替你打开。": "A submitted plan starts without waiting for your click, and the transcript notes it. Only that click is released: tool approvals, the sandbox and deny rules still apply. Off by default, written only to your own user config; a project config cannot turn it on for you.",
|
||||
@@ -799,6 +804,20 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"源文件夹不见了": "Source folder is gone",
|
||||
"找不到来源文件夹:{path}": "The source folder cannot be found: {path}",
|
||||
"从源文件夹更新…": "Update from source folder…",
|
||||
"卸载…": "Remove…",
|
||||
"卸载": "Remove",
|
||||
"卸载中…": "Removing…",
|
||||
"卸载技能「{name}」?": "Remove the skill \"{name}\"?",
|
||||
"会从磁盘删除下面这个文件夹及其中所有文件,不能撤销。要再用它,需要重新安装。": "This deletes the folder below and everything in it from disk, and cannot be undone. To use it again you would have to install it again.",
|
||||
"会从磁盘删除下面这个文件,不能撤销。要再用它,需要重新安装。": "This deletes the file below from disk and cannot be undone. To use it again you would have to install it again.",
|
||||
"无法读取子代理指示的上限设置。": "Could not read the limits on guidance to sub-agents.",
|
||||
"单条指示的字节上限": "Bytes in one message of guidance",
|
||||
"每分钟条数上限": "Messages of guidance per minute",
|
||||
"默认 {n}。超过的指示会被拒绝,不会发给子代理;填 0 表示不设上限。": "Default {n}. Guidance over it is refused and not sent to the sub-agent; 0 sets no limit.",
|
||||
"默认 {n}。发给同一个子代理的指示,一分钟内超过这个条数就会被拒绝;填 0 表示不设上限。": "Default {n}. Guidance to one sub-agent beyond this many in a minute is refused; 0 sets no limit.",
|
||||
"条": "per minute",
|
||||
"给运行中子代理的指示": "Guidance to running sub-agents",
|
||||
"你或模型在子代理还没做完时发给它的补充指示,在它下一次调用工具时读到。这里限制每条的大小和发送的频率,改动立即对正在运行的子代理生效。": "Extra guidance you or the model send a sub-agent that is still working, read at its next tool call. This limits how large each message is and how often they are sent; a change applies to running sub-agents at once.",
|
||||
"文件太大,这里不显示内容。请到来源文件夹里查看:{path}": "This file is too large to show here. Read it in the source folder: {path}",
|
||||
"不是文本文件,这里不显示内容。": "Not a text file, so its content is not shown here.",
|
||||
"(没有可显示的差异)": "(no difference to show)",
|
||||
@@ -1258,7 +1277,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"从 Reasonix 1.x 迁移过来的会话是否出现在侧栏列表里。隐藏后不会删除它们,搜索时仍能找到,正在打开的旧会话也照常显示。默认显示。": "Whether sessions migrated from Reasonix 1.x appear in the sidebar lists. Hiding does not delete them; search still finds them and an open one stays visible. Shown by default.",
|
||||
"显示": "Show",
|
||||
"会话行快捷按钮": "Session row buttons",
|
||||
"鼠标移到侧栏会话行上时,是否在右侧显示「归档」「删除」按钮。关闭时这两个操作仍在右键菜单里。默认关闭。": "Whether the Archive and Delete buttons appear at the right of a sidebar session row while the pointer is over it. When off, both stay in the row's context menu. Off by default.",
|
||||
"悬停或键盘聚焦会话时,左侧显示置顶按钮,右侧显示归档按钮。默认关闭;关闭时可通过会话菜单操作。": "Show Pin on the left and Archive on the right when a session is hovered or focused. Off by default; when hidden, use the session menu.",
|
||||
"置顶会话:{title}": "Pin session: {title}",
|
||||
"取消置顶:{title}": "Unpin session: {title}",
|
||||
"归档会话:{title}": "Archive session: {title}",
|
||||
"取消归档:{title}": "Unarchive: {title}",
|
||||
"隐藏": "Hide",
|
||||
@@ -1277,4 +1298,68 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"仅侧栏收起时显示": "Only when the sidebar is collapsed",
|
||||
"不显示": "Never",
|
||||
"关闭或收起时,这些页面仍可从设置和侧栏打开": "With it off or hidden, these pages still open from settings and the sidebar",
|
||||
"引用历史会话的长度": "Size of a referenced conversation",
|
||||
"在输入框里引用一段历史会话时,最多给这条消息添加多少内容。": "How much a referenced past conversation may add to a message.",
|
||||
"引用的最大长度": "Largest quote",
|
||||
"引用的历史会话会作为只读资料放进这条消息;超过上限时只保留最近的消息,并写明保留了多少。默认 {n} token。": "A referenced conversation goes into the message as read-only reference material. Past the limit only the newest messages stay, and the quote says how many. Default {n} tokens.",
|
||||
"恢复默认": "Restore default",
|
||||
"无法读取引用设置。": "The reference settings could not be read.",
|
||||
"历史会话 · 只读资料": "Past conversation · read-only reference",
|
||||
"引用「{title}」到输入框": "Reference \"{title}\" in the message box",
|
||||
"↑↓ 选择 · 回车添加到这条消息 · Esc 关闭": "↑↓ choose · Enter adds it to this message · Esc closes",
|
||||
"把「{name}」移到另一个项目": "Move \"{name}\" to another project",
|
||||
"只带走对话本身和你设的标题、置顶、归档。原项目的权限与目标设置不会跟着走,新项目按它自己的规则重新判断;对话里记录的路径保持原样。": "Only the conversation itself and the title, pin and archive state you set travel. What the old project allowed does not follow: the new project decides for itself, and the paths recorded inside the conversation stay as they were.",
|
||||
"没有别的项目可以移到。先添加一个项目。": "There is no other project to move it to. Add a project first.",
|
||||
"目标项目": "Target project",
|
||||
"移动": "Move",
|
||||
"先关闭这个会话,再移动它": "Close this conversation first, then move it",
|
||||
"移动到项目…": "Move to a project…",
|
||||
"检查点(回退快照)、后台任务日志和排队中的消息不随会话移动,原件会被移除。": "Checkpoints (rewind snapshots), background-task logs and queued messages do not travel; the originals are removed.",
|
||||
"对话已经移过去了,但原来的那份没能删除,请手动删除。": "The conversation was moved, but the original could not be removed; please delete it by hand.",
|
||||
"运行 Claude Code mod": "Run Claude Code mods",
|
||||
"默认关闭。打开后,已安装的 mod 里的代码会以你的权限在这台电脑上运行(完全信任):没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。关闭时一个 mod 都不会运行。": "Off by default. When on, the code in installed mods runs on this computer with your permissions (full trust): there is no sandbox, network egress governance does not apply, and what it does is not observed by this app. With it off, no mod runs.",
|
||||
"单个钩子的运行时限": "Time limit for one hook",
|
||||
"超过这个时间,mod 的进程会被结束。0 表示使用默认的 10 秒,最大 {max} 毫秒。": "Past this time the mod's process is ended. 0 uses the default 10 seconds; the most is {max} ms.",
|
||||
"毫秒": "ms",
|
||||
"无法读取 mod 设置。": "The mod settings could not be read.",
|
||||
"已安装的 mod": "Installed mods",
|
||||
"未运行": "Not running",
|
||||
"正在启动": "Starting",
|
||||
"正在重启": "Restarting",
|
||||
"已重启 {n} 次": "Restarted {n} times",
|
||||
"它注册的事件": "Events it registered",
|
||||
"本应用不会触发这些事件": "Events this app never raises",
|
||||
"没有安装带 mod 的插件包。": "No installed package carries a mod.",
|
||||
"Claude Code mod": "Claude Code mods",
|
||||
"插件包里的 mod 可以在会话的事件上挂钩子。": "A mod in a package can hook the session's events.",
|
||||
"Claude Code mod 预览": "Claude Code mod preview",
|
||||
"Claude Code mod · 完全信任": "Claude Code mod · full trust",
|
||||
"它的代码会在这台电脑上以你的权限运行:没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。只有在设置里打开「运行 Claude Code mod」后才会运行。": "Its code runs on this computer with your permissions: there is no sandbox, network egress governance does not apply, and what it does is not observed by this app. It runs only once \"Run Claude Code mods\" is turned on in Settings.",
|
||||
"下面是读取代码语法树得到的静态清单,不是保证:imports 是看不见的代码;eval、new Function 和用计算出来的名字访问接口,都会让清单列不全。": "What follows is a static listing read from the code's syntax tree, not a guarantee: imports are code you cannot see, and eval, new Function and reaching the API by a computed name all leave the listing incomplete.",
|
||||
"未预览": "Not previewed",
|
||||
"这不等于没问题:读不了的代码按“未预览”处理。": "That does not mean it is fine: code that cannot be read counts as not previewed.",
|
||||
"它是 TypeScript,本应用不读": "It is TypeScript, which this app does not read",
|
||||
"文件太大": "The file is too large",
|
||||
"语法解析失败": "It could not be parsed",
|
||||
"解析超时": "Parsing timed out",
|
||||
"不是本宿主支持的模块形式": "It is not a module form this host supports",
|
||||
"它挂钩的事件": "Events it hooks",
|
||||
"读你的对话": "Reads your conversation",
|
||||
"往模型面前排提示": "Queues prompts in front of the model",
|
||||
"以它自己的名义,不冒充你": "Under its own name, never as you",
|
||||
"(没有列出)": "(none listed)",
|
||||
"名字无法列出的事件": "Events whose names cannot be listed",
|
||||
"它用到的接口": "API it uses",
|
||||
"它引入的代码(看不见)": "Code it pulls in (not visible)",
|
||||
"会让清单列不全的写法": "Constructs that leave the listing incomplete",
|
||||
"用了 eval": "Uses eval",
|
||||
"用了 new Function": "Uses new Function",
|
||||
"用计算出来的名字访问接口": "Reaches the API by a computed name",
|
||||
"把接口对象交给了别处": "Hands the API object elsewhere",
|
||||
"把注册函数交给了别处": "Hands the registering function elsewhere",
|
||||
"把接口对象解构了出来": "Destructures the API object",
|
||||
"没有找到导出的 register": "No exported register was found",
|
||||
"本应用不会触发的事件": "Events this app never raises",
|
||||
"模块": "Module",
|
||||
"这是插件「{id}」的提问;回答它不会批准任何工具调用或权限。": "This is a question from the plugin \"{id}\"; answering it does not approve any tool call or permission.",
|
||||
};
|
||||
@@ -3,6 +3,15 @@
|
||||
// en_settings and en_kernel were — one screen's worth of wording, read together.
|
||||
|
||||
export const EN_WINDOW: Record<string, string> = {
|
||||
"打开项目": "Open project",
|
||||
"项目列表的更多操作": "More project list actions",
|
||||
"项目与会话": "Projects and conversations",
|
||||
"选择本机文件夹": "Choose a local folder",
|
||||
"连接与机器": "Connections and machines",
|
||||
"打开第一个项目": "Open your first project",
|
||||
"展开所有项目": "Expand all projects",
|
||||
"收起所有项目": "Collapse all projects",
|
||||
"搜索时自动展开项目": "Projects expand automatically while searching",
|
||||
"这台机器的更多操作": "More actions for this machine",
|
||||
"使用系统默认应用打开": "Open with default application",
|
||||
"图片加载失败,它可能已被移动、删除或超过大小限制": "The image could not be loaded. It may have been moved, deleted, or exceed the size limit.",
|
||||
|
||||
@@ -142,7 +142,7 @@ const SAID: Record<string, string> = {
|
||||
"model.not_decision_source": "「{model}」是对话模型,不是决策来源;决策只能选择「模型服务」里添加的决策来源",
|
||||
"roles.override_not_in_user_config": "「{key}」不在用户配置里,可能已被清除,或来自项目配置",
|
||||
"roles.search_model_bad_ref": "搜索模型「{model}」的格式应为 服务/模型",
|
||||
"roles.search_model_not_added": "搜索模型「{model}」所在的连接已被移出",
|
||||
"roles.search_model_not_added": "搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它",
|
||||
"roles.search_model_removed": "搜索模型「{model}」已不在配置里",
|
||||
"roles.search_model_unsupported": "搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索",
|
||||
"roles.search_model_no_credentials": "搜索模型「{model}」所在的连接没有凭据",
|
||||
@@ -351,6 +351,11 @@ const SAID: Record<string, string> = {
|
||||
"session.in_use": "该会话正被其他位置占用,请先关闭该处",
|
||||
// 占用者在另一个进程时,这个窗口里没有可关闭的对象,pid 是唯一可操作的事实。
|
||||
"session.in_use_by": "该会话被另一个进程占用({host} 上的 pid {pid}),结束它之后再删",
|
||||
"session.move_open": "该会话正在窗口里打开,先关闭它再移动",
|
||||
"session.move_held": "该会话还在被写入,稍后再移动",
|
||||
"session.move_held_by": "该会话被另一个进程占用({host} 上的 pid {pid}),结束它之后再移动",
|
||||
"session.same_project": "它已经在这个项目里了",
|
||||
"session.unreadable": "这个会话的记录不完整,不能移动;先让它自己恢复",
|
||||
"session.bind_failed": "接管该会话失败,请重新打开窗口",
|
||||
"session.has_open_pane": "该会话仍有打开的面板,请先关闭",
|
||||
"session.outside_workspace": "该路径不属于任何已知的工作区",
|
||||
@@ -368,7 +373,11 @@ const SAID: Record<string, string> = {
|
||||
"remote.name_required": "请为该机器填写名称",
|
||||
"remote.host_required": "请填写要连接的地址",
|
||||
"remote.bad_port": "这不是有效的端口号",
|
||||
"remote.distro_invalid": "这不是有效的 WSL 发行版名称",
|
||||
"remote.wsl_unreadable": "读不到已安装的 WSL 发行版",
|
||||
"remote.kind_unsupported": "这台机器的接入方式与表单不同(SSH 或 WSL),不能这样修改它",
|
||||
"remote.disabled": "该主机已停用,启用后才能连接",
|
||||
"remote.unknown_host": "主机 {host} 尚未保存,请先添加该主机再连接",
|
||||
"remote.has_open_panes": "该机器仍有 {n} 个打开的面板,请先关闭再移除",
|
||||
// 主机密钥变了没有「仍然连接」这条路:能绕过的警告等于没有警告。
|
||||
"remote.host_key_changed": "{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。",
|
||||
@@ -399,6 +408,13 @@ const SAID: Record<string, string> = {
|
||||
"mcp.editing_disabled": "这个客户端不能登录 MCP 服务器",
|
||||
"mcp.signin_declined": "你没有同意前往该登录站点,已取消登录",
|
||||
"mcp.signin_ineligible": "这个服务器不使用 OAuth 登录",
|
||||
"observability.editing_disabled": "这个客户端不能修改追踪导出设置",
|
||||
"observability.endpoint_invalid": "收集器地址必须是带主机名的 http 或 https 地址",
|
||||
"observability.endpoint_credentials": "收集器地址里不能带用户名、密码、查询串或片段",
|
||||
"observability.header_invalid": "这不是可用的请求头名称",
|
||||
"observability.header_env_invalid": "凭据环境变量必须以 OTEL_ 或 REASONIX_OTEL_ 开头",
|
||||
"observability.save_failed": "追踪导出设置没有保存成功",
|
||||
"mcp.no_scope_upgrade": "这个服务器没有要求更多权限",
|
||||
"mcp.signin_busy": "这个服务器已经在登录中",
|
||||
"mcp.signin_no_host": "服务器给出的登录页没有主机名,已停止",
|
||||
"mcp.signin_no_browser": "这台机器打不开浏览器",
|
||||
@@ -411,6 +427,13 @@ const SAID: Record<string, string> = {
|
||||
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
|
||||
"inbox.not_found": "该条已不在待送达队列中",
|
||||
"tool_call.not_running": "这条调用已经结束了",
|
||||
"subagent.not_found": "没有找到这个子代理的记录",
|
||||
"subagent.not_owned": "这个子代理不属于当前会话",
|
||||
"subagent.read_failed": "读取子代理记录失败:{detail}",
|
||||
"subagent.not_running": "这个子代理已经不在运行,读不到新的指示",
|
||||
"subagent.steer_empty": "指示不能是空的",
|
||||
"subagent.steer_too_large": "这条指示太长了,超过了发给子代理的单条上限",
|
||||
"subagent.steer_rate_limited": "短时间内发给这个子代理的指示太多了,请稍后再发",
|
||||
"tool.cancelled_by_user": "你停止了这条调用,它在外部已经做了什么无法确定",
|
||||
"history.query_required": "请输入要搜索的内容",
|
||||
"history.search_failed": "对话内容索引暂时无法回答,稍后再试",
|
||||
@@ -428,6 +451,7 @@ const SAID: Record<string, string> = {
|
||||
"inbox.schema_readonly": "该待送达由更高版本写入,当前版本只能读取",
|
||||
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
|
||||
"inbox.foreign_run": "这份待送达属于另一个运行,暂不能接管",
|
||||
"inbox.unrecovered": "待送达的正文读不出来,队列内容未知,已暂停等待检查",
|
||||
|
||||
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
|
||||
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
|
||||
@@ -446,12 +470,17 @@ const SAID: Record<string, string> = {
|
||||
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
|
||||
"subagent_claims.editing_disabled": "这台服务器未开放子代理写入范围设置",
|
||||
"subagent_claims.save_failed": "子代理写入范围设置未能保存:{detail}",
|
||||
"subagent_steer.editing_disabled": "这台服务器未开放子代理指示上限设置",
|
||||
"subagent_steer.save_failed": "子代理指示上限设置未能保存:{detail}",
|
||||
"subagent_claims.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"write_lease.editing_disabled": "这台服务器未开放多会话写入设置",
|
||||
"write_lease.save_failed": "多会话写入设置未能保存:{detail}",
|
||||
"plan_approval.editing_disabled": "这台服务器未开放计划批准设置",
|
||||
"plan_approval.save_failed": "计划批准设置未能保存:{detail}",
|
||||
"plan_approval.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"default_mode.editing_disabled": "这台服务器未开放新对话起始模式设置",
|
||||
"default_mode.save_failed": "新对话起始模式设置未能保存:{detail}",
|
||||
"default_mode.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"peer.disabled": "会话间消息未开启",
|
||||
"peer.editing_disabled": "这个客户端不能收发会话间消息",
|
||||
"peer.not_found": "本机没有叫这个名字的会话",
|
||||
@@ -464,7 +493,18 @@ const SAID: Record<string, string> = {
|
||||
"peer.not_held": "这条消息已经处理过了",
|
||||
"peer.send_failed": "会话间消息没能完成:{detail}",
|
||||
"peer.invalid_project": "放行的项目必须是一个绝对路径的文件夹",
|
||||
"peer.invalid_ceiling": "派发上限是 0 或更大的整数,0 表示不限制",
|
||||
"peer.remember_denied": "只有批准才能记住,拒绝只对这一条生效",
|
||||
"mods.invalid_budget": "单个钩子的运行时限是 0 到 55000 毫秒,0 表示默认的 10 秒",
|
||||
"mods.editing_disabled": "这个客户端不能修改 mod 设置",
|
||||
"mods.withheld": "运行 Claude Code mod 的开关还没有开放,现在不能打开",
|
||||
"mod.switch_off": "「运行 Claude Code mod」没有打开,所以没有运行任何 mod",
|
||||
"mod.runtime_missing": "这台电脑上找不到能运行 mod 的 Node 运行时",
|
||||
"mod.runtime_unsupported": "找到的 Node 运行时版本过低,不能运行这个 mod",
|
||||
"mod.crashed": "这个 mod 的进程崩溃了,正在按退避重新启动",
|
||||
"mod.hook_timeout": "它的某个钩子超过了时间预算,进程已被结束;多次发生后会被停用",
|
||||
"mod.hook_threw": "它的某个钩子抛出了异常",
|
||||
"mod.unsupported": "它用到了本宿主不支持的功能",
|
||||
"peer.settings_unwritable": "会话间消息的设置没能保存:{detail}",
|
||||
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
|
||||
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
@@ -473,18 +513,23 @@ const SAID: Record<string, string> = {
|
||||
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
|
||||
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
|
||||
"changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
|
||||
"feedback_level_notices.save_failed": "等级提示的设置未能保存:{detail}",
|
||||
"fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}",
|
||||
"turn_narration.save_failed": "回合间叙述的设置未能保存:{detail}",
|
||||
"web_access.editing_disabled": "这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。",
|
||||
"web_access.save_failed": "联网搜索与抓取的设置未能保存:{detail}",
|
||||
"web_access.unknown_backend": "“{backend}”不是可选的联网后端,未做任何修改。",
|
||||
"web_access.probe_failed": "没能测试这个地址:{detail}",
|
||||
"web_access.bad_searxng_url": "SearXNG 地址必须是带主机名的 http(s) 地址,不能含账号、查询串或片段,未做任何修改。",
|
||||
"ask_timeout.save_failed": "提问等待时长未能保存:{detail}",
|
||||
"ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
|
||||
"session_ref.save_failed": "引用历史会话的长度上限未能保存:{detail}",
|
||||
"session_ref.out_of_range": "长度上限超出允许范围,未做任何修改:{detail}",
|
||||
"receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
|
||||
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
|
||||
"auto_archive.save_failed": "自动归档设置未能保存:{detail}",
|
||||
"auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}",
|
||||
"persona.too_large": "人设内容超出长度上限,未做任何修改:{detail}",
|
||||
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
|
||||
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
|
||||
"progress_watch.out_of_range": "该数值超出允许范围,未做任何修改:{detail}",
|
||||
@@ -613,6 +658,8 @@ const SAID: Record<string, string> = {
|
||||
"feedback.image_metadata": "截图无法清除其中的元数据,请换一张或先用截图工具重新导出",
|
||||
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
|
||||
"feedback.not_replyable": "这份反馈现在不接收回复",
|
||||
"feedback.not_withdrawable": "这份反馈已经有了结论,不能再撤回",
|
||||
"feedback.not_found": "找不到这份反馈",
|
||||
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
|
||||
"feedback.account_required": "需要先登录账号",
|
||||
"feedback.link_conflict": "这台电脑已关联到另一个账号",
|
||||
@@ -638,6 +685,10 @@ const SAID: Record<string, string> = {
|
||||
"skills.update_not_local": "这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)",
|
||||
"skills.update_source_missing": "找不到来源文件夹,可能被移动或删除了",
|
||||
"skills.update_plan_stale": "自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新",
|
||||
"skill.not_removable": "这个技能不是你自己的文件,不能在这里卸载(内置技能或由包提供的技能,随包卸载)",
|
||||
"skill.changed": "这个技能自你打开列表后已经变了,没有删除任何内容。请重新读取后再试",
|
||||
"skill.remove_failed": "卸载技能失败:{detail}",
|
||||
"skill.other_project": "其他项目的技能不能在这里卸载",
|
||||
"skills.update_needs_confirm": "已安装的副本被改过,更新会覆盖这些改动,需要你确认",
|
||||
"skills.update_too_large": "这个技能太大,无法更新(文件数或总大小超过上限)",
|
||||
"skills.update_failed": "更新没有完成,已安装的副本保持原样:{detail}",
|
||||
|
||||
@@ -106,6 +106,17 @@ export interface SubagentClaimsSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// The bounds on guidance sent to a running sub-agent, as the user file records
|
||||
// them: bytes in one message and messages per minute to one sub-agent, zero
|
||||
// lifting a bound. A project file cannot set them.
|
||||
export interface SubagentSteerSettings {
|
||||
maxBytes: number;
|
||||
perMinute: number;
|
||||
defaultMaxBytes: number;
|
||||
defaultPerMinute: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// How this machine's sessions hold the workspace write lease against one
|
||||
// another, as the user file records it. A project file cannot set it. A running
|
||||
// session keeps the lease it was built with, so a change reaches sessions
|
||||
@@ -122,6 +133,13 @@ export interface PlanApprovalSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Whether a new conversation starts in plan mode, as the user file records it.
|
||||
// A project file cannot set it.
|
||||
export interface DefaultModeSettings {
|
||||
plan: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PeerView {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -168,6 +186,9 @@ export interface PeersSettings {
|
||||
enabled: boolean;
|
||||
dispatch: boolean;
|
||||
release_projects: PeerProject[];
|
||||
// Ceilings on chained and simultaneous dispatches; 0 removes a ceiling.
|
||||
max_dispatch_depth: number;
|
||||
max_dispatched_sessions: number;
|
||||
path: string;
|
||||
applies: "next_turn";
|
||||
pending: boolean;
|
||||
@@ -180,6 +201,8 @@ export interface PeersSettingsEdit {
|
||||
enabled?: boolean;
|
||||
dispatch?: boolean;
|
||||
release_projects?: string[];
|
||||
max_dispatch_depth?: number;
|
||||
max_dispatched_sessions?: number;
|
||||
}
|
||||
|
||||
export type PeerRemember = "project" | "none";
|
||||
@@ -196,6 +219,26 @@ export interface AutoArchiveSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// The user's system persona. The kernel serves the built-in starting points
|
||||
// with the settings so the page never keeps a second copy of the prompt text.
|
||||
export interface PersonaPreset {
|
||||
id: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
include_runtime_context: boolean;
|
||||
}
|
||||
|
||||
export interface PersonaSettings {
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
include_runtime_context: boolean;
|
||||
path: string;
|
||||
presets: PersonaPreset[];
|
||||
}
|
||||
|
||||
/** The three fields a save writes; path and presets are the kernel's answer. */
|
||||
export type PersonaDraft = Pick<PersonaSettings, "prefix" | "suffix" | "include_runtime_context">;
|
||||
|
||||
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
|
||||
|
||||
// Which currency costs are shown in; the user file holds it.
|
||||
@@ -240,10 +283,41 @@ export interface WebAccessSettings {
|
||||
known_fetch_backends: string[];
|
||||
paid_backends: string[];
|
||||
deepseek_api_ready: boolean;
|
||||
video_url_passthrough: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type WebAccessPatch = Partial<Pick<WebAccessSettings, "search_backends" | "searxng_url" | "search_paid_allowed" | "fetch_backends" | "fetch_paid_allowed">>;
|
||||
// What one test search to a SearXNG address showed. code is empty when it answered.
|
||||
export interface SearxngProbe {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export type WebAccessPatch = Partial<Pick<WebAccessSettings, "search_backends" | "searxng_url" | "search_paid_allowed" | "fetch_backends" | "fetch_paid_allowed" | "video_url_passthrough">>;
|
||||
|
||||
// The opt-in OpenTelemetry trace export. headerEnvSet says the named variable
|
||||
// has a value now; the value itself is never read back. A save reaches sessions
|
||||
// opened afterwards, not the ones already running.
|
||||
export interface ObservabilitySettings {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
headerName: string;
|
||||
headerEnv: string;
|
||||
serviceName: string;
|
||||
headerEnvSet: boolean;
|
||||
path: string;
|
||||
applies: string;
|
||||
}
|
||||
|
||||
export type ObservabilityPatch = Partial<Pick<ObservabilitySettings, "enabled" | "endpoint" | "headerName" | "headerEnv" | "serviceName">>;
|
||||
|
||||
// Whether the person is told when their feedback level goes up; the user file
|
||||
// holds it, and it starts off.
|
||||
export interface FeedbackLevelNotices {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ChangedFilesSettings {
|
||||
show: boolean;
|
||||
@@ -264,6 +338,16 @@ export interface AskTimeoutSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// How many tokens one referenced past conversation may add to a message; the
|
||||
// user file holds it.
|
||||
export interface SessionRefSettings {
|
||||
tokens: number;
|
||||
defaultTokens: number;
|
||||
minTokens: number;
|
||||
maxTokens: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Whether the end-of-turn delivery receipt is drawn as a card; the user file
|
||||
// holds it, so the terminal reads the same answer.
|
||||
export interface ReceiptSettings {
|
||||
@@ -279,3 +363,45 @@ export interface ProgressWatchSettings {
|
||||
defaultTokenMultiple: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Where a Claude Code mod's hooks module stands in this session. */
|
||||
export type ModState = "inactive" | "starting" | "running" | "restarting" | "disabled";
|
||||
|
||||
export interface ModRuntime {
|
||||
// "electron" for Studio's own binary in Node mode, "node" for one found on PATH.
|
||||
kind: string;
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** One installed mod. `code` is the typed cause when it is not running
|
||||
* (mod.switch_off, mod.runtime_missing, mod.crashed, ...); a frontend maps it
|
||||
* to its own words and never reads `detail` to decide anything. */
|
||||
export interface ModStatus {
|
||||
plugin: string;
|
||||
state: ModState;
|
||||
code?: string;
|
||||
detail?: string;
|
||||
restarts: number;
|
||||
runtime?: ModRuntime;
|
||||
// Literal event names the module registered; `unserved` are the ones this host never raises.
|
||||
events?: string[];
|
||||
unserved?: string[];
|
||||
}
|
||||
|
||||
/** The user file's [mods] table. Turning `enabled` on is Elevated: the module
|
||||
* of an installed mod then runs on this machine with the user's trust. */
|
||||
export interface ModsSettings {
|
||||
enabled: boolean;
|
||||
// One hook's own running time in milliseconds; 0 keeps Claude Code's 10000.
|
||||
hook_budget_ms: number;
|
||||
path: string;
|
||||
// False until the switch can be turned on; the row is not drawn then.
|
||||
available: boolean;
|
||||
applies: "next_session";
|
||||
}
|
||||
|
||||
export interface ModsSettingsEdit {
|
||||
enabled?: boolean;
|
||||
hook_budget_ms?: number;
|
||||
}
|
||||
@@ -9,10 +9,10 @@ export const FEEDBACK_CATEGORIES: readonly FeedbackCategory[] = ["bug", "idea",
|
||||
// What a person may see. A report waiting for triage reads as received; one
|
||||
// the maintainers declined reads as closed, with no reason.
|
||||
export type FeedbackStatus =
|
||||
| "received" | "needs_info" | "answered" | "recorded" | "in_progress" | "fixed" | "wontfix" | "duplicate" | "closed";
|
||||
| "received" | "needs_info" | "answered" | "recorded" | "in_progress" | "fixed" | "wontfix" | "duplicate" | "closed" | "withdrawn";
|
||||
|
||||
export const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [
|
||||
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed",
|
||||
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed", "withdrawn",
|
||||
];
|
||||
|
||||
export const isUnderReview = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
|
||||
@@ -27,6 +27,12 @@ export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "ans
|
||||
export const canReplyTo = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
|
||||
!item.statusUnavailable && (FEEDBACK_REPLYABLE.includes(item.status) || (item.status === "received" && !isUnderReview(item)));
|
||||
|
||||
// A report can be taken back until it has an outcome; the service has the last word.
|
||||
export const FEEDBACK_WITHDRAWABLE: readonly FeedbackStatus[] = ["received", "needs_info", "answered", "recorded", "in_progress"];
|
||||
|
||||
export const canWithdraw = (item: Pick<FeedbackItem, "status" | "statusUnavailable">): boolean =>
|
||||
!item.statusUnavailable && FEEDBACK_WITHDRAWABLE.includes(item.status);
|
||||
|
||||
export interface FeedbackEnvInfo {
|
||||
version: string;
|
||||
commit: string;
|
||||
@@ -100,6 +106,8 @@ export interface FeedbackReply {
|
||||
}
|
||||
|
||||
export interface FeedbackItem {
|
||||
// A report another device of the same account filed; read marks stay on this computer.
|
||||
otherInstall?: boolean;
|
||||
receipt: string;
|
||||
category: FeedbackCategory;
|
||||
titleSnippet: string;
|
||||
@@ -162,6 +170,13 @@ export interface FeedbackMine {
|
||||
unread: number;
|
||||
hasNew: boolean;
|
||||
profile?: FeedbackProfile | null;
|
||||
// A level the service now states that this machine has not told the person about.
|
||||
promotion?: FeedbackPromotion | null;
|
||||
}
|
||||
|
||||
export interface FeedbackPromotion {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
// One install an account holds, as the service lists it: the short id, when it
|
||||
@@ -200,6 +215,8 @@ export const FEEDBACK_CODE = {
|
||||
badToken: "feedback.bad_token",
|
||||
replyLimit: "feedback.reply_limit",
|
||||
notReplyable: "feedback.not_replyable",
|
||||
notWithdrawable: "feedback.not_withdrawable",
|
||||
notFound: "feedback.not_found",
|
||||
challengeRequired: "feedback.challenge_required",
|
||||
accountRequired: "feedback.account_required",
|
||||
linkConflict: "feedback.link_conflict",
|
||||
|
||||
@@ -13,11 +13,36 @@ function answer(status: number, body: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
it("keeps local project moves and host-qualified pin/archive calls on their owning routes", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
try {
|
||||
const hub = new SseHub();
|
||||
await hub.pinSession("/same/session.jsonl", true);
|
||||
await hub.pinSession("/same/session.jsonl", false, "gpu box");
|
||||
await hub.archiveSession("/same/session.jsonl", true);
|
||||
await hub.archiveSession("/same/session.jsonl", false, "gpu box");
|
||||
await hub.moveSessionTo("/same/session.jsonl", "/other-project");
|
||||
expect(fetch.mock.calls.map(([path, init]) => [path, JSON.parse(init.body)])).toEqual([
|
||||
["/tree/sessions/pin", { path: "/same/session.jsonl", pinned: true }],
|
||||
["/remotes/gpu%20box/sessions/pin", { path: "/same/session.jsonl", pinned: false }],
|
||||
["/tree/sessions/archive", { path: "/same/session.jsonl", archived: true }],
|
||||
["/remotes/gpu%20box/sessions/archive", { path: "/same/session.jsonl", archived: false }],
|
||||
["/tree/sessions/move-to", { path: "/same/session.jsonl", target: "/other-project" }],
|
||||
]);
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
// The same defect SseHttp was fixed for, still standing in the hub port: asking
|
||||
// for JSON first threw away every answer that was not an envelope, and what
|
||||
// reached the window was "this request did not reach the kernel (HTTP 502)" —
|
||||
// wrong twice over, because it had, and the kernel had explained itself.
|
||||
describe("a failed hub call", () => {
|
||||
it("surfaces a remote book conflict instead of treating it as an unread book", async () => {
|
||||
answer(409, JSON.stringify({ code: "remote.serve_provider_mismatch", error: "provider setting conflict", params: { host: "gpu-box" } }));
|
||||
await expect(new SseHub().remoteTree("gpu-box")).rejects.toMatchObject({ status: 409,
|
||||
reason: { code: "remote.serve_provider_mismatch", params: { host: "gpu-box" } } });
|
||||
});
|
||||
it("carries a plain-text reason through", async () => {
|
||||
answer(502, "remote /runtimes: 400 Bad Request: workspace /srv/data is not a directory");
|
||||
const err = (await new SseHub().openRemote({ host: "gpu-box" }).catch((e) => e)) as HttpError;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { watchTree } from "./tree_events";
|
||||
import { HttpError, type AgentPort } from "./port";
|
||||
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe } from "./remote";
|
||||
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe, WSLDistros } from "./remote";
|
||||
import type { CloudShareOffer, DeviceSelf, ShareOffer, SharePort, ShareStatus } from "./share";
|
||||
import { SsePort } from "./sse";
|
||||
import { host } from "./host";
|
||||
@@ -177,12 +177,15 @@ export interface HubPort extends SharePort {
|
||||
/** Open a listed project in one of those programs. */
|
||||
openWith(path: string, target: string): Promise<void>;
|
||||
moveWorkspace(path: string, direction: -1 | 1): Promise<void>;
|
||||
moveSession(path: string, direction: -1 | 1): Promise<void>;
|
||||
/** Drop a project before another remembered one, or at the end when `before` is empty. */
|
||||
placeWorkspace(path: string, before: string): Promise<void>;
|
||||
removeSession(path: string): Promise<void>;
|
||||
archiveSession(path: string, archived: boolean): Promise<void>;
|
||||
/** The conversation moves into the named project; originalRemoved is false when the copy arrived but the original could not be taken away. */
|
||||
moveSessionTo(path: string, target: string): Promise<{ path: string; originalRemoved: boolean }>;
|
||||
archiveSession(path: string, archived: boolean, host?: string): Promise<void>;
|
||||
// Tells the kernel which conversations automatic archiving must leave alone.
|
||||
pinSession(path: string, pinned: boolean): Promise<void>;
|
||||
pinSession(path: string, pinned: boolean, host?: string): Promise<void>;
|
||||
// Adds pins made before the kernel kept them. The first call also opens the
|
||||
// gate: until then the kernel archives nothing on its own.
|
||||
syncPins(paths: string[]): Promise<void>;
|
||||
@@ -221,6 +224,8 @@ export interface HubPort extends SharePort {
|
||||
// taken yet. Filling it by hand when the addresses are written down next
|
||||
// door is the step people skip.
|
||||
remoteCandidates(): Promise<string[]>;
|
||||
// The WSL distributions on this machine, for the choice of one as a host.
|
||||
wslDistros(): Promise<WSLDistros>;
|
||||
// Questions a connect stopped for. Nothing arrives here in a browser: the
|
||||
// kernel that would ask is the one this build cannot reach.
|
||||
onRemoteAsk(cb: (ask: RemoteAsk) => void): () => void;
|
||||
@@ -367,20 +372,28 @@ export class SseHub implements HubPort {
|
||||
await this.post<void>("/tree/workspaces/move", { path, direction });
|
||||
}
|
||||
|
||||
async moveSession(path: string, direction: -1 | 1) {
|
||||
await this.post<void>("/tree/sessions/move", { path, direction });
|
||||
}
|
||||
|
||||
async placeWorkspace(path: string, before: string) {
|
||||
await this.post<void>("/tree/workspaces/move", { path, before });
|
||||
}
|
||||
|
||||
async moveSessionTo(path: string, target: string) {
|
||||
return this.post<{ path: string; originalRemoved: boolean }>("/tree/sessions/move-to", { path, target });
|
||||
}
|
||||
|
||||
async removeSession(path: string) {
|
||||
await this.post<void>("/tree/sessions/remove", { path });
|
||||
}
|
||||
|
||||
async archiveSession(path: string, archived: boolean) {
|
||||
await this.post<void>("/tree/sessions/archive", { path, archived });
|
||||
async archiveSession(path: string, archived: boolean, host?: string) {
|
||||
await this.post<void>(host ? `/remotes/${encodeURIComponent(host)}/sessions/archive` : "/tree/sessions/archive", { path, archived });
|
||||
}
|
||||
|
||||
async pinSession(path: string, pinned: boolean) {
|
||||
await this.post<void>("/tree/sessions/pin", { path, pinned });
|
||||
async pinSession(path: string, pinned: boolean, host?: string) {
|
||||
await this.post<void>(host ? `/remotes/${encodeURIComponent(host)}/sessions/pin` : "/tree/sessions/pin", { path, pinned });
|
||||
}
|
||||
|
||||
async syncPins(paths: string[]) {
|
||||
@@ -429,9 +442,6 @@ export class SseHub implements HubPort {
|
||||
async remoteTree(host: string) {
|
||||
const path = `/remotes/${encodeURIComponent(host)}/tree`;
|
||||
const res = await fetch(path, { credentials: "same-origin" });
|
||||
// Nothing open on that machine yet, which is a step to take rather than a
|
||||
// failure to report.
|
||||
if (res.status === 409) return null;
|
||||
if (!res.ok) await SseHub.fail(path, res);
|
||||
return (await res.json()) as TreeWorkspace[];
|
||||
}
|
||||
@@ -469,6 +479,10 @@ export class SseHub implements HubPort {
|
||||
return this.get<string[]>("/remotes/candidates");
|
||||
}
|
||||
|
||||
wslDistros() {
|
||||
return this.get<WSLDistros>("/remotes/wsl");
|
||||
}
|
||||
|
||||
// The question is state the kernel holds, and this is how it is found. No
|
||||
// stream carries it: it exists only while a dial this client started is still
|
||||
// waiting, and that is a thing the client already knows.
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface McpEntry {
|
||||
launch?: string;
|
||||
// Why a pending server waits for the user, as the kernel's code.
|
||||
pendingReason?: string;
|
||||
// What a signed-in server asked to be allowed, in its own words. Only the
|
||||
// user grants it, by clicking; nothing here reads the scope names.
|
||||
scopeUpgrade?: string[];
|
||||
// Tools this connection withheld from the model because their definition is
|
||||
// not the one the user approved. heldDigest names exactly that set, and
|
||||
// approving it hands the digest back so what is approved is what was shown.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sessionCompletion } from "./mock_sessions";
|
||||
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
|
||||
import type { JobOutput, PlanAction } from "./session";
|
||||
import { approvalKind } from "./mock_approval";
|
||||
@@ -327,6 +328,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
})).filter((it) => it.insert !== line.slice(at, to));
|
||||
return { kind: "ref", from: at, to, query: frag.slice(dir.length), items };
|
||||
}
|
||||
const hash = sessionCompletion(line, cursor);
|
||||
if (hash) return hash;
|
||||
if (line.startsWith("/") && !/\s/.test(line)) {
|
||||
const q = line.toLowerCase();
|
||||
const items = [...this.builtins, ...this.commands].filter((it) => it.label.toLowerCase().startsWith(q));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
|
||||
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import { mockMods } from "./mock_mods";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockShell } from "./mock_shell";
|
||||
import type { PersonaDraft, PersonaSettings } from "./boundary";
|
||||
|
||||
// The boundary half of the fixture: what the agent is refused outright, and how
|
||||
// far an approved write reaches. It carries one rule of each kind already, so
|
||||
@@ -15,6 +17,9 @@ function effectiveBash(s: SandboxSettings): string {
|
||||
return s.bash.trim() === "off" ? "off" : "enforce";
|
||||
}
|
||||
|
||||
// One file per machine, so every port of the demo reads the same answer.
|
||||
let levelNotices: FeedbackLevelNotices = { enabled: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
export class MockBoundary extends MockShell {
|
||||
async decideWorkspaceTrust(_trust: "trusted" | "declined") {}
|
||||
// One interrupted barrier and one that a later turn took over, so the card
|
||||
@@ -148,6 +153,17 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.claims };
|
||||
}
|
||||
|
||||
private steerLimits: SubagentSteerSettings = { maxBytes: 16384, perMinute: 30, defaultMaxBytes: 16384, defaultPerMinute: 30, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async subagentSteerSettings(): Promise<SubagentSteerSettings> {
|
||||
return { ...this.steerLimits };
|
||||
}
|
||||
|
||||
async saveSubagentSteerSettings(maxBytes: number, perMinute: number): Promise<SubagentSteerSettings> {
|
||||
this.steerLimits = { ...this.steerLimits, maxBytes, perMinute };
|
||||
return { ...this.steerLimits };
|
||||
}
|
||||
|
||||
private plans: PlanApprovalSettings = { auto: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async planApproval(): Promise<PlanApprovalSettings> {
|
||||
@@ -159,6 +175,17 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.plans };
|
||||
}
|
||||
|
||||
private modes: DefaultModeSettings = { plan: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async defaultMode(): Promise<DefaultModeSettings> {
|
||||
return { ...this.modes };
|
||||
}
|
||||
|
||||
async saveDefaultMode(plan: boolean): Promise<DefaultModeSettings> {
|
||||
this.modes = { ...this.modes, plan };
|
||||
return { ...this.modes };
|
||||
}
|
||||
|
||||
private lease: WriteLeaseSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async writeLease(): Promise<WriteLeaseSettings> {
|
||||
@@ -186,7 +213,23 @@ export class MockBoundary extends MockShell {
|
||||
this.peerState = { ...this.peerState, held: this.peerState.held.filter((h) => h.id !== id) };
|
||||
}
|
||||
|
||||
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
|
||||
protected modConfig: ModsSettings = { enabled: false, hook_budget_ms: 0, path: "/Users/you/.reasonix/config.toml", available: false, applies: "next_session" };
|
||||
|
||||
async modsSettings(): Promise<ModsSettings> {
|
||||
return { ...this.modConfig };
|
||||
}
|
||||
|
||||
async saveModsSettings(edit: ModsSettingsEdit): Promise<ModsSettings> {
|
||||
if (edit.hook_budget_ms !== undefined && (edit.hook_budget_ms < 0 || edit.hook_budget_ms > 55000)) throw new Error("a hook budget is 0 to 55000 milliseconds");
|
||||
this.modConfig = { ...this.modConfig, enabled: edit.enabled ?? this.modConfig.enabled, hook_budget_ms: edit.hook_budget_ms ?? this.modConfig.hook_budget_ms };
|
||||
return this.modsSettings();
|
||||
}
|
||||
|
||||
async mods(): Promise<ModStatus[]> {
|
||||
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 };
|
||||
|
||||
async peersSettings(): Promise<PeersSettings> {
|
||||
return { ...this.peerSettings, release_projects: [...this.peerSettings.release_projects] };
|
||||
@@ -198,6 +241,8 @@ export class MockBoundary extends MockShell {
|
||||
...this.peerSettings,
|
||||
enabled: edit.enabled ?? this.peerSettings.enabled,
|
||||
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,
|
||||
release_projects: keep ? this.peerSettings.release_projects.filter((p) => keep.includes(p.path)) : this.peerSettings.release_projects,
|
||||
pending: true,
|
||||
};
|
||||
@@ -215,6 +260,32 @@ export class MockBoundary extends MockShell {
|
||||
this.archive = { ...this.archive, ...s };
|
||||
return { ...this.archive };
|
||||
}
|
||||
private personaState: PersonaSettings = {
|
||||
prefix: "", suffix: "", include_runtime_context: true,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
presets: [
|
||||
{
|
||||
id: "engineer",
|
||||
prefix: "You are a rigorous staff engineer. Verify before claiming, make the smallest correct change, and state the residual risk.",
|
||||
suffix: "", include_runtime_context: true,
|
||||
},
|
||||
{
|
||||
id: "concise",
|
||||
prefix: "",
|
||||
suffix: "Be terse. Skip the preamble; prefer code and bullets.",
|
||||
include_runtime_context: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async persona(): Promise<PersonaSettings> {
|
||||
return { ...this.personaState, presets: this.personaState.presets.map((p) => ({ ...p })) };
|
||||
}
|
||||
|
||||
async savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings> {
|
||||
this.personaState = { ...this.personaState, ...s };
|
||||
return this.persona();
|
||||
}
|
||||
|
||||
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml", unconverted: [] };
|
||||
|
||||
@@ -238,6 +309,15 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.changedFiles };
|
||||
}
|
||||
|
||||
async feedbackLevelNotices(): Promise<FeedbackLevelNotices> {
|
||||
return { ...levelNotices };
|
||||
}
|
||||
|
||||
async saveFeedbackLevelNotices(enabled: boolean): Promise<FeedbackLevelNotices> {
|
||||
levelNotices = { ...levelNotices, enabled };
|
||||
return { ...levelNotices };
|
||||
}
|
||||
|
||||
private foldWork: FoldWorkSettings = { fold: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async foldWorkSettings(): Promise<FoldWorkSettings> {
|
||||
@@ -255,10 +335,24 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.narration };
|
||||
}
|
||||
|
||||
private observability: ObservabilitySettings = {
|
||||
enabled: false, endpoint: "", headerName: "", headerEnv: "", serviceName: "", headerEnvSet: false,
|
||||
path: "/Users/you/.reasonix/config.toml", applies: "next_session",
|
||||
};
|
||||
|
||||
async observabilitySettings(): Promise<ObservabilitySettings> {
|
||||
return { ...this.observability };
|
||||
}
|
||||
|
||||
async saveObservabilitySettings(patch: ObservabilityPatch): Promise<ObservabilitySettings> {
|
||||
this.observability = { ...this.observability, ...patch };
|
||||
return { ...this.observability };
|
||||
}
|
||||
|
||||
private webAccess: WebAccessSettings = {
|
||||
search_backends: [], searxng_url: "", search_paid_allowed: [], fetch_backends: [], fetch_paid_allowed: [],
|
||||
known_search_backends: ["searxng", "deepseek_api"], known_fetch_backends: ["local", "deepseek_api"],
|
||||
paid_backends: ["deepseek_api"], deepseek_api_ready: false, path: "/Users/you/.reasonix/config.toml",
|
||||
paid_backends: ["deepseek_api"], deepseek_api_ready: false, video_url_passthrough: false, path: "/Users/you/.reasonix/config.toml",
|
||||
};
|
||||
|
||||
async webAccessSettings(): Promise<WebAccessSettings> {
|
||||
@@ -270,6 +364,10 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.webAccess };
|
||||
}
|
||||
|
||||
async probeSearxng(): Promise<SearxngProbe> {
|
||||
return this.webAccess.searxng_url.trim() ? { ok: true } : { ok: false, code: "search.searxng_unreachable" };
|
||||
}
|
||||
|
||||
async saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings> {
|
||||
this.foldWork = { ...this.foldWork, fold };
|
||||
return { ...this.foldWork };
|
||||
@@ -291,6 +389,18 @@ export class MockBoundary extends MockShell {
|
||||
this.ask = { ...this.ask, seconds };
|
||||
return { ...this.ask };
|
||||
}
|
||||
private sessionRef: SessionRefSettings = { tokens: 8000, defaultTokens: 8000, minTokens: 500, maxTokens: 200000, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async sessionRefSettings(): Promise<SessionRefSettings> {
|
||||
return { ...this.sessionRef };
|
||||
}
|
||||
|
||||
async saveSessionRefSettings(tokens: number): Promise<SessionRefSettings> {
|
||||
if (tokens !== 0 && (tokens < this.sessionRef.minTokens || tokens > this.sessionRef.maxTokens)) throw new Error("session reference limit out of range");
|
||||
this.sessionRef = { ...this.sessionRef, tokens: tokens || this.sessionRef.defaultTokens };
|
||||
return { ...this.sessionRef };
|
||||
}
|
||||
|
||||
private receipt: ReceiptSettings = { show: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async receiptSettings(): Promise<ReceiptSettings> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { download } from "./download";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry, SkillUpdatePlan } from "./port";
|
||||
import { mockModPlan } from "./mock_mods";
|
||||
import { HttpError, type PluginExport, type PluginInstallRequest, type PluginPackage, type PluginPlan, type ScopeLayer, type SkillCatalog, type SkillEntry, type SkillUpdatePlan } from "./port";
|
||||
import { MockMarket } from "./mock_market";
|
||||
|
||||
// The extension half of the fixture: skills, and the packages that bring them.
|
||||
@@ -10,7 +11,7 @@ export class MockExtensions extends MockMarket {
|
||||
private skillList: SkillEntry[] = [
|
||||
{
|
||||
name: "review", slashName: "review", description: "复核这一轮改动,给出严重度分级",
|
||||
scope: "project", path: ".reasonix/skills/review/SKILL.md", subagent: true, enabled: true,
|
||||
scope: "project", path: ".reasonix/skills/review/SKILL.md", removes: ".reasonix/skills/review", removesFolder: true, subagent: true, enabled: true,
|
||||
},
|
||||
{ name: "init", slashName: "init", description: "为这个仓库生成一份项目说明", scope: "builtin", enabled: true },
|
||||
{
|
||||
@@ -21,12 +22,12 @@ export class MockExtensions extends MockMarket {
|
||||
// row the old list could not show at all.
|
||||
{
|
||||
name: "release-notes", description: "从提交历史起草发布说明",
|
||||
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
|
||||
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", removes: "~/.reasonix/skills/release-notes", removesFolder: true, enabled: false,
|
||||
license: "Apache-2.0", compatibility: "需要 git,并能访问网络", metadata: { author: "example-org", version: "1.0" },
|
||||
},
|
||||
{
|
||||
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
|
||||
scope: "project", path: ".reasonix/skills/deploy-runbook/SKILL.md", manual: true, enabled: true,
|
||||
scope: "project", path: ".reasonix/skills/deploy-runbook.md", removes: ".reasonix/skills/deploy-runbook.md", manual: true, enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -77,6 +78,15 @@ export class MockExtensions extends MockMarket {
|
||||
}
|
||||
async applySkillUpdate(): Promise<void> {}
|
||||
|
||||
async removeSkill(name: string, scope: string, path: string): Promise<void> {
|
||||
const at = this.skillList.findIndex((x) => x.name === name);
|
||||
if (at < 0) throw new HttpError(404, "no skill named " + name, { code: "request.not_found" });
|
||||
const sk = this.skillList[at];
|
||||
if (sk.scope === "builtin" || sk.plugin || !sk.path) throw new HttpError(409, "not removable", { code: "skill.not_removable" });
|
||||
if (sk.scope !== scope || sk.path !== path) throw new HttpError(409, "changed", { code: "skill.changed" });
|
||||
this.skillList.splice(at, 1);
|
||||
}
|
||||
|
||||
async setSkillEnabled(name: string, enabled: boolean, scope: ScopeLayer = "project") {
|
||||
const sk = this.skillList.find((x) => x.name === name);
|
||||
if (!sk) return;
|
||||
@@ -102,6 +112,7 @@ export class MockExtensions extends MockMarket {
|
||||
// a hook and connects a server is what the confirmation page exists for.
|
||||
async planPlugin(req: PluginInstallRequest): Promise<PluginPlan> {
|
||||
const source = req.source;
|
||||
if (/\bmod\b/i.test(source)) return mockModPlan(source);
|
||||
return {
|
||||
ok: true, status: "planned", applied: false, source, planId: "high:sha256:mock",
|
||||
actions: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockCommit } from "./mock_commit";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
import { FEEDBACK_CODE, canWithdraw, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
|
||||
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
|
||||
|
||||
@@ -28,6 +28,8 @@ const SEEDED: FeedbackItem[] = [
|
||||
item({ receipt: "FB-3F6G-2KV9", category: "idea", titleSnippet: "Fixed in the next build: quieter update banner", status: "fixed", issueNumber: 11390, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11390", resolvedVersion: "next", createdAt: ago(8), updatedAt: ago(0.5) }),
|
||||
item({ receipt: "FB-8Q2J-6PW4", category: "other", titleSnippet: "Please support a monorepo layout", status: "wontfix", issueNumber: 11288, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11288", createdAt: ago(12), updatedAt: ago(5) }),
|
||||
item({ receipt: "FB-4T7V-3ZH1", category: "bug", titleSnippet: "Crash when opening settings on a small window", status: "duplicate", issueNumber: 11301, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11301", duplicateOf: 11299, createdAt: ago(15), updatedAt: ago(9) }),
|
||||
item({ receipt: "FB-2D8K-4HQ6", category: "idea", titleSnippet: "Sent by mistake, please ignore", status: "withdrawn", createdAt: ago(6), updatedAt: ago(5.5) }),
|
||||
item({ receipt: "FB-7L3Q-2DM8", category: "idea", titleSnippet: "Filed from my other laptop: a calmer sidebar", status: "recorded", issueNumber: 11402, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11402", otherInstall: true, createdAt: ago(3), updatedAt: ago(2) }),
|
||||
item({ receipt: "FB-5R2T-6VB3", category: "bug", titleSnippet: "Settings sheet flickers when the window is dragged", status: "received", underReview: true, createdAt: ago(0.3), updatedAt: ago(0.3) }),
|
||||
item({ receipt: "FB-6M9R-5CE8", category: "question", titleSnippet: "How do I move my sessions to another disk?", status: "received", statusUnavailable: true, createdAt: ago(40), updatedAt: ago(40) }),
|
||||
item({ receipt: "FB-1C5W-7NB2", category: "bug", titleSnippet: "Terminal output is cut off after a resize", status: "needs_info", needsInput: true, createdAt: ago(2), updatedAt: ago(0.2),
|
||||
@@ -55,6 +57,7 @@ function fault(key = FAULT): string {
|
||||
// the same reason: "l0".."l6", "legacy", "lapsed", "revoked", or "none" for a
|
||||
// service that states no profile.
|
||||
const STANDING = "rx-mock-feedback-standing";
|
||||
const PROMOTION = "rx-mock-feedback-promotion";
|
||||
|
||||
const THRESHOLDS = [0, 1, 3, 6, 12, 24, 48];
|
||||
const LEVEL_LIMITS = [[3, 10, 3], [5, 15, 4], [6, 20, 5], [8, 25, 6], [10, 30, 8], [12, 40, 10], [12, 60, 10]] as const;
|
||||
@@ -127,7 +130,19 @@ export class MockFeedback extends MockCommit {
|
||||
return { ...i, needsInput: i.statusUnavailable ? false : i.needsInput, unreadReplies };
|
||||
});
|
||||
const unread = items.filter((i) => i.unreadReplies > 0 || i.needsInput).length;
|
||||
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)) };
|
||||
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)), promotion: this.promotion() };
|
||||
}
|
||||
|
||||
// A tab's own sessionStorage ("2:3") stages a level-up for a dev page, until it is acknowledged.
|
||||
private promotion(): { from: number; to: number } | null {
|
||||
const [from, to] = fault(PROMOTION).split(":").map(Number);
|
||||
return Number.isInteger(from) && Number.isInteger(to) && to > from && !this.acked.has(to) ? { from, to } : null;
|
||||
}
|
||||
|
||||
private acked = new Set<number>();
|
||||
|
||||
async ackFeedbackPromotion(level: number): Promise<void> {
|
||||
this.acked.add(level);
|
||||
}
|
||||
|
||||
announced = 0;
|
||||
@@ -184,6 +199,15 @@ export class MockFeedback extends MockCommit {
|
||||
this.links = this.links.filter((l) => l.id !== id);
|
||||
}
|
||||
|
||||
async withdrawFeedback(receipt: string): Promise<void> {
|
||||
const row = this.rows.find((i) => i.receipt === receipt);
|
||||
if (!row) throw refusal(FEEDBACK_CODE.notFound);
|
||||
if (!canWithdraw(row)) throw refusal(FEEDBACK_CODE.notWithdrawable);
|
||||
row.status = "withdrawn";
|
||||
row.needsInput = false;
|
||||
row.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
async feedbackSeen(receipt: string, upTo: number): Promise<void> {
|
||||
if (this.rows.some((i) => i.receipt === receipt)) this.seen.set(receipt, Math.max(this.seen.get(receipt) ?? 0, upTo));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MockMcp } from "./mock_mcp";
|
||||
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
|
||||
// Hooks, and what a dry run of one reports back.
|
||||
export class MockHook extends MockMcp {
|
||||
@@ -12,6 +13,18 @@ export class MockHook extends MockMcp {
|
||||
throw new HttpError(409, "no running tool call with this id", { code: "tool_call.not_running" });
|
||||
}
|
||||
|
||||
async subagents(): Promise<SubagentRun[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async steerSubagent(_ref: string, _text: string): Promise<void> {
|
||||
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
|
||||
}
|
||||
|
||||
async subagentTranscript(_ref: string): Promise<SubagentTranscript> {
|
||||
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
|
||||
}
|
||||
|
||||
async hooks(): Promise<HookCatalog> {
|
||||
return {
|
||||
hooks: this.hookList.map((h) => ({ ...h })),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
import { expect, it } from "vitest";
|
||||
import { MockHub } from "./mock_hub";
|
||||
|
||||
it("keeps session placement and pin/archive flags in the same mock tree", async () => {
|
||||
const hub = new MockHub();
|
||||
const original = (await hub.tree())[0].sessions;
|
||||
const [first, second] = original;
|
||||
await hub.pinSession(second.path, true);
|
||||
await hub.moveSession(second.path, -1);
|
||||
const moved = (await hub.tree())[0].sessions;
|
||||
expect(moved.map((s) => s.path)).toEqual([second.path, first.path]);
|
||||
expect(moved[0].pinned).toBe(true);
|
||||
await hub.archiveSession(second.path, true);
|
||||
const archived = (await hub.tree())[0].sessions;
|
||||
expect(archived.map((s) => s.path)).toEqual([second.path, first.path]);
|
||||
expect(archived[0]).toMatchObject({ archived: true, pinned: false });
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentPort } from "./port";
|
||||
import type { HistoryHit, HubPort, RuntimeView, TreeWindow, TreeWorkspace } from "./hub";
|
||||
import type { HistoryHit, HubPort, RuntimeView, TreeSession, TreeWindow, TreeWorkspace } from "./hub";
|
||||
import type { RemoteHost, RemoteListing, RemoteProbe } from "./remote";
|
||||
import type { ShareStatus } from "./share";
|
||||
import { MockPort } from "./mock";
|
||||
@@ -16,6 +16,12 @@ export class MockHub implements HubPort {
|
||||
];
|
||||
private readonly roots = ["~/projects/DeepSeek-Reasonix", "~/projects/my-website"];
|
||||
private seq = 1;
|
||||
private readonly flags = new Map<string, Pick<TreeSession, "pinned" | "archived">>();
|
||||
|
||||
private decorate(tree: TreeWorkspace[], host = ""): TreeWorkspace[] {
|
||||
return tree.map((ws) => ({ ...ws, sessions: ws.sessions.map((session) => ({ ...session,
|
||||
...this.flags.get(JSON.stringify([host, session.path])) })) }));
|
||||
}
|
||||
|
||||
runtimes() {
|
||||
return Promise.resolve([...this.views]);
|
||||
@@ -60,7 +66,7 @@ export class MockHub implements HubPort {
|
||||
// the tree under a connected host can be designed without one.
|
||||
remoteTree(host: string) {
|
||||
if (host !== "gpu-box") return Promise.resolve(null);
|
||||
return Promise.resolve<TreeWorkspace[]>([
|
||||
return Promise.resolve<TreeWorkspace[]>(this.decorate([
|
||||
{
|
||||
root: "/srv/training",
|
||||
name: "training",
|
||||
@@ -70,7 +76,7 @@ export class MockHub implements HubPort {
|
||||
],
|
||||
},
|
||||
{ root: "/srv/scratch", name: "scratch", sessions: [] },
|
||||
]);
|
||||
], host));
|
||||
}
|
||||
|
||||
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
|
||||
@@ -144,6 +150,10 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve(["attic", "old-laptop"]);
|
||||
}
|
||||
|
||||
wslDistros() {
|
||||
return Promise.resolve({ available: true, distros: [{ name: "Ubuntu-24.04", version: 2, default: true }, { name: "Debian", version: 2 }, { name: "Alpine-legacy", version: 1 }] });
|
||||
}
|
||||
|
||||
// The fixture has no link to block, so nothing ever asks. The dialog is
|
||||
// driven from a story instead of from a stub that would fire on load.
|
||||
onRemoteAsk() {
|
||||
@@ -164,19 +174,19 @@ export class MockHub implements HubPort {
|
||||
tree(_window?: TreeWindow) {
|
||||
const open = new Map(this.views.filter((v) => v.sessionPath).map((v) => [v.sessionPath!, v.id]));
|
||||
return Promise.resolve(
|
||||
this.roots.map<TreeWorkspace>((root, i) => ({
|
||||
this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
|
||||
root,
|
||||
name: root.split("/").pop() ?? root,
|
||||
open: this.views.some((v) => v.root === root),
|
||||
remembered: true,
|
||||
sessions:
|
||||
sessions: this.placed(root,
|
||||
i === 0
|
||||
? [
|
||||
{ path: "/sessions/mock.jsonl", name: "mock", title: "并行会话演示", turns: 3, runtimeId: open.get("/sessions/mock.jsonl") },
|
||||
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl"), legacy: true },
|
||||
]
|
||||
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }],
|
||||
})),
|
||||
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }]),
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +211,26 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Where the person placed sessions, by workspace: what the kernel keeps per project.
|
||||
protected placedSessions = new Map<string, string[]>();
|
||||
|
||||
protected placed(root: string, sessions: TreeSession[]): TreeSession[] {
|
||||
const order = this.placedSessions.get(root) ?? [];
|
||||
const rank = (s: TreeSession) => order.indexOf(s.path);
|
||||
return [...sessions].sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
async moveSession(path: string, direction: -1 | 1) {
|
||||
const ws = (await this.tree()).find((w) => w.sessions.some((s) => s.path === path));
|
||||
if (!ws) return;
|
||||
const order = ws.sessions.map((s) => s.path);
|
||||
const at = order.indexOf(path);
|
||||
const to = at + direction;
|
||||
if (to < 0 || to >= order.length) return;
|
||||
[order[at], order[to]] = [order[to], order[at]];
|
||||
this.placedSessions.set(ws.root, order);
|
||||
}
|
||||
|
||||
moveWorkspace(path: string, direction: -1 | 1) {
|
||||
const at = this.roots.indexOf(path);
|
||||
const to = at + direction;
|
||||
@@ -223,6 +253,10 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
moveSessionTo(path: string, target: string) {
|
||||
return Promise.resolve({ path: `${target}/${path.split("/").pop()}`, originalRemoved: true });
|
||||
}
|
||||
|
||||
removeSession(_path: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -231,11 +265,15 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
archiveSession(_path: string, _archived: boolean) {
|
||||
archiveSession(path: string, archived: boolean, host = "") {
|
||||
const key = JSON.stringify([host, path]);
|
||||
this.flags.set(key, { ...this.flags.get(key), archived, ...(archived ? { pinned: false } : {}) });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pinSession(_path: string, _pinned: boolean) {
|
||||
pinSession(path: string, pinned: boolean, host = "") {
|
||||
const key = JSON.stringify([host, path]);
|
||||
this.flags.set(key, { ...this.flags.get(key), pinned });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,13 @@ export class MockMcp extends MockProvider {
|
||||
return { name, state: "ready", host: "this machine", tools: s.tools };
|
||||
}
|
||||
|
||||
async upgradeMcp(name: string) {
|
||||
const s = this.servers.find((x) => x.name === name);
|
||||
if (!s) throw new HttpError(404, "no configured MCP server named " + name, { code: "mcp.signin_ineligible" });
|
||||
s.scopeUpgrade = undefined;
|
||||
return { name, state: "ready", host: "this machine", tools: s.tools };
|
||||
}
|
||||
|
||||
async followMcp(name: string, follow: boolean) {
|
||||
const s = this.servers.find((x) => x.name === name);
|
||||
if (!s) throw new HttpError(404, "no configured MCP server named " + name, { code: "mcp.approval_owed" });
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ModStatus } from "./boundary";
|
||||
import type { ModPreview, PluginPlan } from "./plugin";
|
||||
|
||||
const RUNTIME = { kind: "electron", path: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", version: "v22.12.0" };
|
||||
|
||||
/** Four installed mods in the states a person can meet: the list is the same
|
||||
* with the switch off, but then nothing runs and each says why. */
|
||||
export function mockMods(enabled: boolean): ModStatus[] {
|
||||
const names = ["statusline-clock", "git-guard", "prompt-tweaks", "fancy-notes"];
|
||||
if (!enabled) return names.map((plugin) => ({ plugin, state: "inactive" as const, code: "mod.switch_off", restarts: 0 }));
|
||||
return [
|
||||
{ plugin: names[0], state: "running", restarts: 0, runtime: RUNTIME, events: ["session.start", "turn.complete"], unserved: [] },
|
||||
{ plugin: names[1], state: "disabled", code: "mod.hook_timeout", restarts: 3, runtime: RUNTIME, events: ["tool.check", "tool.after"], unserved: ["tool.rewrite"] },
|
||||
{ plugin: names[2], state: "restarting", code: "mod.crashed", restarts: 1, runtime: RUNTIME, events: ["prompt.submit"], unserved: [] },
|
||||
{ plugin: names[3], state: "inactive", code: "mod.runtime_missing", restarts: 0, events: ["turn.complete"], unserved: [] },
|
||||
];
|
||||
}
|
||||
|
||||
export const MOCK_MOD_PREVIEW: ModPreview = {
|
||||
plugin: "git-guard", module: "hooks/index.js", sha256: "9f2c41b07ad35e8c61f0aa42d9be3377c0e1145f8a6d2b9c4e7710d3a5b8c6e2", listed: true,
|
||||
events: [{ name: "tool.check" }, { name: "tool.after" }, { name: "turn.complete" }, { name: "x", dynamic: true }],
|
||||
api: [{ namespace: "process", method: "run" }, { namespace: "fs", method: "readFile" }, { namespace: "ui", method: "toast" }],
|
||||
imports: [{ spec: "./lib/rules.js", kind: "static" }, { spec: "minimatch", kind: "require" }],
|
||||
findings: [{ kind: "computed_member" }],
|
||||
session: ["messages", "submit"],
|
||||
unserved: ["tool.rewrite"],
|
||||
};
|
||||
|
||||
/** The plan a source naming a mod gets: one package whose action carries the
|
||||
* preview of its hooks module. */
|
||||
export function mockModPlan(source: string): PluginPlan {
|
||||
return {
|
||||
ok: true, status: "planned", applied: false, source, planId: "high:sha256:mockmod",
|
||||
actions: [{
|
||||
kind: "plugin", action: "install_plugin_package", status: "planned", riskLevel: "high", name: "git-guard", version: "1.2.0", source,
|
||||
target: "~/.reasonix/plugins/git-guard", manifestKind: "claude-code", skills: [], skillCount: 0, commandCount: 0, hookCount: 0,
|
||||
riskReasons: ["FULL TRUST: ships a Claude Code mod whose code runs on this machine"], mod: MOCK_MOD_PREVIEW,
|
||||
}],
|
||||
next: "Review the plan, then install.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Completion, CompletionItem } from "./port";
|
||||
|
||||
const PAST = [
|
||||
{ id: "20261018-020806-feature", title: "把密钥轮换做成一键操作", at: "2026-10-18 02:08" },
|
||||
{ id: "20261017-101010-flaky", title: "修一个偶发红的分支菜单测试", at: "2026-10-17 10:10" },
|
||||
{ id: "20261016-183000-search", title: "跨对话搜索要能搜对话内容", at: "2026-10-16 18:30" },
|
||||
];
|
||||
|
||||
/** What the kernel offers for a #word: past conversations whose title holds it,
|
||||
* newest first, each inserting its @session reference. */
|
||||
export function pastSessionItems(query: string): CompletionItem[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return PAST.filter((s) => !needle || s.title.toLowerCase().includes(needle)).map((s) => ({
|
||||
label: s.title,
|
||||
insert: "@session:" + s.id,
|
||||
hint: s.at,
|
||||
kind: "session",
|
||||
}));
|
||||
}
|
||||
|
||||
/** The #word menu under the caret, or null when the caret is not in one. */
|
||||
export function sessionCompletion(line: string, cursor: number): Completion | null {
|
||||
const before = line.slice(0, cursor);
|
||||
const word = /(?:^|\s)#(\S*)$/.exec(before);
|
||||
if (!word) return null;
|
||||
const rest = line.slice(cursor).search(/\s/);
|
||||
return { kind: "session", from: before.length - word[1].length - 1, to: rest < 0 ? line.length : cursor + rest, query: word[1], items: pastSessionItems(word[1]) };
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface CompletionItem {
|
||||
// A directory, or a command with its own argument menu: accepting re-opens
|
||||
// the menu one level deeper instead of closing it.
|
||||
descend?: boolean;
|
||||
// builtin | command | skill | subagent | prompt | file | dir | resource.
|
||||
// builtin | command | skill | subagent | prompt | file | dir | resource | session.
|
||||
// Empty on argument values, which are not things.
|
||||
kind?: string;
|
||||
// The arguments an MCP prompt declares, in order.
|
||||
@@ -27,7 +27,7 @@ export interface CompletionArg {
|
||||
}
|
||||
|
||||
export interface Completion {
|
||||
kind: "" | "slash" | "slash-arg" | "ref";
|
||||
kind: "" | "slash" | "slash-arg" | "ref" | "session";
|
||||
from: number;
|
||||
to: number;
|
||||
// What the kernel filtered on, so a row can point at the letters that put it
|
||||
@@ -52,6 +52,8 @@ export interface ModelEntry {
|
||||
modelAlias?: string;
|
||||
kind?: string;
|
||||
active?: boolean;
|
||||
// Configured but outside the access list the person added, so a role cannot pick it.
|
||||
notAdded?: boolean;
|
||||
default?: boolean;
|
||||
// The endpoint host. Rows sharing it are one service reached under more than
|
||||
// one protocol, which is what lets the list fold them into a single choice.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MuxClient } from "./muxclient";
|
||||
import { subscribeStream } from "./sse_subscribe";
|
||||
import { StreamBudget } from "./streambudget";
|
||||
import type { WireEvent } from "./wire";
|
||||
|
||||
interface Source { url: string; closed: boolean; open: (conn: string) => void; say: (pane: string, frame: object) => void }
|
||||
@@ -56,6 +57,25 @@ function pane(client: MuxClient, id: string, seen: { at: number | null } = { at:
|
||||
}
|
||||
|
||||
describe("panes sharing one stream", () => {
|
||||
it("keeps paused SSH fallback streams charged when the connection budget settles again", async () => {
|
||||
const hub = stubHub(() => ({ status: 409, code: "mux.pane_not_local" }));
|
||||
const client = new MuxClient();
|
||||
const a = pane(client, "ssh-a");
|
||||
const b = pane(client, "ssh-b");
|
||||
hub.sources[0].open("c1");
|
||||
await flush();
|
||||
const budget = new StreamBudget(() => 1);
|
||||
const dropA = budget.attach("ssh-a", a.stop, () => {}, { visible: true, busy: false });
|
||||
const dropB = budget.attach("ssh-b", b.stop, () => {}, { visible: false, busy: false });
|
||||
expect(budget.liveIds()).toEqual(["ssh-a"]);
|
||||
budget.resettle();
|
||||
expect(budget.liveIds()).toEqual(["ssh-a"]);
|
||||
budget.update("ssh-a", { visible: false, busy: false });
|
||||
budget.update("ssh-b", { visible: true, busy: false });
|
||||
budget.resettle();
|
||||
expect(budget.liveIds()).toEqual(["ssh-b"]);
|
||||
dropA(); dropB(); a.stop(); b.stop();
|
||||
});
|
||||
it("carries nine panes on one connection", async () => {
|
||||
const hub = stubHub();
|
||||
const client = new MuxClient();
|
||||
|
||||
@@ -62,7 +62,8 @@ export class MuxClient {
|
||||
void this.subscribe(id, pane);
|
||||
}
|
||||
},
|
||||
holdsConnection: () => !!pane.own && !pane.paused,
|
||||
// A paused dedicated stream needs a slot again when resumed.
|
||||
holdsConnection: () => !!pane.own,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,51 @@ export interface PluginRuntime {
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
// What the install preview shows about a Claude Code mod's hooks module, read
|
||||
// from its syntax tree. A listing, not a guarantee.
|
||||
export interface ModPreviewEvent {
|
||||
name: string;
|
||||
// Not a literal: the name cannot be listed.
|
||||
dynamic?: boolean;
|
||||
}
|
||||
|
||||
export interface ModPreviewCall {
|
||||
namespace: string;
|
||||
method?: string;
|
||||
dynamic?: boolean;
|
||||
}
|
||||
|
||||
// Code the module pulls in that the listing cannot see.
|
||||
export interface ModPreviewImport {
|
||||
spec: string;
|
||||
kind: "static" | "require" | "dynamic";
|
||||
}
|
||||
|
||||
// eval, new Function, a computed member of the API: parts of the module are unlistable.
|
||||
export interface ModPreviewFinding {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface ModPreview {
|
||||
plugin: string;
|
||||
// The module's path inside the package, and the hash of the bytes previewed.
|
||||
module: string;
|
||||
sha256: string;
|
||||
// False when the module could not be read (typescript | too_large | unparseable | timeout |
|
||||
// unsupported_module): say "not previewed", never "clean".
|
||||
listed: boolean;
|
||||
reason?: string;
|
||||
events: ModPreviewEvent[];
|
||||
api: ModPreviewCall[];
|
||||
imports: ModPreviewImport[];
|
||||
findings: ModPreviewFinding[];
|
||||
// What the module takes from the session or queues into it, by literal call:
|
||||
// "messages" and "usage" read it, "submit" queues a prompt.
|
||||
session: string[];
|
||||
// Literal events this host never raises.
|
||||
unserved: string[];
|
||||
}
|
||||
|
||||
export interface PluginSkipped {
|
||||
capability: string;
|
||||
path?: string;
|
||||
@@ -106,6 +151,7 @@ export interface PluginAction {
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
runtime?: PluginRuntime & { fullTrust: boolean };
|
||||
mod?: ModPreview;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
next?: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, 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 { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
|
||||
|
||||
@@ -46,11 +47,12 @@ import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import type { Appearance, ThemeImport, ThemePack } from "./look";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
import type { PersonaDraft, PersonaSettings } from "./boundary";
|
||||
|
||||
export type * from "./plugin";
|
||||
export type * from "./market";
|
||||
@@ -189,6 +191,10 @@ export interface AgentPort {
|
||||
setSkillEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
|
||||
planSkillUpdate(name: string): Promise<SkillUpdatePlan>;
|
||||
applySkillUpdate(name: string, planId: string, overwriteLocal: boolean): Promise<void>;
|
||||
// Deletes the skill the person was shown, named by the scope and path the list
|
||||
// drew. A built-in or a package's skill is refused (skill.not_removable), and
|
||||
// one that changed since the list was read is too (skill.changed).
|
||||
removeSkill(name: string, scope: string, path: string, root?: string): Promise<void>;
|
||||
// Drops this project's exception so the skill inherits again.
|
||||
clearSkillOverride(name: string, root?: string): Promise<void>;
|
||||
plugins(): Promise<PluginPackage[]>;
|
||||
@@ -282,8 +288,12 @@ export interface AgentPort {
|
||||
saveBrowserTools(enabled: boolean): Promise<BrowserToolsSettings>;
|
||||
subagentClaims(): Promise<SubagentClaimsSettings>;
|
||||
saveSubagentClaims(optimistic: boolean): Promise<SubagentClaimsSettings>;
|
||||
subagentSteerSettings(): Promise<SubagentSteerSettings>;
|
||||
saveSubagentSteerSettings(maxBytes: number, perMinute: number): Promise<SubagentSteerSettings>;
|
||||
planApproval(): Promise<PlanApprovalSettings>;
|
||||
savePlanApproval(auto: boolean): Promise<PlanApprovalSettings>;
|
||||
defaultMode(): Promise<DefaultModeSettings>;
|
||||
saveDefaultMode(plan: boolean): Promise<DefaultModeSettings>;
|
||||
writeLease(): Promise<WriteLeaseSettings>;
|
||||
saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings>;
|
||||
peers(): Promise<PeersState>;
|
||||
@@ -291,25 +301,39 @@ export interface AgentPort {
|
||||
resolvePeerMessage(id: string, approve: boolean, remember?: PeerRemember): Promise<void>;
|
||||
peersSettings(): Promise<PeersSettings>;
|
||||
savePeersSettings(edit: PeersSettingsEdit): Promise<PeersSettings>;
|
||||
mods(): Promise<ModStatus[]>;
|
||||
modsSettings(): Promise<ModsSettings>;
|
||||
saveModsSettings(edit: ModsSettingsEdit): Promise<ModsSettings>;
|
||||
displayCurrency(): Promise<DisplayCurrencySettings>;
|
||||
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
|
||||
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
|
||||
changedFilesSettings(): Promise<ChangedFilesSettings>;
|
||||
saveChangedFilesSettings(show: boolean): Promise<ChangedFilesSettings>;
|
||||
feedbackLevelNotices(): Promise<FeedbackLevelNotices>;
|
||||
saveFeedbackLevelNotices(enabled: boolean): Promise<FeedbackLevelNotices>;
|
||||
foldWorkSettings(): Promise<FoldWorkSettings>;
|
||||
saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings>;
|
||||
turnNarrationSettings(): Promise<TurnNarrationSettings>;
|
||||
saveTurnNarrationSettings(level: TurnNarrationSettings["level"]): Promise<TurnNarrationSettings>;
|
||||
observabilitySettings(): Promise<ObservabilitySettings>;
|
||||
saveObservabilitySettings(patch: ObservabilityPatch): Promise<ObservabilitySettings>;
|
||||
webAccessSettings(): Promise<WebAccessSettings>;
|
||||
saveWebAccessSettings(patch: WebAccessPatch): Promise<WebAccessSettings>;
|
||||
probeSearxng(): Promise<SearxngProbe>;
|
||||
progressWatch(): Promise<ProgressWatchSettings>;
|
||||
// Applies to the running turn at its next round; no rebuild.
|
||||
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
|
||||
askTimeout(): Promise<AskTimeoutSettings>;
|
||||
saveAskTimeout(seconds: number): Promise<AskTimeoutSettings>;
|
||||
sessionRefSettings(): Promise<SessionRefSettings>;
|
||||
saveSessionRefSettings(tokens: number): Promise<SessionRefSettings>;
|
||||
autoArchive(): Promise<AutoArchiveSettings>;
|
||||
// Read by the kernel's background sweep on each pass; no rebuild.
|
||||
saveAutoArchive(s: Pick<AutoArchiveSettings, "enabled" | "days">): Promise<AutoArchiveSettings>;
|
||||
persona(): Promise<PersonaSettings>;
|
||||
// The persona is part of the cache-stable prefix, composed once at boot:
|
||||
// saving is written now and read by the next session, not this one's runtime.
|
||||
savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings>;
|
||||
receiptSettings(): Promise<ReceiptSettings>;
|
||||
saveReceiptSettings(show: boolean): Promise<ReceiptSettings>;
|
||||
// null in a browser tab: there is no window to keep running.
|
||||
@@ -331,6 +355,8 @@ export interface AgentPort {
|
||||
trustMcp(name: string, digest: string): Promise<{ state: string; tools?: number; error?: string }>;
|
||||
// Blocks until the browser sign-in ends (up to five minutes); a refusal is a thrown coded error.
|
||||
signinMcp(name: string): Promise<{ name: string; state: string; host?: string; tools?: number }>;
|
||||
// Like signinMcp, for the access a server asked for; the kernel asks the person every time.
|
||||
upgradeMcp(name: string): Promise<{ name: string; state: string; host?: string; tools?: number }>;
|
||||
followMcp(name: string, follow: boolean): Promise<{ name: string; follow: boolean; tools?: number }>;
|
||||
setMcpEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
|
||||
clearMcpOverride(name: string, root?: string): Promise<void>;
|
||||
@@ -446,6 +472,10 @@ export interface AgentPort {
|
||||
replyFeedback(receipt: string, body: string, images?: FeedbackImage[]): Promise<FeedbackReplyReceipt>;
|
||||
// Records that the thread was shown up to reply upTo, so those replies stop counting as new.
|
||||
feedbackSeen(receipt: string, upTo: number): Promise<void>;
|
||||
// Records that the person was told about this level; only a dismissal calls it.
|
||||
ackFeedbackPromotion(level: number): Promise<void>;
|
||||
// Takes one of this install's reports back; refused with feedback.not_withdrawable once it has an outcome.
|
||||
withdrawFeedback(receipt: string): Promise<void>;
|
||||
// Tying this install to the signed-in account is always the person's own
|
||||
// request. Linking changes no quota; unlinking only removes the join.
|
||||
feedbackLinks(): Promise<FeedbackLinkedInstall[]>;
|
||||
@@ -629,6 +659,15 @@ export interface AgentPort {
|
||||
// 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.
|
||||
steerSubagent(ref: string, text: string): Promise<void>;
|
||||
// The end of a running job's output, for the person looking at it. It does
|
||||
// not take output from the model's own reader; a job that ended is refused
|
||||
// (job.not_running).
|
||||
|
||||
@@ -8,6 +8,11 @@ export type RemoteStatus = "idle" | "connecting" | "connected" | "reconnecting"
|
||||
// row drawn from configuration alone is complete and simply idle.
|
||||
export interface RemoteHost {
|
||||
name: string;
|
||||
// How the machine is reached: absent or "ssh", or "wsl" for a distribution on
|
||||
// this Windows machine, named by distro. The address fields below are an
|
||||
// SSH host's and are empty for a WSL one.
|
||||
kind?: string;
|
||||
distro?: string;
|
||||
// How the user would type it for ssh, so a row is recognisable without
|
||||
// opening it: user@host, and a port only when it is not the assumed one.
|
||||
target: string;
|
||||
@@ -72,6 +77,8 @@ export const REMOTE_STEP_LABEL: Record<string, string> = {
|
||||
// a provider's key takes — so nothing here is a password in transit.
|
||||
export interface RemoteHostEdit {
|
||||
name: string;
|
||||
kind?: string;
|
||||
distro?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
@@ -150,3 +157,17 @@ export interface RemoteProbe {
|
||||
ready: boolean;
|
||||
routes: RemoteRoute[];
|
||||
}
|
||||
|
||||
// One WSL distribution this machine has, and whether it is the default one.
|
||||
export interface WSLDistro {
|
||||
name: string;
|
||||
version: number;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
// What this machine has. Not available means there is no WSL to ask, so the
|
||||
// page offers no WSL choice at all.
|
||||
export interface WSLDistros {
|
||||
available: boolean;
|
||||
distros: WSLDistro[];
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export interface SkillEntry {
|
||||
scope?: string;
|
||||
plugin?: string;
|
||||
path?: string;
|
||||
// What uninstalling deletes, and whether that is a whole folder; absent when it cannot be removed.
|
||||
removes?: string;
|
||||
removesFolder?: boolean;
|
||||
subagent?: boolean;
|
||||
readOnly?: boolean;
|
||||
model?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SseShell } from "./sse_shell";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { PersonaDraft, PersonaSettings } from "./boundary";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
|
||||
// Where the agent may reach: the permission rules a call is matched against and
|
||||
// the sandbox the shell runs in.
|
||||
@@ -50,6 +51,12 @@ export class SseBoundary extends SseShell {
|
||||
savePlanApproval(auto: boolean) {
|
||||
return this.post0<PlanApprovalSettings>("/plan-approval", { auto });
|
||||
}
|
||||
defaultMode() {
|
||||
return this.get<DefaultModeSettings>("/default-mode");
|
||||
}
|
||||
saveDefaultMode(plan: boolean) {
|
||||
return this.post0<DefaultModeSettings>("/default-mode", { plan });
|
||||
}
|
||||
writeLease() {
|
||||
return this.get<WriteLeaseSettings>("/write-lease");
|
||||
}
|
||||
@@ -71,9 +78,24 @@ export class SseBoundary extends SseShell {
|
||||
savePeersSettings(edit: PeersSettingsEdit) {
|
||||
return this.post0<PeersSettings>("/peers/settings", edit);
|
||||
}
|
||||
async mods() {
|
||||
return (await this.get<{ mods: ModStatus[] }>("/mods")).mods ?? [];
|
||||
}
|
||||
modsSettings() {
|
||||
return this.get<ModsSettings>("/mods/settings");
|
||||
}
|
||||
saveModsSettings(edit: ModsSettingsEdit) {
|
||||
return this.post0<ModsSettings>("/mods/settings", edit);
|
||||
}
|
||||
saveSubagentClaims(optimistic: boolean) {
|
||||
return this.post0<SubagentClaimsSettings>("/subagent-claims", { optimistic });
|
||||
}
|
||||
subagentSteerSettings() {
|
||||
return this.get<SubagentSteerSettings>("/subagent-steer-limits");
|
||||
}
|
||||
saveSubagentSteerSettings(maxBytes: number, perMinute: number) {
|
||||
return this.post0<SubagentSteerSettings>("/subagent-steer-limits", { maxBytes, perMinute });
|
||||
}
|
||||
displayCurrency() {
|
||||
return this.get<DisplayCurrencySettings>("/display-currency");
|
||||
}
|
||||
@@ -86,6 +108,12 @@ export class SseBoundary extends SseShell {
|
||||
saveChangedFilesSettings(show: boolean) {
|
||||
return this.post0<ChangedFilesSettings>("/changed-files-settings", { show });
|
||||
}
|
||||
feedbackLevelNotices() {
|
||||
return this.get<FeedbackLevelNotices>("/feedback-level-notices");
|
||||
}
|
||||
saveFeedbackLevelNotices(enabled: boolean) {
|
||||
return this.post0<FeedbackLevelNotices>("/feedback-level-notices", { enabled });
|
||||
}
|
||||
foldWorkSettings() {
|
||||
return this.get<FoldWorkSettings>("/fold-work-settings");
|
||||
}
|
||||
@@ -98,9 +126,18 @@ export class SseBoundary extends SseShell {
|
||||
saveTurnNarrationSettings(level: TurnNarrationSettings["level"]) {
|
||||
return this.post0<TurnNarrationSettings>("/turn-narration-settings", { level });
|
||||
}
|
||||
observabilitySettings() {
|
||||
return this.get<ObservabilitySettings>("/observability");
|
||||
}
|
||||
saveObservabilitySettings(patch: ObservabilityPatch) {
|
||||
return this.post0<ObservabilitySettings>("/observability", patch);
|
||||
}
|
||||
webAccessSettings() {
|
||||
return this.get<WebAccessSettings>("/web-access-settings");
|
||||
}
|
||||
probeSearxng() {
|
||||
return this.post0<SearxngProbe>("/web-access-settings/probe", {});
|
||||
}
|
||||
saveWebAccessSettings(patch: WebAccessPatch) {
|
||||
return this.post0<WebAccessSettings>("/web-access-settings", patch);
|
||||
}
|
||||
@@ -116,12 +153,24 @@ export class SseBoundary extends SseShell {
|
||||
saveAutoArchive(s: Pick<AutoArchiveSettings, "enabled" | "days">) {
|
||||
return this.post0<AutoArchiveSettings>("/auto-archive", s);
|
||||
}
|
||||
persona() {
|
||||
return this.get<PersonaSettings>("/persona");
|
||||
}
|
||||
savePersonaSettings(s: PersonaDraft) {
|
||||
return this.post0<PersonaSettings>("/persona", s);
|
||||
}
|
||||
askTimeout() {
|
||||
return this.get<AskTimeoutSettings>("/ask-timeout");
|
||||
}
|
||||
saveAskTimeout(seconds: number) {
|
||||
return this.post0<AskTimeoutSettings>("/ask-timeout", { seconds });
|
||||
}
|
||||
sessionRefSettings() {
|
||||
return this.get<SessionRefSettings>("/session-ref-settings");
|
||||
}
|
||||
saveSessionRefSettings(tokens: number) {
|
||||
return this.post0<SessionRefSettings>("/session-ref-settings", { tokens });
|
||||
}
|
||||
receiptSettings() {
|
||||
return this.get<ReceiptSettings>("/receipt-settings");
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export class SseExtensions extends SseLook {
|
||||
applySkillUpdate(name: string, planId: string, overwriteLocal: boolean) {
|
||||
return this.post("/skills/" + encodeURIComponent(name) + "/update/apply", { planId, overwriteLocal });
|
||||
}
|
||||
removeSkill(name: string, scope: string, path: string, root?: string) {
|
||||
return this.post("/skills/remove", { name, scope, path, root });
|
||||
}
|
||||
clearSkillOverride(name: string, root?: string) {
|
||||
return this.post("/skills/enabled", { name, clear: true, scope: "project", root });
|
||||
}
|
||||
|
||||
@@ -14,9 +14,15 @@ export class SseFeedback extends SseBackup {
|
||||
replyFeedback(receipt: string, body: string, images: FeedbackImage[] = []) {
|
||||
return this.post0<FeedbackReplyReceipt>("/feedback/" + encodeURIComponent(receipt) + "/reply", images.length > 0 ? { body, images } : { body });
|
||||
}
|
||||
withdrawFeedback(receipt: string) {
|
||||
return this.post("/feedback/" + encodeURIComponent(receipt) + "/withdraw", {});
|
||||
}
|
||||
announceFeedbackReply() {
|
||||
return this.post("/notifications/feedback-reply", {});
|
||||
}
|
||||
ackFeedbackPromotion(level: number) {
|
||||
return this.post("/feedback/promotion/ack", { level });
|
||||
}
|
||||
feedbackSeen(receipt: string, upTo: number) {
|
||||
return this.post("/feedback/" + encodeURIComponent(receipt) + "/seen", { upTo });
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ export class SseMcp extends SseProvider {
|
||||
signinMcp(name: string) {
|
||||
return this.post0<{ name: string; state: string; host?: string; tools?: number }>("/mcp/signin", { name });
|
||||
}
|
||||
upgradeMcp(name: string) {
|
||||
return this.post0<{ name: string; state: string; host?: string; tools?: number }>("/mcp/upgrade", { name });
|
||||
}
|
||||
followMcp(name: string, follow: boolean) {
|
||||
return this.post0<{ name: string; follow: boolean; tools?: number }>("/mcp/follow", { name, follow });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, WorkspaceGit } from "./port";
|
||||
import type { GitHistory } from "./workspace";
|
||||
import type { JobOutput } from "./session";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import { host } from "./host";
|
||||
import { HttpError } from "./http_error";
|
||||
@@ -48,6 +49,16 @@ export class SseWorkspace extends SseFeedback {
|
||||
stopToolCall(callId: string) {
|
||||
return this.post("/tool-calls/" + encodeURIComponent(callId) + "/cancel");
|
||||
}
|
||||
subagents() {
|
||||
return this.get<SubagentRun[]>("/subagents");
|
||||
}
|
||||
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 : ""));
|
||||
}
|
||||
steerSubagent(ref: string, text: string) {
|
||||
return this.post("/subagents/" + encodeURIComponent(ref) + "/steer", { text });
|
||||
}
|
||||
cancelJob(jobId: string) {
|
||||
return this.post("/jobs/" + encodeURIComponent(jobId) + "/cancel");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { HistoryMessage } from "./session";
|
||||
|
||||
/** running is a run this window's kernel is executing now; interrupted is one
|
||||
* whose owner is gone without a result. */
|
||||
export type SubagentState = "running" | "completed" | "failed" | "cancelled" | "interrupted";
|
||||
|
||||
/** One sub-agent run a conversation spawned: GET /subagents. */
|
||||
export interface SubagentRun {
|
||||
ref: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
model: string;
|
||||
effort?: string;
|
||||
state: SubagentState;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** One page of a run's conversation: GET /subagents/{ref}/transcript. Rows keep
|
||||
* the run's own indices; next is where the following page starts. */
|
||||
export interface SubagentTranscript {
|
||||
run: SubagentRun;
|
||||
messages: HistoryMessage[];
|
||||
after: number;
|
||||
next: number;
|
||||
total: number;
|
||||
live: boolean;
|
||||
}
|
||||
@@ -130,6 +130,8 @@ export interface Tool {
|
||||
truncated?: boolean;
|
||||
bound?: Bound;
|
||||
durationMs?: number;
|
||||
// A sub-agent's estimated tokens per second on its status channel; not provider usage.
|
||||
rate?: number;
|
||||
contextTokens?: number;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
// Whether the person is told about a level-up is the kernel's setting
|
||||
// ([ui] feedback_level_notices), per kernel like the other display switches. It
|
||||
// starts off, which is also the kernel's default, so an unreadable answer keeps
|
||||
// the note away rather than showing it.
|
||||
export interface LevelNoticePref {
|
||||
on(): boolean;
|
||||
subscribe(fn: () => void): () => void;
|
||||
adopt(on: boolean): void;
|
||||
}
|
||||
|
||||
function createPref(): LevelNoticePref {
|
||||
let on = false;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
on: () => on,
|
||||
subscribe: (fn) => {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
},
|
||||
adopt: (next) => {
|
||||
if (on === next) return;
|
||||
on = next;
|
||||
listeners.forEach((fn) => fn());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const perPort = new WeakMap<object, LevelNoticePref>();
|
||||
|
||||
export function levelNoticePrefOf(port: object): LevelNoticePref {
|
||||
let pref = perPort.get(port);
|
||||
if (!pref) perPort.set(port, (pref = createPref()));
|
||||
return pref;
|
||||
}
|
||||
|
||||
export function useShowsLevelNotice(port: object): boolean {
|
||||
const pref = levelNoticePrefOf(port);
|
||||
return useSyncExternalStore(pref.subscribe, pref.on, pref.on);
|
||||
}
|
||||
|
||||
type PrefPort = Pick<AgentPort, "feedbackLevelNotices" | "saveFeedbackLevelNotices">;
|
||||
|
||||
export async function loadLevelNoticePref(port: PrefPort): Promise<boolean> {
|
||||
try {
|
||||
const got = (await port.feedbackLevelNotices()).enabled;
|
||||
levelNoticePrefOf(port).adopt(got);
|
||||
return got;
|
||||
} catch {
|
||||
return levelNoticePrefOf(port).on();
|
||||
}
|
||||
}
|
||||
|
||||
// Shows the choice at once and asks the kernel to keep it; a refusal puts it back.
|
||||
export async function saveLevelNoticePref(port: PrefPort, on: boolean): Promise<void> {
|
||||
const pref = levelNoticePrefOf(port);
|
||||
const before = pref.on();
|
||||
pref.adopt(on);
|
||||
try {
|
||||
pref.adopt((await port.saveFeedbackLevelNotices(on)).enabled);
|
||||
} catch (e) {
|
||||
pref.adopt(before);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
...s,
|
||||
steerQueue: ev.pending ? [...s.steerQueue, ev.text] : s.steerQueue,
|
||||
awaitingTurnStart: ev.pending ? s.awaitingTurnStart : [...s.awaitingTurnStart, id],
|
||||
items: [...s.items, { t: "user", id, text: ev.text, pending: ev.pending }],
|
||||
items: [...s.items, { t: "user", id, text: ev.text, pending: ev.pending, at: ev.pending ? undefined : Date.now() }],
|
||||
};
|
||||
}
|
||||
if (ev.kind === "__queued") return nameQueued(s, ev.id, ev.itemId, ev.queued, ev.paused);
|
||||
@@ -650,7 +650,7 @@ function withReceipt(items: Item[], r?: Receipt): Item[] {
|
||||
// eleven. The server strips these before /history now — this is what covers
|
||||
// sessions already on disk.
|
||||
const CONTROL =
|
||||
/<(reasoning-language|turn-narration|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|current-date|project-instructions|capability-route|interrupted-turn-recovery|workspace|scheduled-run|mcp-prompt-failure|refused-calls)[\s\S]*?<\/\1>\s*/g;
|
||||
/<(reasoning-language|turn-narration|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|current-date|project-instructions|project-rules|scoped-rules|capability-route|interrupted-turn-recovery|workspace|scheduled-run|mcp-prompt-failure|refused-calls)[\s\S]*?<\/\1>\s*/g;
|
||||
const stripControl = (s: string) => s.replace(CONTROL, "").trim();
|
||||
|
||||
// A plan that ran to the end is spent: struck through in the rail it reads as
|
||||
@@ -709,7 +709,7 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
|
||||
// after an upgrade as well as for newly written history.
|
||||
const legacyHostRepair = m.content.startsWith("The following tools are unavailable in the current workflow phase:");
|
||||
const text = m.hostAuthored || legacyHostRepair ? "" : stripControl(m.content);
|
||||
if (text) out.push({ t: "user", id: nextId(), text, msgIndex: m.msgIndex, steer: m.steer, via: m.via, attachments: m.attachments });
|
||||
if (text) out.push({ t: "user", id: nextId(), text, msgIndex: m.msgIndex, steer: m.steer, via: m.via, attachments: m.attachments, at: m.createdAt });
|
||||
continue;
|
||||
}
|
||||
if (m.role === "assistant") {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Whether session rows carry hover Archive and Delete buttons. Off unless this
|
||||
// machine turned it on; both actions are always in the row's context menu.
|
||||
const KEY = "rx-session-quick-actions";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export type Item =
|
||||
// The paired device it was sent from; absent is the window.
|
||||
via?: Via;
|
||||
attachments?: StoredAttachment[];
|
||||
// When the message landed in the conversation (unix ms): the moment it was
|
||||
// sent, or for a follow-up that waited, when its turn began. Guidance said
|
||||
// into a running turn has none.
|
||||
at?: number;
|
||||
}
|
||||
// source names which model wrote it — a turn can carry two, and the card is
|
||||
// where the answer has to survive.
|
||||
|
||||
@@ -13,7 +13,7 @@ export function nameTurnStart(s: SessionState, ev: WireEvent): SessionState {
|
||||
const named = s.awaitingTurnStart[at];
|
||||
const seat = s.items.findIndex((i) => i.t === "user" && i.id === named);
|
||||
const was = s.items[seat] as Extract<Item, { t: "user" }>;
|
||||
const row = { ...was, authoredTurn: ev.authoredTurn, msgIndex: ev.msgIndex, pending: false, via: ev.via, attachments: ev.attachments };
|
||||
const row = { ...was, authoredTurn: ev.authoredTurn, msgIndex: ev.msgIndex, pending: false, via: ev.via, attachments: ev.attachments, at: Date.now() };
|
||||
// A follow-up that waited in the queue takes its seat where its turn began,
|
||||
// under the same rule a delivered steer follows: the transcript is ordered by
|
||||
// what happened, and the wait belongs to the composer.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fromHistory, initialState, reduce, type Item, type SessionEvent, type SessionState } from "./session";
|
||||
|
||||
const run = (evs: SessionEvent[]): SessionState => evs.reduce(reduce, initialState);
|
||||
const users = (items: Item[]) => items.filter((i): i is Extract<Item, { t: "user" }> => i.t === "user");
|
||||
const sent = (id: string): SessionEvent => ({ kind: "__user", text: "查一下", pending: false, id }) as SessionEvent;
|
||||
const started = (authoredTurn: number, msgIndex: number): SessionEvent => ({ kind: "turn_started", authoredTurn, msgIndex }) as SessionEvent;
|
||||
|
||||
describe("when a message was sent", () => {
|
||||
it("is read off the record for a rebuilt turn, and absent for guidance that never opened one", () => {
|
||||
const [turn, steer] = users(fromHistory([
|
||||
{ role: "user", content: "查一下", msgIndex: 1, createdAt: 1_760_000_000_000 },
|
||||
{ role: "assistant", content: "好", msgIndex: 2 },
|
||||
{ role: "user", content: "顺便", msgIndex: 3, steer: true },
|
||||
]).items);
|
||||
expect(turn.at).toBe(1_760_000_000_000);
|
||||
expect(steer.at).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is the moment a live message is sent", () => {
|
||||
const before = Date.now();
|
||||
const [row] = users(run([sent("a")]).items);
|
||||
expect(row.at).toBeGreaterThanOrEqual(before);
|
||||
expect(row.at).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it("is when a queued follow-up's turn begins, which is the time a reload reads back", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(1_000);
|
||||
const waiting = run([sent("a"), started(1, 1), sent("b")]);
|
||||
expect(users(waiting.items)[1].at).toBe(1_000);
|
||||
vi.setSystemTime(9_000);
|
||||
const begun = reduce(waiting, started(2, 5));
|
||||
expect(users(begun.items)[1].at).toBe(9_000);
|
||||
expect(users(begun.items)[0].at).toBe(1_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1285,13 +1285,12 @@ what exists.
|
||||
- Children form their own block so they can carry the hierarchy guide line.
|
||||
Indentation alone does not say level: 18px between two tiers scans as "slightly
|
||||
out of line".
|
||||
- A selected session row cannot share hover's colour: both at `--overlay` makes
|
||||
any row the mouse passes look selected. Its hue changed too — `--net` already
|
||||
means "this session has a live runtime" (the dot is that), and reusing it for
|
||||
"you are looking at this one" merges two meanings. The blue dot means a live
|
||||
runtime, not that it is reasoning now: the heartbeat goes only to the one
|
||||
actually running, with the global run state on `.app` and the current session
|
||||
as `data-on`.
|
||||
- A selected session uses an 8% text-ink wash and hover uses 4%, with regular
|
||||
title weight in both states. The neutral fill carries selection without a
|
||||
coloured edge or a second short bar. Runtime marks describe execution state,
|
||||
pin icons describe pinned sessions, and the keyboard focus outline remains
|
||||
independent of selection. `treerows.css` owns the selected-row recipe for
|
||||
both local and SSH sessions.
|
||||
- The rename and delete controls leave the layout but stay on the row: the width
|
||||
they held belonged to the title, and a control nobody can see is the other
|
||||
failure. They reserve their width whatever they look like, so hiding them until
|
||||
@@ -1579,6 +1578,35 @@ what exists.
|
||||
- A prompt that can answer "this and every later one of its kind" draws those choices through one component (`RememberChoice`), only for the kinds the kernel says it will honour, as the quieter buttons beside the primary one.
|
||||
- A note under the footer (`RememberWhere`) says where each is kept: for the session only and never on disk, or as a permission rule in the user config that Settings can undo.
|
||||
|
||||
## Unified project list
|
||||
|
||||
- The sidebar is one project list: local roots and SSH roots share alignment and keyboard levels. Machine names are secondary project metadata, never parent groups. Remote folds use the host and root together as identity.
|
||||
- Project headings have no separate chevron. Clicking a heading or using the tree arrow keys toggles its conversations; known empty projects do not advertise expansion. The heading toolbar switches all projects together, while search reveals matching conversations independently of saved folds.
|
||||
- The project toolbar keeps collapse/expand beside the label and open-project plus and overflow at the right. The plus opens the common local/SSH location picker; project-row chat icons create conversations. Empty lists retain a text entry.
|
||||
- Each project previews five recent sessions and keeps an older active/opening session visible. Search and explicit scopes show their matches. Show more reveals loaded rows and uses the existing bounded kernel paging path.
|
||||
- Time metadata comes only from canonical last-active timestamps. Loaded/total session counts remain available to assistive technology without visible badges.
|
||||
- The sidebar defaults to 316px, respects saved widths and retains its existing 232px floor. Folder glyphs, titles, action slots and conversation indents align across environments. Hover actions reserve their space.
|
||||
- The diagonal toolbar glyphs reuse Tabler's MIT-licensed [collapse][tabler-collapse] and [expand][tabler-expand] paths through the existing Studio icon component.
|
||||
|
||||
## Session hover actions
|
||||
|
||||
- Local and SSH transcript rows reveal Pin on the left and Archive on the
|
||||
right on hover or focus. Pinned idle rows retain the pin. Execution marks
|
||||
take the leading slot at rest; hover or focus reveals the pin action there.
|
||||
- The leading slot and trailing archive width are reserved. Titles do not
|
||||
move when buttons appear, when a session is pinned, or when legacy metadata
|
||||
is visually hidden. Batch checkboxes replace the leading pin slot.
|
||||
- Delete stays in each conversation's context menu. Running sessions refuse
|
||||
archive; restoring uses the same action with the opposite flag. Row buttons
|
||||
stop the open gesture, and SSH writes carry the host separately from the
|
||||
transcript path so an identical local path cannot receive them.
|
||||
- Hover actions default to hidden unless this machine explicitly enabled them; saved on and off preferences survive.
|
||||
Hidden actions remain available in the session menus. Touch surfaces show
|
||||
idle-row actions without requiring hover.
|
||||
|
||||
[tabler-collapse]: https://github.com/tabler/tabler-icons/blob/main/icons/outline/arrows-diagonal-minimize-2.svg
|
||||
[tabler-expand]: https://github.com/tabler/tabler-icons/blob/main/icons/outline/arrows-diagonal-2.svg
|
||||
|
||||
## Brand lockup
|
||||
|
||||
- The mark at the top of the rail is one row: the wordmark and a compact outlined uppercase badge ("STUDIO" by CSS; the text and the aria label are unchanged), vertically centred on each other, gap 8px.
|
||||
|
||||
@@ -3681,6 +3681,21 @@
|
||||
.lrow .sa + .sc { margin-left: 8px }
|
||||
.lrow .sc { margin-left: auto; flex: none; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
|
||||
/* The persona prompt boxes are prompt-sized rather than row inputs: full
|
||||
width, monospace because the content is prompt text, and draggable because
|
||||
a persona can be long. */
|
||||
.pers-field { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 0 }
|
||||
.pers-field .lb { font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text) }
|
||||
.pers-field .ds { font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--faint) }
|
||||
.pers-field textarea {
|
||||
min-height: 84px; padding: 7px 9px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--raised); color: var(--text); outline: none; resize: vertical;
|
||||
font: var(--w-reg) var(--fs-ui)/1.6 var(--mono);
|
||||
}
|
||||
.pers-field textarea:focus-visible { border-color: var(--focus); box-shadow: 0 0 0 2px color-mix(in oklab, var(--focus) 26%, transparent) }
|
||||
.pers-field textarea:disabled { opacity: .6 }
|
||||
.box .persona-name { margin: 10px 0 0; font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted) }
|
||||
|
||||
.skrow { display: flex; align-items: baseline; gap: 10px; padding: 8px 12px; border-top: 1px solid var(--hair) }
|
||||
.skrow:last-of-type { border-bottom: 1px solid var(--hair) }
|
||||
.skrow[data-off] .nm, .skrow[data-off] .ds { color: var(--ghost) }
|
||||
@@ -4124,7 +4139,7 @@
|
||||
|
||||
.slotrail {
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
padding: 4px 2px 6px; max-height: 84px; overflow-y: auto;
|
||||
padding: 4px 5px 6px; max-height: 84px; overflow-y: auto;
|
||||
}
|
||||
.slotrail .xview { flex-direction: row; align-items: center; gap: 8px }
|
||||
|
||||
@@ -4492,7 +4507,7 @@
|
||||
|
||||
.sessrow[data-on] { background: var(--raised); color: var(--text); font-weight: var(--w-med); box-shadow: inset 0 0 0 1px var(--hair), 0 3px 10px -8px rgb(0 0 0 / .5) }
|
||||
.sessrow[data-on] .sesstitle { font-weight: var(--w-med) }
|
||||
.sessrow[data-busy] { opacity: .55; pointer-events: none }
|
||||
.sessrow[data-busy] { opacity: .55 }
|
||||
.sessrow[data-on][data-busy] { opacity: .8 }
|
||||
.sessrow .pip { flex: none; opacity: .35 }
|
||||
.sessrow[data-live] .pip { opacity: 1; background: var(--net) }
|
||||
@@ -4592,6 +4607,7 @@
|
||||
padding: 18px 20px; border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
background: var(--surface); box-shadow: var(--shadow-float);
|
||||
}
|
||||
.movess select { margin-top: 12px }
|
||||
.pickcard h2 { margin: 0 0 12px; font: var(--w-med) var(--fs-lead) var(--ui); color: var(--text) }
|
||||
|
||||
.pickpath { display: flex; align-items: center; gap: 6px }
|
||||
@@ -4717,6 +4733,7 @@
|
||||
display: grid; gap: 8px; margin-top: 12px; padding: 12px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--overlay);
|
||||
}
|
||||
.rmtform > * { min-width: 0 }
|
||||
.rmtf { display: flex; align-items: center; gap: 10px; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--muted) }
|
||||
.rmtf > span { flex: none; width: 13ch }
|
||||
.rmthint { margin: -2px 0 2px calc(13ch + 10px); font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--faint) }
|
||||
@@ -4732,6 +4749,13 @@
|
||||
}
|
||||
.rmtf input:focus-visible, .rmtf select:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px }
|
||||
.rmtck { gap: 8px }
|
||||
.rmtkinds { display: flex; gap: 6px; flex: 1; flex-wrap: wrap }
|
||||
.rmtkinds button {
|
||||
padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-pill); background: transparent;
|
||||
color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.rmtkinds button[aria-checked="true"] { border-color: var(--accent); color: var(--text); background: color-mix(in srgb, var(--accent) 10%, transparent) }
|
||||
.rmtkinds button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
|
||||
.rmtdirs { display: grid; gap: 2px }
|
||||
.rmtdirs .sublb { font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--muted) }
|
||||
|
||||
@@ -20,3 +20,7 @@
|
||||
[data-fold~="side"] .app[data-browser="on"] .pane[data-browser-size="wide"]:has(> .pbody[data-dock]) > .compose {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pane[data-browser-size="full"]:has(> .pbody[data-dock]) > .findhost { grid-area: 1 / 1 / 2 / 3 }
|
||||
.pane[data-browser-size="full"]:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { grid-area: 2 / 1 / 4 / 3; border-left: 0 }
|
||||
.pane[data-browser-size="full"]:has(> .pbody[data-dock]) > :is(.pbody, .compose, .gutter-r) { display: none }
|
||||
@@ -6,3 +6,7 @@
|
||||
.content-matches b { overflow: hidden; font-weight: var(--w-med); text-overflow: ellipsis; white-space: nowrap }
|
||||
.content-matches span { display: -webkit-box; overflow: hidden; color: var(--faint); -webkit-line-clamp: 2; -webkit-box-orient: vertical }
|
||||
.content-matches[data-state] { padding: 6px 12px }
|
||||
.content-matches li { display: flex; align-items: stretch; gap: 2px }
|
||||
.content-matches li > button:first-child { flex: 1; width: auto; min-width: 0 }
|
||||
.content-matches .content-match-ref { flex: none; width: 28px; padding: 0; place-items: center; color: var(--faint); font: var(--w-med) var(--fs-ui) var(--mono) }
|
||||
.content-matches .content-match-ref:hover { color: var(--text) }
|
||||
@@ -288,3 +288,7 @@
|
||||
.fbk-filter-chip:hover, .fbk-filter-chip[aria-pressed="true"] { color: var(--text); background: var(--overlay); }
|
||||
.fbk-filter-chip[data-live] small { color: var(--accent-ink); font-weight: 600; }
|
||||
.fbk-filter-chip:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
.fbk-withdraw-acts { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.fbk-withdraw .btn.danger { border-color: color-mix(in srgb, var(--err) 42%, transparent); color: var(--err-ink); }
|
||||
.fbk-withdraw .btn.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--err) 10%, transparent); }
|
||||
@@ -0,0 +1,9 @@
|
||||
.modlist { margin: 10px 0 0; padding: 0; list-style: none; display: grid; gap: 8px }
|
||||
.modlist li { display: grid; gap: 2px; padding: 8px 10px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface) }
|
||||
.modlist .modhead { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px }
|
||||
.modlist b { font-weight: var(--w-med); overflow-wrap: anywhere }
|
||||
.modlist .modstate { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted) }
|
||||
.modlist li[data-state="running"] .modstate { color: var(--ok, var(--text)) }
|
||||
.modlist li[data-state="disabled"] .modstate { color: var(--danger, var(--text)) }
|
||||
.modlist li[data-state="restarting"] .modstate { color: var(--warn, var(--text)) }
|
||||
.modlist .ds { overflow-wrap: anywhere }
|
||||
@@ -37,7 +37,7 @@ const MARKS: Record<string, string> = {
|
||||
const MIXED = new Set([
|
||||
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "isolab", "k",
|
||||
"lb", "meta", "mk", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt",
|
||||
"sc", "sessmeta", "st", "sym", "sz", "tag", "u", "uspan", "v",
|
||||
"sc", "st", "sym", "sz", "tag", "u", "uspan", "v",
|
||||
]);
|
||||
|
||||
const TRANSLATORS = ["t", "tx", "plural"];
|
||||
|
||||
@@ -9,3 +9,5 @@
|
||||
.notice-bar .nwhy { display: block; margin-top: 2px; font-size: var(--fs-meta); line-height: 1.6; color: var(--faint); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.notice-bar .nrule { display: block; margin-top: 2px; font: var(--w-reg) var(--fs-meta)/1.6 var(--mono); color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.notice-bar .nmd { margin-top: 2px; color: var(--text); overflow-wrap: anywhere; }
|
||||
.notice-bar .nbtn { margin-top: 4px; padding: 0; border: 0; background: transparent; cursor: pointer; color: var(--text); font: var(--w-med) var(--fs-meta) var(--ui); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.notice-bar .nbtn:hover { color: var(--accent); }
|
||||
@@ -19,6 +19,10 @@ textarea.peerfield { min-height: 4.5em; resize: vertical; }
|
||||
.wa-consent { display: flex; flex: 1 1 100%; align-items: center; gap: 10px; padding: 6px 0 2px 14px; }
|
||||
.wa-consent > .tx { display: grid; flex: 1 1 12em; min-width: 0; }
|
||||
.wa-url { display: grid; gap: 4px; padding: 6px 12px 10px; }
|
||||
.wa-test { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
|
||||
.wa-test > .act { flex: none; }
|
||||
.wa-test > .peernote { flex: 1 1 14em; padding: 0; }
|
||||
.wa-test > .peernote.bad { color: var(--err-ink); }
|
||||
.peerreleased { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.peerreleased li { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 12px; border-top: 1px solid var(--hair); }
|
||||
.peerreleased .tx { display: grid; min-width: 0; }
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
.rail .sessquick {
|
||||
flex: none; display: flex; align-items: center; gap: 2px; width: 0; overflow: hidden;
|
||||
flex: none; display: flex; align-items: center; width: 24px; margin-inline-end: 6px;
|
||||
opacity: 0; pointer-events: none; transition: opacity 100ms ease;
|
||||
}
|
||||
.rail .sessrow:hover > .sessquick,
|
||||
.rail .sessrow:focus-within > .sessquick { width: 50px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
|
||||
.rail .sessquick > button {
|
||||
.rail .sessrow:is(:hover, :focus-within) > .sessquick,
|
||||
.rail .sessrow:is(:hover, :focus-within) > .sesspin-action { opacity: 1; pointer-events: auto; }
|
||||
.rail .sessquick > button,
|
||||
.rail .sesspin-action {
|
||||
flex: none; width: 24px; height: 28px; padding: 0; display: grid; place-items: center;
|
||||
border: 0; border-radius: 5px; background: transparent; color: var(--faint); cursor: pointer;
|
||||
}
|
||||
.rail .sessquick .studio-icon { width: 14px; height: 14px; }
|
||||
.rail .sessquick > button:hover { background: var(--raised); color: var(--text); }
|
||||
.rail .sessquick > .danger:hover { color: var(--err-ink); }
|
||||
.rail .sessquick > button:disabled { opacity: .35; cursor: default; background: transparent; color: var(--faint); }
|
||||
.rail .sessquick > button:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px; }
|
||||
.rail .sessrow-context[data-unread]:hover > .sessquick,
|
||||
.rail .sessrow-context[data-unread]:focus-within > .sessquick { margin-inline-end: 26px; }
|
||||
.rail .sessrow-context[data-unread]:hover > .sesscopies,
|
||||
.rail .sessrow-context[data-unread]:focus-within > .sesscopies { margin-inline-end: 8px; }
|
||||
.rail .sessrow-context[data-unread]:hover > .sesstitle,
|
||||
.rail .sessrow-context[data-unread]:focus-within > .sesstitle { padding-inline-end: 9px; }
|
||||
.rail .sessquick .studio-icon,
|
||||
.rail .sesspin-action .studio-icon { width: 14px; height: 14px; }
|
||||
.rail .sessquick > button:hover,
|
||||
.rail .sesspin-action:hover { background: var(--raised); color: var(--text); }
|
||||
.rail .sessquick > button:disabled,
|
||||
.rail .sesspin-action:disabled { opacity: .35; cursor: default; background: transparent; color: var(--faint); }
|
||||
.rail .sessquick > button:focus-visible,
|
||||
.rail .sesspin-action:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px; }
|
||||
.rail .sesspin-action { position: absolute; inset-inline-start: 2px; inset-block-start: 3px; opacity: 0; pointer-events: none; }
|
||||
.rail .sesspin-action[aria-pressed="true"] { opacity: 1; pointer-events: auto; color: var(--muted); }
|
||||
.rail .sessrow[data-run]:not(:hover):not(:focus-within) > .sesspin-action { opacity: 0; pointer-events: none; }
|
||||
.rail .sesspin-marker { position: absolute; width: 14px; height: 14px; inset-inline-start: 7px; inset-block-start: 10px; color: var(--muted); }
|
||||
.rail .sessrow[data-run] > .sesspin-marker { display: none; }
|
||||
.rail .sessrow:is(.sessrow-context, .sessrow-remote) { margin-inline-start: -17px; }
|
||||
.rail :is(.sessrow-context, .sessrow-remote) > .sesstitle { padding-inline-start: 29px; }
|
||||
.rail .sessrow-remote > .sesstitle { padding-inline-end: 9px; }
|
||||
.rail .sessrow-context > .session-select { position: absolute; inset-inline-start: 7px; inset-block-start: 10px; margin: 0; }
|
||||
.rail .sessrow-context > .sessedit { margin-inline-start: 24px; }
|
||||
.rail .sessrow:has(> .sessedit) > :is(.sesspin-action, .sesspin-marker, .sessquick) { visibility: hidden; pointer-events: none; }
|
||||
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within)::before,
|
||||
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within) > .pip { visibility: hidden; }
|
||||
.rail .sessrow:has(.sessquick) > .unread-dot { inset-inline-end: 36px; }
|
||||
@media (hover: none) {
|
||||
.rail .sessquick { opacity: 1; width: 50px; margin-inline-end: 6px; pointer-events: auto; }
|
||||
.rail .sessrow-context[data-unread] > .sessquick { margin-inline-end: 26px; }
|
||||
.rail .sessrow-context[data-unread] > .sesscopies { margin-inline-end: 8px; }
|
||||
.rail .sessrow-context[data-unread] > .sesstitle { padding-inline-end: 9px; }
|
||||
.rail .sessquick,
|
||||
.rail .sesspin-action { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
.rail .sesslegacy {
|
||||
flex: none; display: inline-flex; align-items: center; padding: 0 5px; height: 15px;
|
||||
@@ -31,5 +41,4 @@
|
||||
color: var(--ghost); cursor: default;
|
||||
font: var(--w-reg) var(--fs-micro) var(--ui);
|
||||
}
|
||||
.rail .sessrow:hover > .sesslegacy,
|
||||
.rail .sessrow:focus-within > .sesslegacy { display: none; }
|
||||
.rail .sessrow:has(.sessquick):is(:hover, :focus-within) > .sesslegacy { visibility: hidden; }
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const TOKENS = import.meta.glob("./tokens.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const SHELL = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const APP = import.meta.glob("./app.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
const tokens = Object.values(TOKENS)[0];
|
||||
const shell = Object.values(SHELL)[0];
|
||||
@@ -44,6 +45,10 @@ describe("the spacing scale", () => {
|
||||
});
|
||||
|
||||
describe("a migrated surface", () => {
|
||||
it("keeps a pending session row clickable so returning to it can become the latest selection", () => {
|
||||
const pending = block(Object.values(APP)[0], ".sessrow[data-busy]");
|
||||
expect(pending).not.toMatch(/pointer-events:\s*none/);
|
||||
});
|
||||
it("takes every distance from the scale", () => {
|
||||
for (const selector of [".activity-group", ".activity-group > summary"]) {
|
||||
expect(literalDistances(shell, selector), selector).toEqual([]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const ROWS = import.meta.glob("./treerows.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const MARKER = "Theme token closure";
|
||||
|
||||
describe("the Studio shell closes over the active theme", () => {
|
||||
@@ -41,17 +42,12 @@ describe("the Studio shell closes over the active theme", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Selection was a 4% darkening of the surface, invisible under a theme pack:
|
||||
// the open conversation read as unselected. It is the same short bar a
|
||||
// running row gets, in the theme's accent. The row itself stays a plain
|
||||
// surface — a filled one is the loudest thing in the list at any saturation.
|
||||
it("marks the open conversation with a bar, not a filled row", () => {
|
||||
const selected = closure.match(/\.rail \.sessrow\[aria-selected="true"\]\s*\{([^}]*)\}/s)?.[1] ?? "";
|
||||
expect(selected).toContain("var(--overlay)");
|
||||
expect(selected).not.toContain("var(--accent)");
|
||||
expect(selected).toContain("box-shadow: none");
|
||||
const css = Object.values(CSS)[0];
|
||||
expect(css).toMatch(/\.rail \.sessrow\[aria-selected="true"\]::before/);
|
||||
it("gives selection a stronger neutral fill than hover and no extra bar", () => {
|
||||
const rows = Object.values(ROWS)[0];
|
||||
expect(rows).toMatch(/\.sessrow\[aria-selected="true"\][^{]*\{[^}]*var\(--text\) 8%/);
|
||||
expect(rows).toMatch(/\.sessrow:hover[^{]*\{[^}]*var\(--text\) 4%/);
|
||||
expect(rows).not.toContain("inset 2px");
|
||||
expect(Object.values(CSS)[0]).not.toMatch(/\.sessrow\[aria-selected="true"\]::before/);
|
||||
});
|
||||
|
||||
// The scale is the reader's size and weight, not a literal: a pack states
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
|
||||
body { background: var(--page); }
|
||||
.app { --rail-open: 238px; background: var(--page); }
|
||||
.app { --rail-open: 316px; background: var(--page); }
|
||||
.app[data-nav="on"] { --nav-w: var(--nav-open); }
|
||||
.app::after { opacity: .012; }
|
||||
|
||||
@@ -643,8 +643,8 @@ body { background: var(--page) !important; }
|
||||
transform: translateY(-40px); opacity: 0; pointer-events: none; border: 0; background: var(--surface);
|
||||
transition: opacity 120ms ease, transform 140ms ease;
|
||||
}
|
||||
.rail .wsfind:focus-within { position: relative; left: auto; right: auto; width: calc(100% - 38px); margin: 0 19px 7px; transform: none; opacity: 1; pointer-events: auto; }
|
||||
.rail .wsfind:focus-within { background: var(--overlay); box-shadow: 0 0 0 1px var(--border); }
|
||||
.rail .wsfind:is(:focus-within, [data-query]) { position: relative; left: auto; right: auto; width: calc(100% - 38px); margin: 0 19px 7px; transform: none; opacity: 1; pointer-events: auto; }
|
||||
.rail .wsfind:is(:focus-within, [data-query]) { background: var(--overlay); box-shadow: 0 0 0 1px var(--border); }
|
||||
.rail .scroll { padding-bottom: 8px; }
|
||||
.rail .wsrow { margin-inline: 15px; min-height: 35px; border-radius: 6px; }
|
||||
.rail .wsnode[data-current] > .wsrow .wsname { color: var(--text); font-weight: 620; }
|
||||
@@ -655,7 +655,6 @@ body { background: var(--page) !important; }
|
||||
.rail .kids { margin-left: 18px; border-left-color: var(--hair); }
|
||||
.rail .sessrow { min-height: 42px; margin: 1px 12px 1px 3px; padding-inline: 9px 6px; border-radius: 7px; color: var(--muted); }
|
||||
.rail .sessrow:hover { background: var(--overlay); color: var(--text); }
|
||||
.rail .sessrow[aria-selected="true"] { background: var(--raised); color: var(--text); box-shadow: inset 2px 0 var(--text); }
|
||||
.rail .sessrow .pip { opacity: .45; }
|
||||
.rail .sesstitle { font-size: calc(11px * var(--fs-scale, 1)); line-height: 1.35; }
|
||||
.rail .sessmeta { flex: none; color: var(--faint); font-size: calc(9px * var(--fs-scale, 1)); white-space: nowrap; }
|
||||
@@ -841,13 +840,14 @@ body { background: var(--page) !important; }
|
||||
.flow .user-acts > button,
|
||||
.flow .user-acts .stepctl button {
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||
width: 26px; height: 26px; padding: 0; opacity: 1; line-height: 12px;
|
||||
width: 26px; height: 26px; padding: 0; opacity: 1; line-height: 15px;
|
||||
border: 0; border-radius: 6px; background: transparent; color: var(--muted);
|
||||
}
|
||||
.flow .user-acts .studio-icon { width: 12px; height: 12px; }
|
||||
.flow .user-acts .studio-icon { width: 15px; height: 15px; }
|
||||
.flow .call[data-k="me"] .user-acts > button:hover,
|
||||
.flow .call[data-k="me"] .user-acts .stepctl button:hover { background: var(--overlay); color: var(--text); }
|
||||
.flow .user-acts > .copy[data-feedback] { width: auto; gap: 5px; padding: 0 6px; white-space: nowrap; }
|
||||
.flow .user-acts .user-time { margin-inline-end: 4px; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); white-space: nowrap; }
|
||||
.flow .user-acts > button.copy[data-state="done"],
|
||||
.flow .user-acts > button.copy[data-state="done"]:hover { color: var(--ok); }
|
||||
.flow .user-acts > button.copy[data-state="failed"],
|
||||
@@ -1363,7 +1363,7 @@ body { background: var(--page) !important; }
|
||||
.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-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: 620 calc(12px * var(--fs-scale, 1))/1 var(--ui); text-transform: uppercase; }
|
||||
.workbench-explorer-head small { color: var(--faint); }
|
||||
.workbench-explorer-head small { color: var(--faint); font-size: calc(10px * var(--fs-scale, 1)); }
|
||||
.workbench-explorer-head small { margin-left: auto; }
|
||||
.workbench-open-editor {
|
||||
width: 28px; height: 28px; margin-left: 8px; padding: 0; flex: none;
|
||||
@@ -1875,11 +1875,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
}
|
||||
.rail .sessrow:hover { background: var(--overlay); color: var(--text); }
|
||||
.rail .sessrow .pip { display: none; }
|
||||
.rail .sessrow[aria-selected="true"] {
|
||||
background: var(--overlay);
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
}
|
||||
.rail .sesstitle {
|
||||
flex: 1; min-width: 0; align-self: stretch; box-sizing: border-box;
|
||||
min-height: 34px; height: 34px; padding: 0 34px 0 9px;
|
||||
@@ -1892,12 +1887,10 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
flex: 1; min-width: 0; display: block;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.rail .sessrow[aria-selected="true"] .sesstitle { font-weight: 590; }
|
||||
.rail .sessrow:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px; }
|
||||
.rail .sessmeta { color: var(--faint); font-family: var(--ui); font-size: calc(9px * var(--fs-scale, 1)); }
|
||||
.rail .sessrow { position: relative; }
|
||||
.rail .sessrow[data-run]::before,
|
||||
.rail .sessrow[aria-selected="true"]::before {
|
||||
.rail .sessrow[data-run]::before {
|
||||
content: "";
|
||||
position: absolute; inset-block: 50% auto; inset-inline-start: 3px;
|
||||
width: 2px; height: 14px; translate: 0 -50%;
|
||||
@@ -3036,15 +3029,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
.rail .wsname,
|
||||
.rail .sessrow { color: var(--muted); }
|
||||
.rail .kids { border-color: var(--hair); }
|
||||
.rail .sessrow[aria-selected="true"] {
|
||||
/* The open conversation is marked by the same short bar a running one gets,
|
||||
in the theme's accent. A filled row would say "act on me", which
|
||||
navigation does not, and at any saturation it is the loudest thing in the
|
||||
list. */
|
||||
background: var(--overlay);
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.hero .mk { color: var(--faint); }
|
||||
.hero .t { color: var(--text); }
|
||||
|
||||
@@ -1,30 +1,67 @@
|
||||
.rowswap { display: grid; align-items: center; justify-items: end; }
|
||||
.rowswap > * { grid-area: 1 / 1; transition: opacity var(--m-line); }
|
||||
.machrow > .rowswap { grid-area: 1 / 4; }
|
||||
.machrow[data-group] .rmtpip { background: none; box-shadow: inset 0 0 0 1.5px var(--faint); }
|
||||
.machrow[data-group] .rowcount { opacity: 1; }
|
||||
.rmts > .rmt { padding-left: 8px; }
|
||||
.rowcount { pointer-events: none; padding-inline: 4px; font: var(--w-reg) var(--fs-micro)/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--faint); }
|
||||
.rowbtns { display: flex; align-items: center; gap: 2px; }
|
||||
.rowbtns, .rowswap > .machpick { opacity: 0; }
|
||||
:is(.wsrow, .machrow):is(:hover, :focus-within) :is(.rowbtns, .rowswap > .machpick),
|
||||
.rowswap:has([aria-expanded="true"], [data-busy]) :is(.rowbtns, .machpick) { opacity: 1; }
|
||||
:is(.wsrow, .machrow):is(:hover, :focus-within) .rowcount,
|
||||
.rowswap:has([aria-expanded="true"], [data-busy]) .rowcount { opacity: 0; }
|
||||
@media (hover: none) {
|
||||
.rowbtns, .rowswap > .machpick { opacity: 1; }
|
||||
.rowcount { display: none; }
|
||||
}
|
||||
:is(.rowswap .machpick, .machacts .machpick, .rowbtns .wsadd, .rowbtns .wsmore) { width: 22px; height: 22px; border-radius: 6px; color: var(--text); }
|
||||
:is(.rowswap .machpick, .rowbtns .wsadd, .rowbtns .wsmore) { box-shadow: inset 0 0 0 1px var(--hair); }
|
||||
:is(.rowswap .machpick, .machacts .machpick, .rowbtns .wsadd, .rowbtns .wsmore):is(:hover, :active, [aria-expanded="true"], [data-busy]):not(:disabled) { background: var(--raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.machrow:is([data-here], [data-group]) .rmtname { font-size: calc(11px * var(--fs-scale, 1)); font-weight: 640; letter-spacing: .04em; color: var(--muted); }
|
||||
.rowcount[data-words] { font: var(--w-reg) calc(11px * var(--fs-scale, 1))/1 var(--ui); }
|
||||
.rowcount[data-pill] { min-width: 18px; padding: 2px 5px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); text-align: center; }
|
||||
.wsrow .rowbtns .wsmore { opacity: 1; }
|
||||
.projects-header { margin: 8px 15px 6px; }
|
||||
.projects-heading { display: flex; align-items: center; justify-content: space-between; min-height: 28px; padding-inline: 7px; color: var(--faint); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); }
|
||||
.projects-heading-actions { display: flex; align-items: center; gap: 2px; }
|
||||
.projects-heading-title { display: flex; align-items: center; gap: 6px; }
|
||||
.projects-add { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: 6px; padding: 0; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.projects-add .studio-icon { width: 16px; height: 16px; }
|
||||
.projects-add:hover:not(:disabled) { background: var(--overlay); color: var(--text); }
|
||||
.projects-add:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
|
||||
.projects-add:disabled { opacity: .4; cursor: default; }
|
||||
.projects-toggle { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: 6px; padding: 0; background: transparent; color: var(--faint); cursor: pointer; }
|
||||
.projects-toggle .studio-icon { width: 15px; height: 15px; }
|
||||
.projects-toggle:hover:not(:disabled) { background: var(--overlay); color: var(--text); }
|
||||
.projects-toggle:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
|
||||
.projects-toggle:disabled { opacity: .4; cursor: default; }
|
||||
.projects-open, .projects-manage { display: flex; align-items: center; gap: 9px; min-height: 34px; border: 0; border-radius: 6px; padding: 6px 8px; background: transparent; color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); cursor: pointer; text-align: left; }
|
||||
.projects-open { width: 100%; }
|
||||
.projects-open:disabled { opacity: .5; cursor: default; }
|
||||
.projects-open .studio-icon, .projects-manage .studio-icon { width: 16px; height: 16px; flex: none; }
|
||||
.projects-open:hover:not(:disabled), .projects-manage:hover { background: var(--overlay); }
|
||||
.projects-open:focus-visible, .projects-manage:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
.rail > .projects-manage { width: calc(var(--rail-open) - 30px); margin: 0 15px 8px; padding-top: 10px; border-top: 1px solid var(--hair); border-radius: 0; }
|
||||
.projects-manage > span { flex: 1; }
|
||||
.projects-manage > .studio-icon:last-child { width: 12px; color: var(--faint); }
|
||||
.rail .wsnode { padding: 0 0 6px; }
|
||||
.rail .wsrow { grid-template-columns: 16px minmax(0, 1fr) auto; gap: 0 9px; margin-inline: 15px; min-height: 34px; padding: 5px 7px; color: var(--muted); }
|
||||
.wsfolder { grid-area: 1 / 1; width: 16px; height: 16px; color: var(--muted); }
|
||||
.rail .wsname { grid-area: 1 / 2; }
|
||||
.rail .wsmeta { grid-area: 2 / 2; }
|
||||
.rail .rmtwsrow .bound-workspace { grid-area: 2 / 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-micro)/1.25 var(--ui); color: var(--faint); }
|
||||
.rail .wsacts { grid-area: 1 / 3; }
|
||||
.wsrow:not([aria-expanded]) { cursor: default; }
|
||||
.rail .wsname { font-size: var(--fs-ui); }
|
||||
.rail .sesstitle { font-size: var(--fs-meta); }
|
||||
.rail .wsnode[data-current] > .wsrow .wsfolder { color: var(--muted); }
|
||||
.rail .wsrow .wsname, .rail .wsnode[data-current] > .wsrow .wsname { font-weight: var(--w-med); color: var(--muted); }
|
||||
.rowswap { display: flex; align-items: center; gap: 4px; }
|
||||
.rowcount { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.rowbtns { display: flex; align-items: center; flex: none; gap: 2px; opacity: 0; }
|
||||
.wsrow:is(:hover, :focus-within) .rowbtns, .rowbtns:has([aria-expanded="true"], [data-busy]) { opacity: 1; }
|
||||
.rowbtns :is(.wsadd, .wsmore), .projects-heading .wsmore { width: 26px; height: 26px; border-radius: 6px; color: var(--muted); box-shadow: none; }
|
||||
.rowbtns :is(.wsadd, .wsmore):is(:hover, [aria-expanded="true"]):not(:disabled), .projects-heading .wsmore:hover { background: var(--raised); color: var(--text); }
|
||||
.rowbtns .studio-icon { width: 15px; height: 15px; }
|
||||
.rowbtns .wsmore { opacity: 1; }
|
||||
.rail .rmtwsrow { grid-template-columns: 16px minmax(0, 1fr) auto auto; }
|
||||
.rail .rmtwsrow .wsacts { grid-area: 1 / 4; }
|
||||
.project-source { grid-area: 1 / 3; max-width: 58px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--mono); }
|
||||
.rail .kids { margin: 0 15px 0 22px; padding-left: 10px; border-left: 0; }
|
||||
.rail .sessrow { min-height: 34px; margin: 1px 0 1px 3px; }
|
||||
.rail .sessrow > .pip { display: none; }
|
||||
.rail .sessrow[data-run="running"] > .pip { display: block; }
|
||||
.rail .rmtws-node .sessdel { opacity: 0; }
|
||||
.rail .rmtws-node .sessrow:is(:hover, :focus-within) .sessdel { opacity: 1; }
|
||||
.rail .sessage { margin-left: auto; font-size: var(--fs-micro); }
|
||||
.rail .sessmore { width: calc(100% - 12px); margin: 2px 0 5px 3px; padding-left: 9px; text-align: left; }
|
||||
.rail .sessrow:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||
.rail .sessrow[aria-selected="true"] { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); font-weight: var(--w-reg); box-shadow: none; }
|
||||
.rail .sessrow:not([data-unread]) .sesstitle { font-weight: var(--w-reg); }
|
||||
.project-connection { display: flex; align-items: center; gap: 5px; margin: 0 22px 4px 48px; color: var(--warn-ink); font: var(--w-reg) var(--fs-micro)/1.5 var(--ui); }
|
||||
.project-connection .studio-icon { width: 12px; height: 12px; flex: none; }
|
||||
.project-connection > span { overflow-wrap: anywhere; }
|
||||
.project-locations { max-height: 45vh; overflow: auto; }
|
||||
.project-locations .pickrow { min-height: 40px; }
|
||||
.project-locations .pickrow .studio-icon { width: 18px; height: 18px; }
|
||||
.project-locations small { margin-left: auto; color: var(--faint); }
|
||||
@media (hover: none) { .rowbtns { opacity: 1; } }
|
||||
.studio-session-segments button { font-size: calc(12px * var(--fs-scale, 1)); }
|
||||
.studio-session-segments b { font-size: calc(10px * var(--fs-scale, 1)); }
|
||||
.rail .machrow { margin-right: 15px; }
|
||||
.machrow[data-here] { grid-template-columns: 10px 6px minmax(0, 1fr) auto auto; }
|
||||
.machrow[data-here] > .machpick { grid-area: 1 / 5; opacity: 1; width: 22px; height: 22px; border-radius: 6px; color: var(--text); box-shadow: inset 0 0 0 1px var(--hair); }
|
||||
.machrow[data-here] > .machpick:is(:hover, :active, [data-busy]):not(:disabled) { background: var(--raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { ModPreviewBlock } from "./ModPreviewBlock";
|
||||
import { useEscape } from "./dismiss";
|
||||
import { current as language, plural, t } from "../i18n";
|
||||
import { useFileDrop } from "./filedrop";
|
||||
@@ -310,6 +311,7 @@ export function Candidate({ a }: { a: PluginAction }) {
|
||||
{r.why && <span className="why">{t(r.why)}</span>}
|
||||
</div>
|
||||
))}
|
||||
{a.mod && <ModPreviewBlock mod={a.mod} />}
|
||||
{adds.length > 0 && (
|
||||
<div className="risk">
|
||||
<span className="lb">{t("将添加")}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePaneCallbacks } from "./usePaneCallbacks";
|
||||
import { watchTreeWake } from "./treewake";
|
||||
import { usePinnedSessions } from "./usePinnedSessions";
|
||||
import { AutoArchiveNotice } from "./AutoArchiveNotice";
|
||||
import { LevelUpNotice } from "./LevelUpNotice";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
@@ -28,6 +29,7 @@ import { useWindowKeys, type Shortcut } from "./windowkeys";
|
||||
import { useEscStop } from "../state/escstop";
|
||||
import { startUpdateWatch } from "../state/updatewatch";
|
||||
import { StopHint } from "./StopHint";
|
||||
import { useFindAsk } from "./usefindask";
|
||||
import { folded as roomGaveUp, onRoomWidth } from "./viewport";
|
||||
import { useFoldAway } from "./foldaway";
|
||||
import { useDrawerCloses } from "./drawer";
|
||||
@@ -59,8 +61,11 @@ import { useSessionSwitch } from "./sessionswitch";
|
||||
import { singleFlight } from "./singleflight";
|
||||
import { useTreeRead } from "./useTreeRead";
|
||||
import { useOpenPane } from "./useOpenPane";
|
||||
import { useOpenRemotePane } from "./useOpenRemotePane";
|
||||
import { useNewConversation } from "./useNewConversation";
|
||||
import { markSettled } from "../boot/gate";
|
||||
import type { PaneView } from "../state/paneview";
|
||||
import { usePaneSessionRefs } from "./usePaneSessionRefs";
|
||||
|
||||
// Start fetching the settings chunk with the shell instead of waiting for the
|
||||
// first click. It remains a separate chunk (and keeps its failure boundary),
|
||||
@@ -121,13 +126,14 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
const [dockW, setDockW] = useState(() => widthOf(DOCK));
|
||||
const [dockLimit, setDockLimit] = useState(() => dockMax(document.body.clientWidth));
|
||||
const [report, setReport] = useState<PaneReport>(NO_REPORT);
|
||||
const [findPulse, setFindPulse] = useState(0);
|
||||
const [filesRequests, setFilesRequests] = useState<Record<string, number>>({});
|
||||
const [viewRequests, setViewRequests] = useState<Record<string, { to: PaneView; n: number }>>({});
|
||||
const [sessionRefs, referenceSession] = usePaneSessionRefs(active, (id) => panePorts.has(id));
|
||||
const [error, setError] = useState("");
|
||||
const [renaming, setRenaming] = useState<{ path: string; title: string } | null>(null);
|
||||
// false = closed, true = open at its last section, a string = open there.
|
||||
const [settings, setSettings] = useState<string | boolean>(false);
|
||||
const find = useFindAsk(settings, setSettings);
|
||||
const [feedback, setFeedback] = useState<FeedbackTab | null>(null);
|
||||
const [feedbackUnread, setFeedbackUnread] = useState(0);
|
||||
const [browser, setBrowser] = useState(false);
|
||||
@@ -263,21 +269,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
[hub],
|
||||
);
|
||||
|
||||
const openRemotePane = useCallback(
|
||||
async (host: string, workspace?: string, sessionPath?: string) => {
|
||||
setOpening((n) => n + 1);
|
||||
try {
|
||||
const view = await hub.openRemote({ host, workspace, sessionPath });
|
||||
await reloadPanes();
|
||||
sw.cancel();
|
||||
setActive(view.id);
|
||||
} finally {
|
||||
setOpening((n) => n - 1);
|
||||
void reloadRemotes();
|
||||
}
|
||||
},
|
||||
[hub, reloadPanes, reloadRemotes, sw.cancel],
|
||||
);
|
||||
const openRemotePane = useOpenRemotePane({ hub, reloadPanes, reloadRemotes, sw, setActive, setOpening });
|
||||
|
||||
// One port per pane, held across renders — a fresh instance would resubscribe
|
||||
// the event stream and drop the frames in between.
|
||||
@@ -406,13 +398,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
useLinkRouting(activePort, useCallback(() => setBrowser(true), []), fail);
|
||||
|
||||
// Settings cover the conversation, so a find opened under them is one nobody
|
||||
// sees: asking for it leaves them.
|
||||
const openFind = useCallback(() => {
|
||||
setSettings(false);
|
||||
setFindPulse((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
// The window's shortcuts, named by the action each one performs — the same
|
||||
// identity the control on screen carries, because they are the same thing
|
||||
// asked for two ways. Written out rather than branched so the census can read
|
||||
@@ -422,13 +407,13 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
() => [
|
||||
{ chord: "\\", fields: true, action: "rail.toggle", run: () => setRail((v) => !v) },
|
||||
{ chord: ",", fields: true, action: "chrome.settings", run: showPrefs },
|
||||
{ chord: "f", action: "transcript.find", run: openFind },
|
||||
{ chord: "f", action: "transcript.find", run: find.open },
|
||||
{ chord: "1", fields: true, action: "pane.view", run: () => showView("flow") },
|
||||
{ chord: "2", fields: true, action: "pane.view", run: () => showView("analysis") },
|
||||
{ chord: "3", fields: true, action: "pane.view", run: () => showView("browser") },
|
||||
...zoomKeys,
|
||||
],
|
||||
[showPrefs, openFind, showView, zoomKeys],
|
||||
[showPrefs, find.open, showView, zoomKeys],
|
||||
);
|
||||
const closeBrowser = useCallback(() => setBrowser(false), []);
|
||||
const stopTurn = useCallback(() => activePort?.cancel(), [activePort]);
|
||||
@@ -455,15 +440,16 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// "新会话" — with several open, identical labels are the same as no labels.
|
||||
const titleFor = useCallback(
|
||||
(rt: RuntimeView, at: number) => {
|
||||
for (const ws of tree) {
|
||||
for (const ws of rt.host ? viewed.remote[rt.host] ?? [] : tree) {
|
||||
for (const session of ws.sessions) {
|
||||
// An unwritten one has only a file name; the numbered fallback reads better.
|
||||
if (session.runtimeId === rt.id && (session.title || session.turns)) return session.title || session.name;
|
||||
const held = rt.host ? workspaceContains(ws, rt.root) && session.path === rt.sessionPath : session.runtimeId === rt.id;
|
||||
if (held && (session.title || session.turns)) return session.title || session.name;
|
||||
}
|
||||
}
|
||||
return at === 0 ? t("新会话") : t("新会话 {n}", { n: at + 1 });
|
||||
},
|
||||
[tree],
|
||||
[tree, viewed.remote],
|
||||
);
|
||||
|
||||
const tabs = useMemo(
|
||||
@@ -484,11 +470,13 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// 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 ?? <AutoArchiveNotice tree={tree} />, [errorBar, tree]);
|
||||
const bar = useMemo(
|
||||
() => errorBar ?? <LevelUpNotice port={machineRt ? machinePort : null} tick={feedback === null ? 0 : 1} fallback={<AutoArchiveNotice tree={tree} />} />,
|
||||
[errorBar, tree, machineRt, machinePort, feedback],
|
||||
);
|
||||
const activeWorkspace = tree.find((ws) => !activeRuntime?.host && workspaceContains(ws, activeRuntime?.root)) ?? tree[0];
|
||||
// The folder a new session opens in is the one the switcher above names —
|
||||
// the window's, not whichever project happens to sit first in the tree.
|
||||
const newSessionRoot = activeWorkspace?.root;
|
||||
const startConversation = useNewConversation(active, runtimes, newSessionRoot, openPane, openRemotePane);
|
||||
|
||||
usePaneForSheets({
|
||||
settings: !!settings,
|
||||
@@ -580,7 +568,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
account={account}
|
||||
theme={scheme}
|
||||
onTheme={() => setTheme(scheme === "dark" ? "light" : "dark")}
|
||||
onFind={openFind}
|
||||
onFind={find.toggle}
|
||||
view={report.view} rows={report.rows} surfaces={report.surfaces} onView={showView}
|
||||
hub={hub} onError={fail}
|
||||
/>
|
||||
@@ -598,7 +586,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
onFeedback={() => setFeedback(feedbackEntryTab(feedbackUnread))}
|
||||
onNew={() => {
|
||||
hidePrefs();
|
||||
void openPane({ root: newSessionRoot }).catch(fail);
|
||||
void startConversation().catch(fail);
|
||||
}}
|
||||
feedbackUnread={feedbackUnread}
|
||||
shown={navRail.shown}
|
||||
@@ -629,6 +617,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
adder={adder}
|
||||
opening={sw.path}
|
||||
onOpen={openPane}
|
||||
onNew={startConversation}
|
||||
onOpenRemote={openRemotePane}
|
||||
onFocusPane={focusPane}
|
||||
onClosePanes={closePanes}
|
||||
@@ -643,9 +632,10 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
onSettings={showPrefs}
|
||||
onFiles={openFiles}
|
||||
onView={showView}
|
||||
onReferenceSession={panePorts.has(active) ? referenceSession : undefined}
|
||||
onFeedback={(tab) => setFeedback(tab ?? "send")}
|
||||
feedbackUnread={feedbackUnread}
|
||||
onFind={openFind}
|
||||
onFind={find.open}
|
||||
onError={fail}
|
||||
/>
|
||||
|
||||
@@ -695,9 +685,11 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// looks blank — the next history row would take it over.
|
||||
onSessionChanged={refreshPanes}
|
||||
pulse={settingsPulse}
|
||||
findPulse={findPulse}
|
||||
findPulse={find.pulse}
|
||||
findTogglePulse={find.togglePulse}
|
||||
filesPulse={filesRequests[rt.id] ?? 0}
|
||||
viewRequest={viewRequests[rt.id]}
|
||||
sessionRef={sessionRefs[rt.id]}
|
||||
alert={rt.id === active ? bar : undefined}
|
||||
connect={first.connect}
|
||||
needsProject={needsProject}
|
||||
@@ -731,7 +723,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
) : (
|
||||
<>
|
||||
<p className="h">{t("从左栏选择,或在当前文件夹新建")}</p>
|
||||
<button data-action="session.new" onClick={() => void openPane({ root: newSessionRoot }).catch(fail)}>{t("新建会话")}</button>
|
||||
<button data-action="session.new" onClick={() => void startConversation().catch(fail)}>{t("新建会话")}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { ariaChord, chord } from "./keys";
|
||||
|
||||
export function BrowserFullToggle({ full, onToggle }: { full: boolean; onToggle: () => void }) {
|
||||
const label = full ? t("退出全屏") : t("全屏浏览器");
|
||||
return (
|
||||
<button className="workbench-files" data-action="browser.full" aria-pressed={full}
|
||||
aria-label={label} aria-keyshortcuts={`${ariaChord("F")}+Shift`}
|
||||
title={`${label} (${chord("Shift+F")})`} onClick={onToggle}>
|
||||
<StudioIcon name="expand" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,12 @@ const KIND: Record<string, string> = {
|
||||
file: "文件",
|
||||
dir: "目录",
|
||||
resource: "资源",
|
||||
session: "会话",
|
||||
};
|
||||
|
||||
const HINT: Record<string, string> = {
|
||||
ref: "↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭",
|
||||
session: "↑↓ 选择 · 回车添加到这条消息 · Esc 关闭",
|
||||
slash: "↑↓ 选择 · Tab 或回车补全 · Esc 关闭",
|
||||
"slash-arg": "↑↓ 选择 · Tab 或回车补全 · Esc 关闭",
|
||||
};
|
||||
@@ -30,7 +32,7 @@ const HINT: Record<string, string> = {
|
||||
// the other side — because a false yes costs a request and a false no costs a
|
||||
// menu that never opens.
|
||||
function mightComplete(text: string): boolean {
|
||||
return text.startsWith("/") || text.includes("@");
|
||||
return text.startsWith("/") || text.includes("@") || text.includes("#");
|
||||
}
|
||||
|
||||
// A "/" opening a word past the start of the line can only become a chip. The
|
||||
@@ -93,6 +95,7 @@ export function useCompletion(
|
||||
caret: number,
|
||||
apply: (text: string, caret: number) => void,
|
||||
chips?: ChipHost,
|
||||
onSession?: (picked: { id: string; title: string }) => void,
|
||||
): State {
|
||||
const [{ completion, active, kb }, setMenu] = useState({ completion: EMPTY, active: 0, kb: true });
|
||||
// An answer and its default highlight publish together; a deferred reset can
|
||||
@@ -123,7 +126,7 @@ export function useCompletion(
|
||||
const slashy = mid !== null || text.startsWith("/");
|
||||
const compatible = slashy
|
||||
? previous.kind === "slash" || previous.kind === "slash-arg"
|
||||
: previous.kind === "ref";
|
||||
: previous.kind === "ref" || previous.kind === "session";
|
||||
if (!compatible) setCompletion(EMPTY);
|
||||
setLoading(false);
|
||||
const timer = inSlashWord(text, caret) && !compatible
|
||||
@@ -159,6 +162,13 @@ export function useCompletion(
|
||||
(item?: CompletionItem, mode: "confirm" | "descend" = "confirm") => {
|
||||
const pick = item ?? items[Math.min(active, items.length - 1)];
|
||||
if (!pick) return;
|
||||
if (completion.kind === "session" && pick.kind === "session") {
|
||||
onSession?.({ id: pick.insert.slice("@session:".length), title: pick.label });
|
||||
asked.current++;
|
||||
setCompletion(EMPTY);
|
||||
setLoading(false);
|
||||
return apply(text.slice(0, completion.from) + text.slice(completion.to).replace(/^ /, ""), completion.from);
|
||||
}
|
||||
const placed = chips?.place(text, completion, pick);
|
||||
if (placed) return apply(placed.text, placed.caret);
|
||||
const finishRef = completion.kind === "ref" && !(mode === "descend" && pick.descend);
|
||||
@@ -172,7 +182,7 @@ export function useCompletion(
|
||||
}
|
||||
apply(next, completion.from + pick.insert.length + (finishRef ? 1 : 0));
|
||||
},
|
||||
[items, active, text, completion, apply, chips],
|
||||
[items, active, text, completion, apply, chips, onSession],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { Composer } from "./Composer";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { Completion, SessionStatus } from "../port/port";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
const past = [
|
||||
{ label: "Rotate the signing key", insert: "@session:20261018-020806-test", hint: "2026-10-18 02:08 · 4", kind: "session" },
|
||||
{ label: "Fix the flaky branch menu", insert: "@session:20261017-101010-test", hint: "2026-10-17 10:10 · 9", kind: "session" },
|
||||
];
|
||||
|
||||
function draw() {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "complete").mockImplementation(async (line, cursor): Promise<Completion> => {
|
||||
const at = line.lastIndexOf("#", cursor);
|
||||
return at < 0 ? { kind: "", from: 0, to: 0, items: [] } : { kind: "session", from: at, to: line.length, query: line.slice(at + 1), items: past };
|
||||
});
|
||||
const onSubmit = vi.fn(async () => true);
|
||||
const view = render(<Composer
|
||||
port={port}
|
||||
status={{ plan: false, effort: "auto", modelRef: "deepseek/deepseek-v4-pro" } as SessionStatus}
|
||||
running={false} focus={0} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()}
|
||||
/>);
|
||||
const box = screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement;
|
||||
const type = (value: string, caret = value.length) => fireEvent.change(box, { target: { value, selectionStart: caret } });
|
||||
return { ...view, port, onSubmit, box, type };
|
||||
}
|
||||
|
||||
it("turns a picked past conversation into a removable chip and takes the typed word out of the box", async () => {
|
||||
const { box, type } = draw();
|
||||
type("continue from #sig");
|
||||
const option = await screen.findByRole("option", { name: /Rotate the signing key/ });
|
||||
expect(screen.getAllByText("会话")).toHaveLength(2);
|
||||
fireEvent.mouseDown(option);
|
||||
expect(box.value).toBe("continue from ");
|
||||
expect(screen.getByText("Rotate the signing key")).toBeTruthy();
|
||||
expect(screen.getByText("历史会话 · 只读资料")).toBeTruthy();
|
||||
await waitFor(() => expect(screen.queryByRole("listbox", { name: "补全" })).toBeNull());
|
||||
fireEvent.click(screen.getByRole("button", { name: "移除 Rotate the signing key" }));
|
||||
expect(screen.queryByText("Rotate the signing key")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends the reference with the typed text, ahead of it like an attachment, and only once per conversation", async () => {
|
||||
const { box, type, onSubmit } = draw();
|
||||
type("#sig");
|
||||
fireEvent.mouseDown(await screen.findByRole("option", { name: /Rotate the signing key/ }));
|
||||
type("#fix");
|
||||
fireEvent.mouseDown(await screen.findByRole("option", { name: /Rotate the signing key/ }));
|
||||
expect(screen.getAllByText("Rotate the signing key")).toHaveLength(1);
|
||||
type("what did we decide");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledExactlyOnceWith("@session:20261018-020806-test what did we decide"));
|
||||
await waitFor(() => expect(screen.queryByText("Rotate the signing key")).toBeNull());
|
||||
});
|
||||
|
||||
it("sends a chip on its own, and puts it back with the draft when the send fails", async () => {
|
||||
const { port, box, type, onSubmit } = draw();
|
||||
onSubmit.mockResolvedValueOnce(false);
|
||||
type("#");
|
||||
fireEvent.mouseDown(await screen.findByRole("option", { name: /Fix the flaky branch menu/ }));
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("@session:20261017-101010-test"));
|
||||
await waitFor(() => expect(screen.getByText("Fix the flaky branch menu")).toBeTruthy());
|
||||
expect(port.complete).toHaveBeenCalled();
|
||||
});
|
||||
@@ -52,7 +52,7 @@ interface Props {
|
||||
restore?: { n: number; text: string };
|
||||
// Text another surface hands the composer, such as a file reference; it goes
|
||||
// in at the caret with a space on either side.
|
||||
inject?: { n: number; text: string };
|
||||
inject?: { n: number; text: string; session?: { id: string; title: string } };
|
||||
// Resolves false when the line never left, so what was typed comes back
|
||||
// rather than being lost to a refusal the user could not have prevented.
|
||||
// Bumped when something outside asks for the cursor — answering a plan card
|
||||
@@ -166,6 +166,11 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
|
||||
useEffect(() => {
|
||||
if (!inject?.n || inject.n === injectedAt.current) return;
|
||||
injectedAt.current = inject.n;
|
||||
if (inject.session) {
|
||||
addSession(inject.session);
|
||||
queueMicrotask(() => box.current?.focus());
|
||||
return;
|
||||
}
|
||||
const where = caret;
|
||||
setText((prev) => {
|
||||
const at = Math.min(where, prev.length);
|
||||
@@ -182,10 +187,11 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
|
||||
type(next, at);
|
||||
}, []);
|
||||
const chips = useSkillChips(box, text, moveTo);
|
||||
const addSession = useCallback((s: { id: string; title: string }) => setShots((prev) => prev.some((c) => c.k === "session" && c.session === s.id) ? prev : [...prev, { k: "session", id: chipId(), session: s.id, title: s.title }]), []);
|
||||
const menu = useCompletion(port, text, caret, (next, at) => {
|
||||
moveTo(next, at);
|
||||
box.current?.focus();
|
||||
}, chips);
|
||||
}, chips, addSession);
|
||||
const ime = useIme();
|
||||
const sendShortcut = useSendShortcut();
|
||||
|
||||
@@ -221,7 +227,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
|
||||
if (submittingRef.current || shots.some((c) => c.k === "attachment" && c.state !== "ready")) return;
|
||||
const v = text.trim();
|
||||
if (!v && shots.length === 0) return;
|
||||
const refs = shots.flatMap((c) => (c.k === "attachment" && c.a ? [c.a.ref] : []));
|
||||
const refs = shots.flatMap((c) => (c.k === "attachment" && c.a ? [c.a.ref] : c.k === "session" ? ["@session:" + c.session] : []));
|
||||
const pastes = shots.flatMap((c) => (c.k === "paste" ? [c.saved ? c.saved.pointer.trim() : c.body] : []));
|
||||
// A quote is what the question is about, so it leads; a held-back paste is
|
||||
// the material the answer needs and follows what was typed.
|
||||
@@ -473,6 +479,14 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : c.k === "session" ? (
|
||||
<>
|
||||
<span className="glyph" aria-hidden="true">{"#"}</span>
|
||||
<span className="meta">
|
||||
<span className="nm" title={c.title}>{c.title}</span>
|
||||
<span className="sz">{t("历史会话 · 只读资料")}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="glyph" aria-hidden="true">{c.k === "quote" ? "❝" : "TXT"}</span>
|
||||
|
||||
@@ -8,11 +8,11 @@ import { RailSearch } from "./railsearch";
|
||||
const tree = [{ root: "/w/a", name: "a", sessions: [{ path: "/w/a/s1.jsonl", name: "s1", title: "Rotate keys" }] }] as unknown as TreeWorkspace[];
|
||||
const hit: HistoryHit = { path: "/w/a/s1.jsonl", id: "s1", snippet: "rotate the zebra credentials", role: "user", messageIndex: 0, score: 1 };
|
||||
|
||||
function setup(search: (q: string) => Promise<HistoryHit[]>, workspaces = tree) {
|
||||
function setup(search: (q: string) => Promise<HistoryHit[]>, workspaces = tree, onReference?: (id: string, title: string) => void) {
|
||||
const onOpen = vi.fn(() => Promise.resolve());
|
||||
render(
|
||||
<RailSearch>
|
||||
<ContentMatches hub={{ searchHistory: search }} tree={workspaces} onOpen={onOpen} onError={() => {}} />
|
||||
<ContentMatches hub={{ searchHistory: search }} tree={workspaces} onOpen={onOpen} onError={() => {}} onReference={onReference} />
|
||||
</RailSearch>,
|
||||
);
|
||||
return onOpen;
|
||||
@@ -61,4 +61,21 @@ describe("ContentMatches", () => {
|
||||
fireEvent.click(document.querySelector("[data-action='session.open-match']")!);
|
||||
expect(onOpen).toHaveBeenCalledWith({ root: "/worktrees/a", sessionPath: hit.path });
|
||||
});
|
||||
|
||||
it("offers to reference a match in the message box, and referencing does not open it", async () => {
|
||||
const onReference = vi.fn();
|
||||
const onOpen = setup(() => Promise.resolve([hit]), tree, onReference);
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "zebra" } });
|
||||
await act(async () => void vi.advanceTimersByTime(400));
|
||||
fireEvent.click(screen.getByRole("button", { name: "引用「Rotate keys」到输入框" }));
|
||||
expect(onReference).toHaveBeenCalledExactlyOnceWith("s1", "Rotate keys");
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has no reference button when there is no message box to put it in", async () => {
|
||||
setup(() => Promise.resolve([hit]));
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "zebra" } });
|
||||
await act(async () => void vi.advanceTimersByTime(400));
|
||||
expect(document.querySelector("[data-action='session.reference-match']")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,12 @@ function rootOf(hit: HistoryHit, tree: TreeWorkspace[]): string | undefined {
|
||||
/** Conversations whose words, not names, match what the rail is searching for.
|
||||
* The name filter still answers instantly; this adds what was said once the
|
||||
* reader has paused. */
|
||||
export function ContentMatches({ hub, tree, onOpen, onError }: {
|
||||
export function ContentMatches({ hub, tree, onOpen, onError, onReference }: {
|
||||
hub: Pick<HubPort, "searchHistory">;
|
||||
tree: TreeWorkspace[];
|
||||
onOpen: (req: { root?: string; sessionPath?: string }) => Promise<void>;
|
||||
onError: (e: unknown) => void;
|
||||
onReference?: (id: string, title: string) => void;
|
||||
}) {
|
||||
const needle = useRailQuery();
|
||||
const [read, setRead] = useState<Reading | null>(null);
|
||||
@@ -69,6 +70,11 @@ export function ContentMatches({ hub, tree, onOpen, onError }: {
|
||||
<b>{titleOf(hit, tree)}</b>
|
||||
<span>{hit.snippet}</span>
|
||||
</button>
|
||||
{onReference && (
|
||||
<button type="button" className="content-match-ref" data-action="session.reference-match" data-target={hit.path}
|
||||
aria-label={t("引用「{title}」到输入框", { title: titleOf(hit, tree) })} title={t("引用到输入框")}
|
||||
onClick={() => onReference(hit.id, titleOf(hit, tree))}>#</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { DefaultMode } from "./DefaultMode";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const toggle = () => screen.findByRole("switch", { name: "新对话默认进入计划模式" });
|
||||
|
||||
describe("the default-mode switch", () => {
|
||||
it("starts off, says what it changes and what it leaves alone, and saves the opposite of what it shows", async () => {
|
||||
const p = new MockPort() as unknown as AgentPort;
|
||||
const save = vi.spyOn(p, "saveDefaultMode");
|
||||
render(<DefaultMode port={p} />);
|
||||
const sw = await toggle();
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
expect(screen.getByText(/已有的对话保持它自己的模式/)).toBeTruthy();
|
||||
expect(screen.getByText(/项目配置不能替你改/)).toBeTruthy();
|
||||
await userEvent.click(sw);
|
||||
expect(save).toHaveBeenCalledWith(true);
|
||||
await waitFor(() => expect(sw.getAttribute("aria-checked")).toBe("true"));
|
||||
await userEvent.click(sw);
|
||||
expect(save).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("keeps the switch where it was when the save is refused", async () => {
|
||||
const p = new MockPort() as unknown as AgentPort;
|
||||
p.saveDefaultMode = async () => {
|
||||
throw new Error("disk full");
|
||||
};
|
||||
render(<DefaultMode port={p} />);
|
||||
const sw = await toggle();
|
||||
await userEvent.click(sw);
|
||||
await screen.findByText(/disk full/);
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("says so when the setting cannot be read", async () => {
|
||||
const p = new MockPort() as unknown as AgentPort;
|
||||
p.defaultMode = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
render(<DefaultMode port={p} />);
|
||||
expect(await screen.findByText("无法读取新对话的起始模式设置。")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort, DefaultModeSettings } from "../port/port";
|
||||
import { Group } from "./Group";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
// One switch over how a new conversation starts: in plan mode, or as usual. Off
|
||||
// is the default. It changes only the starting mode; the mode picker still
|
||||
// switches it, and a conversation that already exists keeps its own.
|
||||
export function DefaultMode({ port }: { port: AgentPort }) {
|
||||
const [state, setState] = useState<DefaultModeSettings | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
port
|
||||
.defaultMode()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [port]);
|
||||
|
||||
const group = (body: ReactNode) => (
|
||||
<Group id="default-mode" title={t("新对话的起始模式")} hint={t("新建对话时先进入哪种模式。只影响起点,不会改动已有的对话。")}>
|
||||
{body}
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (!state) return group(<div className="empty">{t("无法读取新对话的起始模式设置。")}</div>);
|
||||
|
||||
const flip = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setState(await port.saveDefaultMode(!state.plan));
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return group(
|
||||
<div className="box">
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("新对话默认进入计划模式")}</span>
|
||||
<span className="ds">{t("新建的对话先做计划,你批准后才动手。输入框里的模式按钮仍可随时切换;已有的对话保持它自己的模式。默认关闭,只写在你自己的用户配置里,项目配置不能替你改。")}</span>
|
||||
</span>
|
||||
<Switch
|
||||
data-action="default-mode.plan"
|
||||
on={state.plan}
|
||||
busy={busy}
|
||||
label={t("新对话默认进入计划模式")}
|
||||
onClick={() => void flip()}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { ChangedFilesSetting } from "./ChangedFilesSetting";
|
||||
import { FeedbackLevelNoticeSetting } from "./FeedbackLevelNoticeSetting";
|
||||
import { FoldWorkSetting } from "./FoldWorkSetting";
|
||||
import { TurnNarrationSetting } from "./TurnNarrationSetting";
|
||||
import { ReceiptSetting } from "./ReceiptSetting";
|
||||
@@ -9,6 +10,7 @@ export function DisplaySettings({ port }: { port: AgentPort }) {
|
||||
return (
|
||||
<>
|
||||
<ChangedFilesSetting port={port} />
|
||||
<FeedbackLevelNoticeSetting port={port} />
|
||||
<FoldWorkSetting port={port} />
|
||||
<TurnNarrationSetting port={port} />
|
||||
<ReceiptSetting port={port} />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { loadLevelNoticePref, saveLevelNoticePref, useShowsLevelNotice } from "../state/levelnotices";
|
||||
import { Group } from "./Group";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
export function FeedbackLevelNoticeSetting({ port }: { port: AgentPort }) {
|
||||
const on = useShowsLevelNotice(port);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
void loadLevelNoticePref(port);
|
||||
}, [port]);
|
||||
return (
|
||||
<Group id="feedback-level-notices" title={t("等级提示")} hint={t("你的反馈被采纳、等级提高时,在窗口顶部提示一次,由你点“知道了”关掉。默认关闭;关着时不会提示,也不会补报之前的升级。")}>
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("等级提升时通知我")}</span>
|
||||
<span className="ds">{t("不弹窗、不发系统通知,只在窗口顶部的提示条里说一次")}</span>
|
||||
</span>
|
||||
<Switch data-action="chrome.feedback-level-notices" on={on} label={t("等级提升时通知我")} onClick={() => {
|
||||
setError("");
|
||||
saveLevelNoticePref(port, !on).catch((e) => setError(reason(e)));
|
||||
}} />
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ describe("my feedback", () => {
|
||||
const port = portWith({ offline: false, unread: 0, hasNew: false, items: FEEDBACK_STATUSES.map((status, i) => item({ receipt: `FB-AAAA-000${i}`, status })) });
|
||||
render(<FeedbackMine port={port} onFile={() => {}} />);
|
||||
await screen.findByText("FB-AAAA-0000");
|
||||
for (const [status, label] of [["received", "已收到"], ["needs_info", "需要补充信息"], ["answered", "维护者已回复"], ["closed", "已关闭此反馈"], ["recorded", "已登记"], ["in_progress", "处理中"], ["fixed", "已修复"], ["wontfix", "不予修复"], ["duplicate", "重复"]]) {
|
||||
for (const [status, label] of [["received", "已收到"], ["needs_info", "需要补充信息"], ["answered", "维护者已回复"], ["closed", "已关闭此反馈"], ["recorded", "已登记"], ["in_progress", "处理中"], ["fixed", "已修复"], ["wontfix", "不予修复"], ["duplicate", "重复"], ["withdrawn", "已撤回"]]) {
|
||||
expect(document.querySelector(`.fbk-chip[data-status="${status}"]`)!.textContent).toBe(label);
|
||||
}
|
||||
});
|
||||
|
||||
Loaded 100 of 851 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user