Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui

# Conflicts:
#	.github/workflows/studio.yml
This commit is contained in:
esengine committed 2026-10-17 07:33:06 -07:00
commit 4f8852dbd2
210 files changed
+7699 -834

No files matched your search

+6
View File
@@ -205,6 +205,12 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
+1
View File
@@ -164,6 +164,7 @@ jobs:
node perf/narrow-steps.mjs
node perf/job-names.mjs
node perf/web-access.mjs
node perf/peer-threads.mjs
node perf/text-size.mjs
node perf/fs-scale.mjs
node perf/composer-feel.mjs
+149 -19
View File
@@ -45,26 +45,72 @@ func killedRun(t *testing.T) result {
return r
}
// A run whose final metrics never landed has no harness-side accounting. The
// report already has the state for that (Unaccounted) and swebench mode sets it;
// the suite path must not fold such a run in as an accounted zero, or every
// published total silently omits what it spent.
func TestSuiteRunWithoutFinalMetricsIsNotCountedAsAccounted(t *testing.T) {
// writeSilentAgentStub writes a stand-in that leaves neither a final record nor
// a snapshot: what it spent is simply gone, so nothing can bound it.
func writeSilentAgentStub(t *testing.T, dir string) string {
t.Helper()
requireShellStub(t)
path := filepath.Join(dir, "silent-agent.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run recovered from its snapshot is still accounted: its numbers are real and
// stop at the last write, which is exactly what Partial says. Marking it
// Unaccounted would drop them from every total and hide the lower bound.
func TestSuiteSidecarRecoveredRunStaysAccountedAsALowerBound(t *testing.T) {
r := killedRun(t)
if r.Unaccounted || !r.Partial {
t.Fatalf("a run recovered from its snapshot was reported as unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 1 {
t.Fatalf("a run that wrote no final metrics counted as accounted:\n"+
" unaccounted=%d accounted=%d tokens=%d cost=%.4f\n"+
" (prompt_tokens=%d completion_tokens=%d, from the result itself)",
stats.unaccounted, stats.accounted, stats.pTok+stats.cTok, stats.cost,
r.PromptTokens, r.CompletionTokens)
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 1 {
t.Fatalf("a recovered run changed category:\n"+
" unaccounted=%d accounted=%d partial=%d", stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 1200 || stats.cTok != 80 || stats.cost != 0.0123 {
t.Fatalf("the recovered numbers never reached the aggregates:\n"+
" tokens=%d/%d cost=%.4f, want 1200/80 at 0.0123", stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the lower-bound hint is unreachable:\n%s", out)
}
}
// A leg that leaves neither record has no surviving evidence at all, so the run
// is unmeasured and must stay out of the totals rather than count as zero.
func TestSuiteRunWithNoRecordAtAllIsUnaccounted(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "silent-task", Prompt: "do the thing"}
r := result{task: tk}
cfg := suiteConfig{bin: writeSilentAgentStub(t, work)}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("the stub exits 0; a silent leg must not fail the run: %v", err)
}
if !r.Unaccounted || r.Partial {
t.Fatalf("a run that left no record at all was not marked unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=false unaccounted=true", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 1 || stats.accounted != 0 {
t.Fatalf("a record-less run was counted as accounted:\n"+
" unaccounted=%d accounted=%d", stats.unaccounted, stats.accounted)
}
if stats.pTok != 0 || stats.cTok != 0 || stats.cost != 0 {
t.Fatalf("a record-less run contributed numbers: tokens=%d/%d cost=%.4f",
stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "Accounting incomplete for 1 of 1 instances") {
t.Fatalf("the record-less run was not disclosed as unaccounted:\n%s", out)
}
}
// The killed child did leave its numbers: the metrics sink snapshots in flight
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path
// deletes the directory without looking, so the evidence is destroyed rather
// than being missing.
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path must
// do the same rather than let the directory removal destroy the evidence.
func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
r := killedRun(t)
if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
@@ -75,14 +121,98 @@ func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
}
}
// The meter-vs-harness divergence line divides one side by the other. A run
// whose harness-side number never landed has no side, so folding its meter
// tokens against a silent zero invents a divergence and blames the harness for
// it.
// The meter-vs-harness divergence line divides one side by the other. Whether a
// run has a harness side is the suite's Unaccounted state, not the token values:
// a run with no record at all has no side, and pricing it against the meter
// invents a divergence and blames the harness for it.
func TestMeterDivergenceDoesNotPriceAMetricslessRun(t *testing.T) {
r := result{task: task{ID: "killed"}, Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
r := result{task: task{ID: "killed"}, Unaccounted: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("a run with no harness-side number was priced as a self-report divergence:\n%s", got)
}
}
// A persisted record that says zero tokens is a measurement of zero, not an
// absence: the harness side exists, so it belongs on both sides of the ratio
// even when the number it reports disagrees with the meter.
func TestMeterAccountingKeepsAPersistedZeroOnBothSides(t *testing.T) {
r := result{task: task{ID: "zero"}, Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r})
if !strings.Contains(got, "-100.0% (harness 0 vs meter 1,280 over 1 runs)") {
t.Fatalf("a real zero-token measurement was dropped as if it were missing:\n%s", got)
}
}
func TestCorrectBoundaryMediansExcludeRunsWithNoRecordedSplit(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
measured := result{
task: task{ID: "measured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1, RoundsBeforeCorrect: 3, RoundsAfterCorrect: 4,
CallsAfterCorrect: 5, VerifyAfterCorrect: 2,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
// the same run twice, but -trajectories was omitted: no digest, so no split
// was ever taken and the before/after tallies stay at their zero value.
unmeasured := result{
task: task{ID: "unmeasured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1,
}
unmeasured2 := unmeasured
unmeasured2.task = task{ID: "unmeasured-2"}
// The audit read the rendered line as plain text; the renderer emits
// markdown emphasis around each label, so compare with it stripped.
const want = "**rounds before** 3 · **rounds after** 4"
got := renderSolveProfiles([]result{measured, unmeasured, unmeasured2})
if !strings.Contains(got, want) {
t.Fatalf("medians folded in a run whose trajectory was never split:\n%s\n"+
"want %q (the measured run only)", got, want)
}
}
// Control (a): when every run recorded its split, the exclusion must not touch
// a single number — the medians stay the measured ones.
func TestCorrectBoundaryMediansAllMeasuredUnchanged(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
mk := func(id string, before, after, verify, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
RoundsBeforeCorrect: before, RoundsAfterCorrect: after,
VerifyAfterCorrect: verify, MutationsBeforeCorrect: mutations,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
}
got := renderSolveProfiles([]result{mk("a", 2, 6, 1, 0), mk("b", 3, 4, 2, 1), mk("c", 4, 5, 3, 2)})
const want = "**mutations before** 1 · **rounds before** 3 · **rounds after** 5 · **verifications after** 2"
if !strings.Contains(got, want) {
t.Fatalf("all-measured medians changed:\n%s\nwant %q", got, want)
}
}
// Control (b): with -trajectories off no split was ever taken for any run, so
// there is nothing to average. Printing zeros would publish them as
// observations; the report must say the boundary is unmeasured instead.
func TestCorrectBoundaryWithNoRecordedSplitSaysNothingToAverage(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
run := func(id string, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: mutations,
}
}
got := renderSolveProfiles([]result{run("a", 1), run("b", 3)})
if strings.Contains(got, "rounds before") || strings.Contains(got, "verifications after") {
t.Fatalf("a boundary that was never split printed as a measurement:\n%s", got)
}
if !strings.Contains(got, "**mutations before** 3") {
t.Fatalf("the checkpoint-derived mutation count is measured without a trajectory and must still be printed:\n%s", got)
}
if !strings.Contains(got, "nothing to average") {
t.Fatalf("the report must say the boundary was never measured:\n%s", got)
}
}
+19 -7
View File
@@ -195,7 +195,7 @@ func renderSolveProfiles(results []result) string {
}
line := "**Solve profile**: "
parts := []string{}
regressed, withCorrect := 0, 0
regressed, withCorrect, split := 0, 0, 0
var roundsBefore, roundsAfter, verifyAfter, mutationsBefore []int64
for _, r := range results {
if len(r.Checkpoints) == 0 {
@@ -203,10 +203,16 @@ func renderSolveProfiles(results []result) string {
}
if r.FirstCorrectMs > 0 {
withCorrect++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
// Fold in only runs whose trajectory was read and split (Trajectory
// is set exactly when -trajectories ran): a run without one has no
// measurement, and its zeros are absence, not a zero.
mutationsBefore = append(mutationsBefore, int64(r.MutationsBeforeCorrect))
if r.Trajectory != nil {
split++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
}
}
if r.RegressedAfterCorrect {
regressed++
@@ -247,9 +253,15 @@ func renderSolveProfiles(results []result) string {
ttfum = append(ttfum, r.FirstUsefulMs)
}
}
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
if split == 0 {
// The split counts are absent; the checkpoint-derived ones are not.
line += fmt.Sprintf("\n\n**Correct boundary**: **mutations before** %d · **regression-after-correct** %s — nothing to average: the runs reached a correct state, but none recorded a trajectory, so the boundary was never split",
median(mutationsBefore), pct(regressed, withCorrect))
} else {
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
}
if len(ttfum) > 0 {
line += fmt.Sprintf(" · **TTFUM median** %s", dur(median(ttfum)))
}
+4 -4
View File
@@ -116,10 +116,10 @@ func renderMeterAccounting(results []result) string {
miss += r.Meter.CacheMissTokens
unmeasured += r.Meter.WithoutUsage
injected += r.Meter.Injected
// A run whose metrics file never landed has no harness number, so it
// stays out of both sides of the comparison. A metered run whose
// harness reported no tokens at all is the same absence, not a ratio.
if r.Unaccounted || r.PromptTokens+r.CompletionTokens == 0 {
// Whether the harness has a side to compare is the suite's state, not a
// token count: Unaccounted has no record and Partial stops at its last
// snapshot, so neither side is whole. A persisted zero is a measurement.
if r.Unaccounted || r.Partial {
continue
}
selfReported++
+6 -7
View File
@@ -51,15 +51,14 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
m, partial, err := legMetrics(metricsPath)
if err != nil {
// The leg left no record at all: its real spend is unknown, so the
// run is reported as unmeasured rather than priced at zero.
// The leg left no record at all: nothing survives to say what it
// spent, so the run is unmeasured rather than priced at zero.
r.Unaccounted = true
} else {
// A leg that finished wrote the final record; one that was killed
// only left the sidecar snapshot, so the account stops at the last
// write and the run's total is still incomplete.
r.Unaccounted = r.Unaccounted || partial
r.Partial = partial || !m.Complete
// A snapshot is real evidence, so the run stays accounted and only
// Partial says its numbers stop at the last write. It is sticky: a
// complete later leg cannot restore the tail an earlier one lost.
r.Partial = r.Partial || partial || !m.Complete
keepMetrics = keepMetrics || partial
foldSegmentMetrics(r, m, seg.index)
}
+97 -6
View File
@@ -50,6 +50,36 @@ func recordedMetricsPath(t *testing.T, work string) string {
return got
}
// twoLegStub writes a stand-in whose legs differ: the first leaves only the
// in-flight snapshot, the second lands its final record. Both exit 0, which is
// what lets the run reach the second leg after the first one stopped short.
func twoLegStub(t *testing.T, name string) string {
t.Helper()
requireShellStub(t)
script := `#!/bin/sh
metrics=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
prev="$arg"
done
case "$metrics" in
*-1.json)
printf '%s' '{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,"currency":"USD","complete":false}' > "$metrics.partial"
;;
*)
printf '%s' '{"prompt_tokens":300,"completion_tokens":20,"steps":2,"cost":0.001,"currency":"USD","complete":true}' > "$metrics"
;;
esac
exit 0
`
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run that lands its final metrics record is untouched by the recovery
// path: it stays accounted, keeps its real numbers and is still not partial.
func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) {
@@ -63,9 +93,9 @@ func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) {
t.Fatalf("a healthy leg must not fail the run: %v", err)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 0 || stats.accounted != 1 {
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d",
stats.unaccounted, stats.accounted)
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 0 {
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d partial=%d",
stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 900 || stats.cTok != 60 || stats.cost != 0.02 {
t.Fatalf("a healthy run's numbers changed: tokens=%d/%d cost=%.4f",
@@ -104,11 +134,52 @@ func TestSuiteRunKeepsTheSnapshotItRecovered(t *testing.T) {
t.Fatalf("the snapshot was kept but not recovered: tokens=%d/%d",
r.PromptTokens, r.CompletionTokens)
}
// Recovering the numbers must not also mark the run unmeasured: the snapshot
// is evidence, so the run stays accounted and Partial marks it a lower bound.
if r.Unaccounted || !r.Partial {
t.Fatalf("a recovered snapshot was not reported as accounted but partial: partial=%v unaccounted=%v",
r.Partial, r.Unaccounted)
}
}
// TestMeterAccountingSkipsAnUnaccountedRunWithTokens pins the other term of the
// guard: a run the suite already marked unmeasured stays out of the ratio even
// when the harness number is present, so a partial account is not priced.
// A lower bound belongs to the run, not to its last leg: a complete second leg
// cannot restore the tail a killed first leg stopped short of, so once any leg
// ends at a snapshot the whole run stays partial — and still counts.
func TestSuitePartialFirstLegMakesTheWholeRunALowerBound(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "two-leg-partial", Prompt: "do the thing", MaxSteps: 10}
cfg := suiteConfig{bin: twoLegStub(t, "stub-two-leg.sh"), segments: 2}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("both legs exit 0; the run must not fail: %v", err)
}
if r.Segments != 2 {
t.Fatalf("the run did not take both legs: segments=%d", r.Segments)
}
// The second leg's completeness says nothing about the first leg's tail.
if !r.Partial || r.Unaccounted {
t.Fatalf("a complete last leg erased the first leg's lower bound:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
// Partial bounds the account; it does not exclude it. Both legs are counted.
if r.PromptTokens != 1500 || r.CompletionTokens != 100 {
t.Fatalf("the legs' spend was not folded together: tokens=%d/%d, want 1500/100",
r.PromptTokens, r.CompletionTokens)
}
stats := gatherSuiteStats([]result{r})
if stats.accounted != 1 || stats.unaccounted != 0 || stats.partial != 1 || stats.pTok != 1500 {
t.Fatalf("a two-leg partial run changed category:\n"+
" accounted=%d unaccounted=%d partial=%d pTok=%d, want 1/0/1/1500",
stats.accounted, stats.unaccounted, stats.partial, stats.pTok)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the run was counted but not disclosed as a lower bound:\n%s", out)
}
}
// TestMeterAccountingSkipsAnUnaccountedRunWithTokens pins the guard: a run the
// suite marked unmeasured stays out of the ratio even when a harness number is
// present, so a run with no record is not priced.
func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) {
r := result{
task: task{ID: "killed"},
@@ -121,3 +192,23 @@ func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) {
t.Fatalf("an unaccounted run with a harness number was priced as a self-report divergence:\n%s", got)
}
}
// The same guard has to cover a lower bound: a Partial run's harness side stops
// at its last snapshot while the meter saw the whole leg, so pricing it reads as
// a divergence that is only the missing tail.
func TestMeterAccountingSkipsAPartialRunWithTokens(t *testing.T) {
r := result{
task: task{ID: "lower-bound"},
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
Partial: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("a partial run's missing tail was priced as a self-report divergence:\n%s", got)
}
// Its meter tokens are still a real measurement, so the totals keep them.
if !strings.Contains(got, "tokens 1,280") {
t.Fatalf("skipping the ratio also dropped the run's metered tokens:\n%s", got)
}
}
+12
View File
@@ -84,3 +84,15 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow.
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
## Workspace preparation / 工作目录准备
The workspace palette and preparation states reuse existing typography and
tokens. Real Git checks cover branches, creation, retry and cancellation; visual
QA passed. Captures remain in
[PR #13598](https://github.com/esengine/DeepSeek-Reasonix/pull/13598).
工作目录面板与准备状态复用现有字号、图标和表面样式;真实 Git 验证了分支搜索、
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
+1
View File
@@ -16,6 +16,7 @@
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"focus-live": "electron test/focus_live.js",
"ime-live": "electron test/ime_live.js",
"freeze-live": "electron test/freeze_live.js",
"windowstate-live": "electron test/windowstate_live.js",
"computer-live": "electron test/computer_live.js"
+44 -8
View File
@@ -10,6 +10,12 @@ const { KeyboardKeeper } = require("./keyboard");
// a phone layout or not at all.
const AGENT_VIEWPORT = { width: 1280, height: 900 };
// How long a page may be held out of the window while its navigation runs. A
// server that accepts the connection and never answers would otherwise keep the
// panel blank, with nothing turning, for as long as it likes; past this the page
// is shown again, loaded or not.
const LEAVE_LIMIT_MS = 4000;
// CSS cannot clip a native view, so the page's corners are rounded here; the
// value is .bview's border-radius in studio.css, whose rect the view fills.
const PAGE_RADIUS = 8;
@@ -108,7 +114,7 @@ class BrowserViews {
}
onEvent(method, params);
});
const entry = { view, onDownload, typed: 0, synthUntil: 0 };
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false };
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
this.watchLoads(targetId, entry);
let closed = false;
@@ -116,15 +122,18 @@ class BrowserViews {
if (closed) return;
closed = true;
this.entries.delete(targetId);
if (!this.win.isDestroyed()) this.win.contentView.removeChildView(view);
clearTimeout(entry.returning);
if (entry.attached && !this.win.isDestroyed()) this.win.contentView.removeChildView(view);
entry.attached = false;
if (!contents.isDestroyed()) contents.close();
onClosed();
};
contents.once("destroyed", close);
contents.on("render-process-gone", close);
this.entries.set(targetId, entry);
this.win.contentView.addChildView(view);
this.putAway(view);
// The page loads before the window holds it: a page that finishes loading in a
// view already attached is given the keyboard by the platform, and the
// person's composition in the app page ends with it.
void contents.loadURL(url).catch(() => {});
return {
targetId,
@@ -161,8 +170,9 @@ class BrowserViews {
// A page that was parked comes back as a view that is attached but whose
// widget never repaints until the window's geometry changes; attaching it
// again is what wakes it, the same as after a minimize.
if (!entry.at) this.win.contentView.removeChildView(entry.view);
if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
entry.attached = true;
entry.view.setBounds(at);
entry.at = at;
this.keepKeyboard();
@@ -177,7 +187,7 @@ class BrowserViews {
reattach() {
for (const [id, entry] of this.entries) {
if (this.win.isDestroyed() || entry.view.webContents.isDestroyed()) continue;
if (this.win.isDestroyed() || entry.view.webContents.isDestroyed() || !entry.attached) continue;
this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
if (id !== this.shown) this.putAway(entry.view, entry);
@@ -185,6 +195,29 @@ class BrowserViews {
this.keepKeyboard();
}
// A navigation that starts in an attached view takes the keyboard from the app
// page when it finishes, and a composition in progress there is committed as
// the raw letters. While the app page holds the keyboard the view is held out of
// the window for the length of the navigation, and put back when it ends; a
// person who is in the page themselves keeps it where it is.
leave(entry) {
if (!entry.attached || this.win.isDestroyed() || this.keyboard.owner !== "app") return;
this.win.contentView.removeChildView(entry.view);
entry.attached = false;
clearTimeout(entry.returning);
entry.returning = setTimeout(() => this.settle(entry), LEAVE_LIMIT_MS);
}
settle(entry) {
clearTimeout(entry.returning);
entry.returning = undefined;
if (entry.attached || this.win.isDestroyed() || entry.view.webContents.isDestroyed()) return;
this.win.contentView.addChildView(entry.view);
entry.attached = true;
if (entry.at) entry.view.setBounds(entry.at);
else this.putAway(entry.view, entry);
}
// freeze answers with a picture of the page on screen, taken before it is put
// away, for the window to draw in its place while something of its own is
// over it. Only the page already shown to the person is ever taken, and the
@@ -270,8 +303,11 @@ class BrowserViews {
watchLoads(targetId, entry) {
const contents = entry.view.webContents;
contents.on("did-start-navigation", (details) => {
if (details.isMainFrame && !details.isSameDocument) this.report(targetId, null);
if (!details.isMainFrame || details.isSameDocument) return;
this.report(targetId, null);
this.leave(entry);
});
contents.on("did-stop-loading", () => this.settle(entry));
contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => {
// -3 is ERR_ABORTED: a navigation replaced by another, or stopped. A
// typed address reports through loadTyped, which may still retry it.
@@ -389,4 +425,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 };
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS };
+125
View File
@@ -0,0 +1,125 @@
"use strict";
// A person in the middle of an IME composition — the pinyin letters still
// underlined, the candidate list open — must not lose it when the agent's browser
// loads, navigates, reloads or is shown. Losing it is not a lost focus that comes
// back: the composition is committed as the raw letters and the word they were
// spelling is gone. Run with `pnpm ime-live`; it needs no network and no kernel.
const { app, BrowserWindow } = require("electron");
const { BrowserViews, LEAVE_LIMIT_MS } = require("../src/browserviews");
const { BrowserProtocol } = require("../src/browserprotocol");
const { checker, listen, wait } = require("./livekit");
const APP = `<!doctype html><title>app</title><textarea id="compose" autofocus></textarea>
<script>window.__ime = { start: 0, update: 0, end: 0, blur: 0 };
const t = document.getElementById("compose");
t.addEventListener("compositionstart", () => __ime.start++);
t.addEventListener("compositionupdate", () => __ime.update++);
t.addEventListener("compositionend", () => __ime.end++);
t.addEventListener("blur", () => __ime.blur++);</script>`;
const PAGE = `<!doctype html><title>page</title><input id="name"><p>hello</p>`;
const { check, failures } = checker();
async function main() {
await app.whenReady();
const page = await listen((req, res) => {
// A server that takes the connection and never answers.
if (req.url === "/hang") return;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
const win = new BrowserWindow({ width: 1000, height: 700, show: true });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(APP));
win.show();
app.focus({ steal: true });
win.focus();
win.webContents.focus();
await wait(800);
if (!win.webContents.isFocused()) {
process.stdout.write(" SKIP this desktop would not give the test window the keyboard, so there is nothing to keep\n");
app.exit(0);
return;
}
const dbg = win.webContents.debugger;
dbg.attach("1.3");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message, sessionId) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message, ...(sessionId ? { sessionId } : {}) }) });
setTimeout(() => resolve({ timedOut: true }), 4000);
});
const ime = () => win.webContents.executeJavaScript("JSON.stringify({ ...window.__ime, value: document.getElementById('compose').value })").then(JSON.parse);
const compose = async (text) => {
await win.webContents.executeJavaScript("window.__ime.start = window.__ime.update = window.__ime.end = window.__ime.blur = 0; document.getElementById('compose').value = ''; document.getElementById('compose').focus()");
await dbg.sendCommand("Input.imeSetComposition", { text, selectionStart: text.length, selectionEnd: text.length });
await wait(150);
};
const keepsComposing = async (what) => {
const s = await ime();
check(`${what}: the composition is still open`, s.start >= 1 && s.end === 0, s);
};
protocol.receive({ conn: "c", open: "ime-live" });
await compose("nihao");
await keepsComposing("before the agent does anything");
const created = await send({ method: "Target.createTarget", params: { url: page.url } });
const targetId = created.result.targetId;
const session = (await send({ method: "Target.attachToTarget", params: { targetId } })).result.sessionId;
await wait(1500);
await keepsComposing("opening a page");
await compose("zhongwen");
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(600);
await keepsComposing("showing it");
await compose("ceshi");
await send({ method: "Page.navigate", params: { url: page.url + "/next" } }, session);
await wait(1500);
await keepsComposing("navigating it");
await compose("shuaxin");
await send({ method: "Page.reload" }, session);
await wait(1500);
await keepsComposing("reloading it");
await compose("dianji");
await send({ method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.dispatchMouseEvent", params: { type: "mouseReleased", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.insertText", params: { text: "x" } }, session);
await wait(500);
await keepsComposing("the agent clicking and typing in it");
await compose("guaqi");
// Not awaited: the protocol answers a navigation only when the page does.
void send({ method: "Page.navigate", params: { url: page.url + "/hang" } }, session);
await wait(500);
const out = !win.contentView.children.includes([...views.entries.values()][0].view);
check("a navigation that has just started holds the page out of the window", out);
await wait(LEAVE_LIMIT_MS + 1000);
const back = win.contentView.children.includes([...views.entries.values()][0].view);
check("one that never answers does not keep it out for good", back);
await keepsComposing("and the composition is still open while it hangs");
page.server.closeAllConnections?.();
page.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL ime-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+112 -2
View File
@@ -1,4 +1,4 @@
import { test } from "node:test";
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import path from "node:path";
@@ -2267,7 +2267,7 @@ test("a parked page is attached again when it is shown, and a page already on sc
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
views.entries.set("t1", { view, at: null });
views.entries.set("t1", { view, at: null, attached: true });
const rect = { x: 10, y: 20, width: 600, height: 400 };
views.show("t1", rect);
@@ -2571,3 +2571,113 @@ test("reclaim hands the keyboard back only when nobody holds it and the app page
test("a person's click is only a click for a moment", () => {
assert.ok(PERSON_MS > 0 && PERSON_MS < 2000);
});
function loadedViews() {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
let LEAVE_LIMIT_MS;
try {
({ BrowserViews, LEAVE_LIMIT_MS } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const calls = [];
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => true,
webContents: Object.assign(new EventEmitter(), { isFocused: () => true }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
return { views, view, calls, LEAVE_LIMIT_MS };
}
test("a page navigating while the app page holds the keyboard is held out of the window, then put back where it was", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: { x: 10, y: 20, width: 600, height: 400 }, attached: true };
views.leave(entry);
assert.deepEqual(calls, ["remove"]);
assert.equal(entry.attached, false);
views.settle(entry);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
assert.equal(entry.attached, true);
views.settle(entry);
assert.equal(calls.length, 3, "settling a page that is already attached does nothing");
});
test("a parked page comes back parked", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: null, attached: true };
views.leave(entry);
calls.length = 0;
views.settle(entry);
assert.equal(calls[0], "add");
assert.match(calls[1], /^bounds -?\d+$/);
assert.equal(entry.at, null);
});
test("a page is left where it is when the person, not the app page, holds the keyboard, or when it is not attached", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: null, attached: true };
views.keyboard.owner = "view";
views.leave(entry);
assert.deepEqual(calls, []);
assert.equal(entry.attached, true);
views.keyboard.owner = "app";
const loading = { view, at: null, attached: false };
views.leave(loading);
assert.deepEqual(calls, []);
});
test("a page still loading is not put back by a window event", () => {
const { views, view, calls } = loadedViews();
views.entries.set("t1", { view, at: null, attached: false });
views.reattach();
assert.deepEqual(calls, []);
});
test("a navigation that never finishes does not keep the page out of the window for good", () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { views, view, calls, LEAVE_LIMIT_MS } = loadedViews();
const entry = { view, at: { x: 10, y: 20, width: 600, height: 400 }, attached: true };
views.leave(entry);
assert.deepEqual(calls, ["remove"]);
mock.timers.tick(LEAVE_LIMIT_MS - 1);
assert.equal(entry.attached, false, "still out just before the limit");
mock.timers.tick(1);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
assert.equal(entry.attached, true);
mock.timers.tick(LEAVE_LIMIT_MS * 3);
assert.equal(calls.length, 3, "and it is put back once");
} finally {
mock.timers.reset();
}
});
test("a navigation that does finish cancels the limit, and a second one gets a fresh one", () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { views, view, calls, LEAVE_LIMIT_MS } = loadedViews();
const entry = { view, at: null, attached: true };
views.leave(entry);
mock.timers.tick(LEAVE_LIMIT_MS - 100);
views.settle(entry);
const back = calls.length;
mock.timers.tick(LEAVE_LIMIT_MS * 2);
assert.equal(calls.length, back, "the limit did not fire after the page came back");
views.leave(entry);
mock.timers.tick(LEAVE_LIMIT_MS);
assert.equal(entry.attached, true, "the second navigation was bounded by its own limit");
} finally {
mock.timers.reset();
}
});
+26 -17
View File
@@ -1,5 +1,5 @@
// Opening the branch menu while the pointer is still over the branch chip must not leave the hover card showing:
// the two surfaces would stack their shadows. Visibility under :hover is a styling question, so it is asked of a real browser.
// Workspace selection must stay reachable before and after a task, with no
// legacy hover card stacked over the open palette.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
@@ -9,22 +9,31 @@ const check = (name, ok, detail = "") => {
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
try {
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 } })).newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
const chip = page.locator('.studio-branch-pop .studio-branch').first();
await chip.waitFor();
const card = page.locator(".studio-branch-card").first();
const shown = () => card.evaluate((el) => getComputedStyle(el).visibility === "visible" && Number(getComputedStyle(el).opacity) > 0.5);
await chip.hover();
await page.waitForTimeout(400);
check("the hover card shows over a closed chip", await shown());
await chip.click();
await page.waitForTimeout(500);
check("the branch menu opened", (await chip.getAttribute("aria-expanded")) === "true");
check("the hover card is gone while the menu is open", !(await shown()));
for (const turns of [0, 2]) {
const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
const url = new URL(BASE);
url.searchParams.set("turns", String(turns));
await page.goto(url.href, { waitUntil: "networkidle" });
const chip = page.getByRole("button", { name: "配置工作目录", exact: true });
await chip.hover();
await chip.click();
const palette = page.getByRole("dialog", { name: "配置工作目录", exact: true });
await palette.waitFor();
check(`${turns} turns: the workspace palette opened`, await chip.getAttribute("aria-expanded") === "true");
const stacked = await page.locator(".studio-branch-card").evaluateAll((cards) => cards.some((card) => getComputedStyle(card).visibility === "visible" && Number(getComputedStyle(card).opacity) > 0.5));
check(`${turns} turns: no legacy hover card overlaps the palette`, !stacked);
await palette.getByRole("option", { name: "main", exact: true }).waitFor();
check(`${turns} turns: the current branch is selected`, await palette.getByRole("option", { name: "main", exact: true }).getAttribute("aria-selected") === "true");
const search = palette.getByRole("searchbox", { name: "搜索分支", exact: true });
await search.fill("studio");
check(`${turns} turns: search filters the branch choices`, await palette.getByRole("option").count() === 1 && await palette.getByRole("option", { name: "studio", exact: true }).isVisible());
await search.press("Escape");
await palette.waitFor({ state: "detached" });
check(`${turns} turns: closing returns focus to the workspace entry`, await chip.evaluate((element) => element === document.activeElement));
await page.close();
}
} finally {
await browser.close();
}
+5 -3
View File
@@ -1,4 +1,4 @@
// The session title sits right beside the project name in the title bar, at every width, and the
// The session title sits right beside the project and checkout in the title bar, at every width, and the
// project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question.
import { chromium } from "playwright";
@@ -22,9 +22,11 @@ try {
const range = document.createRange();
range.selectNodeContents(projEl);
const text = range.getBoundingClientRect();
return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) };
const tags = [...document.querySelectorAll(".chrome .crumb .isolab")].filter((el) => !el.hidden);
const edge = tags.at(-1)?.getBoundingClientRect().right ?? text.right;
return { gap: Math.round(sep.left - edge), titleRight: Math.round(title.right) };
});
check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title follows the project and checkout`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`);
const long = await page.evaluate(() => {
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
+25
View File
@@ -30,6 +30,7 @@ import "../src/styles/gitgraph.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
import "../src/styles/bring.css";
import "../src/styles/peerthreads.css";
import "../src/styles/line.css";
import "../src/styles/notice.css";
import "../src/styles/peers.css";
@@ -100,6 +101,21 @@ class BenchPort extends MockPort {
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
}
// ?badprice=1: DeepSeek's flash price is a card the vendor's table does not have,
// under a pinned display currency.
async providers() {
const all = await super.providers();
if (!query.has("badprice")) return all;
return all.map((p) => p.name === "deepseek"
? { ...p, officialPricing: true, modelPrices: { "deepseek-flash": { input: 0.3, output: 1.2, cacheHit: 0, currency: "USD", official: false } } }
: p);
}
async displayCurrency() {
const got = await super.displayCurrency();
return query.has("badprice") ? { ...got, mode: "CNY" as const, unconverted: [{ provider: "deepseek", model: "deepseek-flash", currency: "USD" }] } : got;
}
async browserTabs() {
if (!query.has("agentpage")) return super.browserTabs();
return [{ id: "t1", target: "view-a", url: "https://example.com/docs/install", title: "Install guide", active: true }];
@@ -161,6 +177,15 @@ class BenchPort extends MockPort {
}
async peers() {
if (query.has("peerthreads")) {
const states = ["waiting", "working", "replied", "declined"] as const;
return {
enabled: true,
peers: [],
held: [],
threads: states.map((state, i) => ({ key: `s-${i}`, title: ["文档整理", "发布脚本", "拾音", "旧迁移"][i], project: "rx-archive", sessionPath: `/sessions/w0-s${i}.jsonl`, state, since: "2026-10-16T10:00:00Z", messageId: `m-${i}` })),
};
}
if (!query.has("peers")) return super.peers();
return {
enabled: true,
@@ -0,0 +1,55 @@
// A session this one has written to carries one small icon for where the
// conversation stands: it has to sit inside its row, leave the title readable,
// say its state to a reader who cannot hover, and not select the row it is on.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=6&peerthreads=1";
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"]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".sessrow");
await page.waitForSelector(".peerthread");
const rows = await page.evaluate(() =>
[...document.querySelectorAll(".sessrow")].map((row) => {
const mark = row.querySelector(".peerthread");
const title = row.querySelector(".sesstitle");
const r = row.getBoundingClientRect();
const m = mark?.getBoundingClientRect();
const t = title?.getBoundingClientRect();
return {
path: row.getAttribute("data-target"),
state: mark?.getAttribute("data-state") ?? null,
label: mark?.getAttribute("aria-label") ?? null,
inside: m ? m.left >= r.left && m.right <= r.right && m.top >= r.top && m.bottom <= r.bottom : null,
clear: m && t ? m.left >= t.right - 1 : null,
size: m ? Math.round(m.width) : null,
};
}),
);
const marked = rows.filter((r) => r.state);
check(`${scheme}: four sessions carry a mark, the rest none`, marked.length === 4 && marked.map((r) => r.state).sort().join() === "declined,replied,waiting,working", marked.map((r) => r.state).join(","));
check(`${scheme}: each mark says its state in words`, marked.every((r) => r.label && r.label.length > 6));
check(`${scheme}: each mark sits inside its row`, marked.every((r) => r.inside));
check(`${scheme}: no mark covers the title`, marked.every((r) => r.clear));
check(`${scheme}: marks are small`, marked.every((r) => r.size >= 14 && r.size <= 22), marked.map((r) => r.size).join(","));
const before = await page.locator('.sessrow[aria-selected="true"]').count();
await page.locator(".peerthread[data-state=\"waiting\"]").first().focus();
check(`${scheme}: a mark takes keyboard focus`, await page.evaluate(() => document.activeElement?.classList.contains("peerthread")));
if (shotDir) await page.locator(".rail").screenshot({ path: `${shotDir}/rail-${scheme}.png` });
void before;
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+8
View File
@@ -95,6 +95,14 @@ try {
const replies = (text.match(new RegExp(`word${WORDS - 1}`, "g")) ?? []).length;
check("the open pane shows every reply it was sent while the network flapped", replies >= TURNS + 1, `${replies} of ${TURNS + 1} replies`);
check("the open pane returns to idle", done);
// The sidebar counts what is running from the page's own state of every pane,
// open or not; the Studio, the shared stream's metrics and the tree already agree
// that nothing runs, so a count that stays up is the page not recovering.
const live = () => page.evaluate(() => document.querySelector('.studio-session-segments button[data-value="live"] b')?.textContent?.trim() ?? "0");
const quiet = await until("the sidebar's running count to reach zero", async () => (await live()) === "0", 45_000).then(() => true, () => false);
const stuck = quiet ? "" : await page.evaluate(() => [...document.querySelectorAll(".sessrow[data-run]")].map((r) => `${r.textContent.trim().slice(0, 18)}:${r.getAttribute("data-run")}`).join(" | "));
check("the sidebar's running count settles to zero once every turn has finished", quiet, quiet ? "" : `reads ${await live()}; rows ${stuck}`);
if (process.env.RELAY_SHOT) {
await page.mouse.move(700, 700);
await page.waitForTimeout(2500);
+4
View File
@@ -69,6 +69,9 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.configure", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.prepare-cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.prepare-details", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.prepare-edit", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-search", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-navigate", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-select", kind: "view", target: "none", proof: "interaction" },
@@ -410,6 +413,7 @@ export const ACTIONS: UIAction[] = [
// Reached from the workspace tree and from the remote host tree, which are
// two surfaces for one intent; opening an existing session is not starting a
// new one, and the host asked differs.
{ id: "peer.thread-open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "session.open", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remote.open", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Asking a machine whether it answers. Nothing on either side changes, and
+2
View File
@@ -375,6 +375,8 @@ export const EN: Record<string, string> = {
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
"使用当前位置": "Stay where I am",
"隔离": "Isolated",
"原目录": "Local checkout",
"隔离目录": "Worktree",
"移除": "Remove",
"删除": "Delete",
"删除该会话": "Delete this session",
+78 -1
View File
@@ -1,10 +1,74 @@
// The turn starts here: drafting, attachments, run posture, and the two actions
// that remain available while an agent is live.
export const EN_COMPOSER: Record<string, string> = {
"工作目录准备": "Preparing workspace",
"正在提交创建请求": "Submitting creation request",
"正在检查起始分支": "Checking source branch",
"正在创建工作目录": "Creating workspace",
"正在检出已提交文件": "Checking out committed files",
"正在复制任务附件": "Copying task attachments",
"正在绑定工作目录": "Binding workspace",
"正在清理未完成目录": "Cleaning up incomplete workspace",
"正在中止并清理": "Stopping and cleaning up",
"工作目录已绑定": "Workspace bound",
"worktree 创建失败": "Could not create worktree",
"已取消创建": "Creation cancelled",
"工作目录已就绪": "Workspace ready",
"需要确认创建结果": "Confirm creation outcome",
"正在取消创建…": "Cancelling creation…",
"正在创建 worktree…": "Creating worktree…",
"任务尚未发送,输入和附件已为你保留。": "The task has not been sent. Your draft and attachments are retained.",
"任务尚未发送,可以继续编辑。": "The task has not been sent. You can continue editing.",
"目录已经创建成功,取消未能撤回目录切换。任务尚未发送。": "The workspace was already created and bound. Cancellation could not reverse it. The task has not been sent.",
"即将进入对话,自动开始任务。": "Opening the conversation and starting your task.",
"暂不能确认操作或清理结果,请先查看详情并核对。": "The operation or cleanup outcome is unconfirmed. Check the details before proceeding.",
"请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。": "Save your task and attachments, then reconcile the retained folder and task branch in a terminal. Once reconciled, close this conversation from its menu and start a new one in the source folder to retry.",
"正在中止操作并清理未完成的目录。": "Stopping the operation and cleaning up the incomplete workspace.",
"创建耗时较长,尚未收到完成确认。": "Creation is taking longer. Completion has not been confirmed.",
"准备完成后将自动开始任务。": "Your task will start automatically when the workspace is ready.",
"正在等待状态确认": "Waiting for confirmation",
"清理未完成": "Cleanup did not finish",
"创建未完成": "Creation did not finish",
"创建已中止,目录已清理": "Creation stopped and workspace cleaned up",
"已等待 {n} 秒": "Waiting {n}s",
"用户任务": "Your task",
"请查看附件": "See the attachments",
"附件({n})": "Attachments ({n})",
"待交接": "Pending handoff",
"已保留": "Retained",
"查看详情": "View details",
"收起详情": "Hide details",
"仍在等待创建完成。你可以继续等待、查看详情,或取消创建。": "Still waiting for creation. You can keep waiting, view details, or cancel.",
"操作编号": "Operation ID",
"当前状态": "Current state",
"再次核对": "Check again",
"重试创建": "Retry creation",
"返回编辑": "Back to editing",
"任务和附件正在交接…": "Handing over the task and attachments…",
"取消创建": "Cancel creation",
"正在取消…": "Cancelling…",
"准备工作目录": "Preparing workspace",
"创建失败": "Creation failed",
"创建已取消": "Creation cancelled",
"切换当前分支": "Switch current branch",
"创建请求不完整或与原操作不一致,请返回编辑后重试": "The creation request is incomplete or differs from the original. Return to editing and try again.",
"无法确认此前创建的结果,请先检查工作目录,避免重复创建": "The previous outcome is unconfirmed. Check the workspace before creating another.",
"已有工作目录正在准备或等待清理,请先确认该操作的结果": "A workspace is being prepared or awaiting cleanup. Reconcile that operation first.",
"工作目录创建失败,任务和附件已保留": "Workspace creation failed. The task and attachments are retained.",
"未完成目录尚未清理,暂不能重试创建,请查看详情": "The incomplete workspace could not be cleaned up. Check the details before retrying.",
"创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.",
"创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.",
"配置工作目录": "Configure workspace",
"独立 worktree · {branch}": "New worktree · {branch}",
"独立 worktree · {branch}": "Isolated worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}",
"新建 worktree": "New worktree",
"独立 worktree": "Isolated worktree",
"此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.",
"如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.",
"使用当前目录": "Use current folder",
"关闭工作目录配置": "Close workspace configuration",
"发送消息时开始创建。": " Creation starts when you send the message.",
"选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.",
"为此会话创建独立的代码目录": "Create a separate code folder for this session",
"起始分支": "Source branch",
"任务分支": "Task branch",
@@ -14,6 +78,19 @@ export const EN_COMPOSER: Record<string, string> = {
"选择起始分支": "Choose source branch",
"搜索分支": "Search branches",
"搜索分支…": "Search branches…",
"当前目录": "Current folder",
"查看完整路径": "Show full path",
"复制完整路径": "Copy full path",
"复制操作编号": "Copy operation ID",
"任务分支自动创建": "A task branch will be created automatically",
"搜索起始分支…": "Search source branches…",
"搜索本地分支…": "Search local branches…",
"分支读取失败": "Could not read branches",
"正在读取分支…": "Reading branches…",
"其他 worktree 占用": "Used by another worktree",
"起点": "Source",
"从已提交版本创建,任务和附件将在就绪后发送": "Create from the committed version; the task and attachments are sent when ready",
"选择后立即切换,同目录会话也会受影响": "Switches immediately; other sessions in this folder are affected",
"远程分支": "Remote branches",
"当前分支": "Current branch",
"没有匹配的分支": "No matching branches",
@@ -78,6 +78,15 @@ export const EN_KERNEL: Record<string, string> = {
"What this server launches changed since you enabled it; review the command and enable it again",
"这个服务没有被暂挡的工具定义": "This server has no held tool definitions",
"你看到的被挡定义已经变了,没有接受;请重新查看": "The held definitions changed since you saw them, so nothing was accepted; review them again",
"这个客户端不能登录 MCP 服务器": "This client cannot sign in to MCP servers",
"你没有同意前往该登录站点,已取消登录": "You did not agree to go to that sign-in site, so sign-in was cancelled",
"这个服务器不使用 OAuth 登录": "This server does not use OAuth sign-in",
"这个服务器已经在登录中": "A sign-in for this server is already open",
"服务器给出的登录页没有主机名,已停止": "The server's sign-in page names no host, so it was stopped",
"这台机器打不开浏览器": "This machine cannot open a browser",
"登录没有在时限内完成": "Sign-in was not completed in time",
"登录已取消": "Sign-in was cancelled",
"登录没有完成:{detail}": "Sign-in did not complete: {detail}",
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
// 待送达:条目、队列、这份存档各自会拒
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
@@ -97,6 +106,7 @@ export const EN_KERNEL: Record<string, string> = {
"该条没有正文": "That entry has no body",
"该会话的待送达已关闭": "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",
"该提交标识已被使用,且当时的内容不同": "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",
@@ -345,6 +355,9 @@ export const EN_KERNEL: Record<string, string> = {
"这条消息太长": "That message is too long",
"这条消息已经处理过了": "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",
"只有批准才能记住,拒绝只对这一条生效": "Only an approval can be remembered; a denial applies to this message alone",
"会话间消息的设置没能保存:{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",
"辅助提问没能完成,请重试": "The side question could not be completed; try again",
@@ -6,6 +6,10 @@ export const EN_SETTINGS: Record<string, string> = {
"来自 {from} 的消息": "Message from {from}",
"来自另一个会话「{from}」的文字,不是你写的。批准后才会交给这个会话的模型。": "Text from another session, \u201c{from}\u201d; you did not write it. It reaches this session\u2019s model only after you approve it.",
"批准并交给模型": "Approve and give it to the model",
"已发给「{title}」({project}),等对方的用户批准": "Sent to \"{title}\" ({project}); waiting for their user to approve",
"「{title}」({project})已放行,正在处理你的消息": "\"{title}\" ({project}) accepted it and is working on your message",
"「{title}」({project})回复了你": "\"{title}\" ({project}) replied to you",
"「{title}」({project})的用户拒绝了你的消息": "The user of \"{title}\" ({project}) declined your message",
"等你决定的会话间消息": "Session messages waiting for your decision",
"还有 {n} 条等你决定": "{n} more waiting for your decision",
"无法读取会话间消息的状态。": "Could not read the state of messages between sessions.",
@@ -440,6 +444,13 @@ export const EN_SETTINGS: Record<string, string> = {
"{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置价格": "Price by model",
"这个价格不在官方价目表里,选了人民币或美元显示时无法换算。": "This price is not in the vendor's price list, so it cannot be converted when a display currency is chosen.",
"缓存命中价是 0:命中的 token 会按免费计,费用会偏低。": "The cache-hit price is 0: cached tokens are counted as free, so cost reads too low.",
"{model} 恢复官方价": "{model} restore official price",
"恢复官方价": "Restore official price",
"{model}({currency})": "{model} ({currency})",
"、": ", ",
"这些模型的价格不在官方价目表里,无法换算成所选币种,费用只能按原币种显示:{models}。到「服务商」编辑该模型,点「恢复官方价」即可。": "These models' prices are not in the vendor's price list, so they cannot be converted to the chosen currency and cost shows in the original one: {models}. Edit the model under Providers and choose Restore official price.",
"每百万 token 的单价。留空表示未知,用量页会如实显示「未知」,而不是 0;填 0 表示免费。缓存命中价留空时按输入价计算。": "Price per million tokens. Left empty it is unknown, and the usage page says unknown rather than 0; enter 0 for free. An empty cache-hit price is charged at the input price.",
"{model} 的输入单价": "{model} input price",
"{model} 的输出单价": "{model} output price",
+20
View File
@@ -170,6 +170,13 @@ const SAID: Record<string, string> = {
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
"workspace.worktree_request_invalid": "创建请求不完整或与原操作不一致,请返回编辑后重试",
"workspace.worktree_operation_unknown": "无法确认此前创建的结果,请先检查工作目录,避免重复创建",
"workspace.worktree_busy": "已有工作目录正在准备或等待清理,请先确认该操作的结果",
"workspace.worktree_failed": "工作目录创建失败,任务和附件已保留",
"workspace.worktree_cleanup_failed": "未完成目录尚未清理,暂不能重试创建,请查看详情",
"workspace.worktree_cancelled": "创建已取消,任务尚未发送",
"workspace.worktree_timeout": "创建等待超时,任务和附件已保留,可查看详情后重试",
"settings.unknown_preset": "不存在该预设",
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
@@ -382,6 +389,15 @@ const SAID: Record<string, string> = {
"mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用",
"mcp.nothing_held": "这个服务没有被暂挡的工具定义",
"mcp.digest_mismatch": "你看到的被挡定义已经变了,没有接受;请重新查看",
"mcp.editing_disabled": "这个客户端不能登录 MCP 服务器",
"mcp.signin_declined": "你没有同意前往该登录站点,已取消登录",
"mcp.signin_ineligible": "这个服务器不使用 OAuth 登录",
"mcp.signin_busy": "这个服务器已经在登录中",
"mcp.signin_no_host": "服务器给出的登录页没有主机名,已停止",
"mcp.signin_no_browser": "这台机器打不开浏览器",
"mcp.signin_timed_out": "登录没有在时限内完成",
"mcp.signin_cancelled": "登录已取消",
"mcp.signin_failed": "登录没有完成:{detail}",
"activation.unavailable": "开关未能保存:其存储文件无法读取或写入",
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
@@ -403,6 +419,7 @@ const SAID: Record<string, string> = {
"inbox.closed": "该会话的待送达已关闭",
"inbox.schema_readonly": "该待送达由更高版本写入,当前版本只能读取",
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
"inbox.foreign_run": "这份待送达属于另一个运行,暂不能接管",
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
@@ -438,6 +455,9 @@ const SAID: Record<string, string> = {
"peer.too_large": "这条消息太长",
"peer.not_held": "这条消息已经处理过了",
"peer.send_failed": "会话间消息没能完成:{detail}",
"peer.invalid_project": "放行的项目必须是一个绝对路径的文件夹",
"peer.remember_denied": "只有批准才能记住,拒绝只对这一条生效",
"peer.settings_unwritable": "会话间消息的设置没能保存:{detail}",
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
+1
View File
@@ -14,6 +14,7 @@ import "./styles/tips.css";
import "./styles/skillpin.css";
import "./styles/waitnote.css";
import "./styles/bring.css";
import "./styles/peerthreads.css";
import "./styles/line.css";
import "./styles/notice.css";
import "./styles/peers.css";
@@ -137,10 +137,23 @@ export interface PeerHeld {
body: string;
}
export type PeerThreadState = "waiting" | "working" | "replied" | "declined";
export interface PeerThread {
key: string;
title: string;
project: string;
sessionPath?: string;
state: PeerThreadState;
since: string;
messageId: string;
}
export interface PeersState {
enabled: boolean;
peers: PeerView[];
held: PeerHeld[];
threads?: PeerThread[];
}
export interface PeerReceipt {
@@ -158,9 +171,17 @@ export interface AutoArchiveSettings {
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
// Which currency costs are shown in; the user file holds it.
export interface UnconvertedPrice {
provider: string;
model: string;
currency: string;
}
export interface DisplayCurrencySettings {
mode: DisplayCurrencyMode;
path: string;
// Models whose price cannot be shown in a pinned currency.
unconverted: UnconvertedPrice[];
}
// Whether the changed-files pill is drawn above the composer; the user file
+6 -1
View File
@@ -19,9 +19,14 @@ export const isUnderReview = (item: Pick<FeedbackItem, "status" | "underReview"
item.status === "received" && item.underReview === true && !item.statusUnavailable;
// Only these take a reply from the reporter; the service has the last word. A
// reply to a fixed report says the fix did not hold and reopens it.
// reply to a fixed report says the fix did not hold and reopens it. A received
// report takes one once it is released: one still under review reads as received
// too, and is not asked for anything.
export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "answered", "recorded", "in_progress", "fixed"];
export const canReplyTo = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
!item.statusUnavailable && (FEEDBACK_REPLYABLE.includes(item.status) || (item.status === "received" && !isUnderReview(item)));
export interface FeedbackEnvInfo {
version: string;
commit: string;
+7 -2
View File
@@ -35,6 +35,8 @@ export interface RuntimeView {
export interface TreeSession {
path: string;
workspaceRoot?: string;
isolated?: boolean;
name: string;
title?: string;
turns?: number;
@@ -98,8 +100,7 @@ export interface OpenWithTarget {
export interface TreeWorkspace {
root: string;
name: string;
// A delivery worktree carries the project's own folder name, so the sidebar
// needs this to tell the copy from the original.
workspaceRoots?: string[];
isolated?: boolean;
missing?: boolean;
open?: boolean;
@@ -117,6 +118,10 @@ export interface TreeWorkspace {
sessions: TreeSession[];
}
export function workspaceContains(workspace: TreeWorkspace, root: string | undefined): boolean {
return root !== undefined && (workspace.root === root || !!workspace.workspaceRoots?.includes(root));
}
// How much of each folder tree() builds: `limit` for all of them (0 is every
// row), `limits` overriding it per folder root.
export type TreeScope = "" | "active" | "archived";
+6 -1
View File
@@ -4,7 +4,7 @@ import { approvalKind } from "./mock_approval";
import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources } from "./mock_workspace";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
@@ -238,6 +238,11 @@ export class MockPort extends MockWorkspace implements AgentPort {
return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch);
}
private preparations = new MockPreparations();
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) { return this.preparations.prepare(id, selection, async () => { await this.isolateWorkspace(selection); return (await this.status()).workspaceRoot ?? ""; }); }
worktreePreparation(id: string) { return this.preparations.read(id); }
cancelWorktreePreparation(id: string) { return this.preparations.read(id); }
async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) {
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
this.branchState.branch = "reasonix/delivery-demo";
@@ -173,7 +173,7 @@ export class MockBoundary extends MockShell {
protected peerState: PeersState = { enabled: false, peers: [], held: [] };
async peers(): Promise<PeersState> {
return { ...this.peerState, peers: [...this.peerState.peers], held: [...this.peerState.held] };
return { ...this.peerState, peers: [...this.peerState.peers], held: [...this.peerState.held], threads: [...(this.peerState.threads ?? [])] };
}
async sendPeerMessage(to: string, text: string): Promise<PeerReceipt> {
@@ -198,7 +198,7 @@ export class MockBoundary extends MockShell {
return { ...this.archive };
}
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml" };
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml", unconverted: [] };
async displayCurrency(): Promise<DisplayCurrencySettings> {
return { ...this.currency };
@@ -1,3 +1,4 @@
import { reason } from "../i18n/kernel";
import { HttpError } from "./http_error";
import type { KeptText } from "./attachment";
import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
@@ -118,3 +119,28 @@ export class MockWorkspace extends MockFeedback {
}
}
export class MockPreparations {
private items = new Map<string, import("./workspace").WorktreePreparation>();
async prepare(id: string, selection: import("./workspace").WorktreeSelection, create: () => Promise<string>) {
const found = this.items.get(id);
if (found) return found;
const value: import("./workspace").WorktreePreparation = { id, phase: "validating", sourceRoot: selection.sourceRoot, ref: selection.ref, startedAt: Date.now() };
try {
value.workspaceRoot = await create();
value.phase = "ready";
value.branch = "reasonix/demo";
} catch (error) {
value.phase = "failed";
value.code = "workspace.worktree_failed";
value.detail = reason(error);
}
this.items.set(id, value);
return value;
}
async read(id: string) {
const value = this.items.get(id);
if (!value) throw new Error("unknown worktree operation");
return value;
}
}
+3
View File
@@ -453,6 +453,9 @@ export interface AgentPort {
setWorkspace(path: string): Promise<void>;
isolateWorkspace(selection?: import("./workspace").WorktreeSelection): Promise<void>;
worktreeSources(): Promise<import("./workspace").WorktreeSources>;
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection): Promise<import("./workspace").WorktreePreparation>;
worktreePreparation(id: string): Promise<import("./workspace").WorktreePreparation>;
cancelWorktreePreparation(id: string): Promise<import("./workspace").WorktreePreparation>;
// The native folder picker, or "" where there is none (a browser tab) or
// when the user cancelled. Only the shell can open one.
// The native folder picker: the chosen path, "" when the user cancelled, and
@@ -63,6 +63,8 @@ export interface ProviderEntry {
// Per model: the price per million tokens it declares for itself. A model
// with none is absent, which means unknown, not zero.
modelPrices?: Record<string, ModelRate>;
// A vendor's own endpoint: its table can say whether a stored price is its own.
officialPricing?: boolean;
// How the latest plain test of this saved entry ended, in this kernel process.
// Absent when none ran or the entry changed since.
lastCheck?: { at: number; ok: boolean; code?: string };
@@ -276,6 +278,8 @@ export interface ModelRate {
output: number;
cacheHit: number;
currency: string;
// True when the card is one the vendor's table has; sent by the kernel, never back.
official?: boolean;
}
// One model's own context window and output cap; an absent field inherits.
+13 -80
View File
@@ -1,6 +1,6 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -66,38 +66,6 @@ export class SsePort extends SseWorkspace implements AgentPort {
return ((await res.json()) as { text: string }).text;
}
async exportPlugin(name: string): Promise<PluginExport> {
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
credentials: "same-origin",
});
if (!res.ok) await SsePort.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
const blob = await res.blob();
// A shell with a save dialog puts the archive where it is asked to; the
// anchor below is the browser's own way and the only one a tab has.
const saved = await host().saveBytes(`${name}.zip`, new Uint8Array(await blob.arrayBuffer()));
if (saved !== null) return { required, savedTo: saved || undefined };
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.zip`;
a.click();
URL.revokeObjectURL(url);
return { required };
}
async dryRunHook(h: HookEntry): Promise<HookDryRun> {
const res = await fetch(this.base + "/hooks/dry-run", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ event: h.event, match: h.match, command: h.command, timeout: h.timeout, cwd: h.cwd }),
});
const body = (await res.json().catch(() => ({}))) as HookDryRun & { error?: string };
if (!res.ok) throw new Error(body.error || `/hooks/dry-run: ${res.status}`);
return body;
}
usage(days: number, source?: string): Promise<UsageReport>;
usage(query: UsageQuery): Promise<UsageReport>;
usage(query: number | UsageQuery, source?: string) {
@@ -139,53 +107,6 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post("/memory/restore", { name, revision });
}
async trustMcp(name: string, digest: string) {
const res = await fetch(this.base + "/mcp/trust", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ name, digest }),
});
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string };
if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`);
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error };
}
async reconnectMcp(name: string) {
const res = await fetch(this.base + "/mcp/reconnect", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ name }),
});
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number; error?: string };
if (!res.ok && !body.error) throw new Error(`/mcp/reconnect: ${res.status}`);
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: body.tools, error: body.error };
}
async parseMcp(input: string): Promise<McpDraft> {
const res = await fetch(this.base + "/mcp/parse", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ input }),
});
const body = (await res.json().catch(() => ({}))) as McpDraft & { error?: string };
if (!res.ok) throw new Error(body.error || `/mcp/parse: ${res.status}`);
return { servers: body.servers ?? [], risks: body.risks ?? [] };
}
async probeProvider(baseUrl: string, apiKey: string): Promise<ProviderProbe> {
const res = await fetch(this.base + "/providers/probe", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ baseUrl, apiKey }),
});
const text = await res.text();
if (!res.ok) throw new Error(text.trim() || `/providers/probe: ${res.status}`);
return JSON.parse(text) as ProviderProbe;
}
async welcomeSeen(): Promise<boolean> {
const res = await this.get<{ seen: boolean }>("/welcome");
return !!res.seen;
@@ -407,6 +328,18 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get<import("./workspace").WorktreeSources>("/workspace/worktree-sources");
}
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) {
return this.post0<import("./workspace").WorktreePreparation>("/workspace/prepare", { id, selection });
}
worktreePreparation(id: string) {
return this.get<import("./workspace").WorktreePreparation>(`/workspace/prepare/${encodeURIComponent(id)}`);
}
cancelWorktreePreparation(id: string) {
return this.post0<import("./workspace").WorktreePreparation>(`/workspace/prepare/${encodeURIComponent(id)}/cancel`);
}
async openInEditor(): Promise<{ editor: string; root: string }> {
return this.post0<{ editor: string; root: string }>("/workspace/editor");
}
+6 -1
View File
@@ -107,7 +107,12 @@ export class SseExtensions extends SseLook {
});
if (!res.ok) await SseExtensions.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
const url = URL.createObjectURL(await res.blob());
const blob = await res.blob();
// A shell with a save dialog puts the archive where it is asked to; the
// anchor below is the browser's own way and the only one a tab has.
const saved = await host().saveBytes(`${name}.zip`, new Uint8Array(await blob.arrayBuffer()));
if (saved !== null) return { required, savedTo: saved || undefined };
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.zip`;
@@ -0,0 +1,37 @@
import { afterEach, expect, it, vi } from "vitest";
import { HttpError, type HookEntry } from "./port";
import { SsePort } from "./sse";
afterEach(() => vi.unstubAllGlobals());
// The real port is a chain of faces. A method an outer face declares again hides
// the inner one, and a fix made in the inner one then never runs.
it("no face of the real port redeclares a method of a face it extends", () => {
const repeated: string[] = [];
for (let face = SsePort.prototype; face !== Object.prototype; face = Object.getPrototypeOf(face)) {
for (const name of Object.getOwnPropertyNames(face)) {
if (name === "constructor") continue;
for (let below = Object.getPrototypeOf(face); below !== Object.prototype; below = Object.getPrototypeOf(below)) {
if (Object.hasOwn(below, name)) repeated.push(`${face.constructor.name}.${name} hides ${below.constructor.name}.${name}`);
}
}
}
expect(repeated).toEqual([]);
});
function refusing(status: number, refusal: { code: string; error: string }) {
const body = JSON.stringify(refusal);
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status, text: async () => body, json: async () => refusal })));
}
const hook: HookEntry = { event: "PreToolUse", command: "true", scope: "project" };
it.each([
["probeProvider", 401, "provider.probe.unauthorized", (p: SsePort) => p.probeProvider("https://api.example.test", "sk-test")],
["dryRunHook", 400, "hooks.dry_run_failed", (p: SsePort) => p.dryRunHook(hook)],
])("%s hands the refusal's code to the reader", async (_, status, code, call) => {
refusing(status, { code, error: "refused" });
const err = await call(new SsePort("", "r1")).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HttpError);
expect((err as HttpError).reason?.code).toBe(code);
});
+3 -15
View File
@@ -31,21 +31,9 @@ export class SseLook extends SseHttp {
// cross-site form from posting here at all.
async uploadWallpaper(blob: Blob) {
guardUpload(blob.size, UPLOAD_LIMIT_MB.wallpaper, tooLarge("wallpaper.too_large", "limit"));
const res = await fetch(this.base + "/appearance/wallpaper", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ mime: blob.type, data: await base64Of(blob) }),
});
const body = (await res.json().catch(() => ({}))) as Appearance & { error?: string };
if (!res.ok) throw new Error(body.error || `/appearance/wallpaper: ${res.status}`);
return body;
return this.post0<Appearance>("/appearance/wallpaper", { mime: blob.type, data: await base64Of(blob) });
}
async clearWallpaper() {
const res = await fetch(this.base + "/appearance/wallpaper", {
method: "DELETE",
credentials: "same-origin",
});
if (!res.ok) throw new Error(`/appearance/wallpaper: ${res.status}`);
clearWallpaper() {
return this.del("/appearance/wallpaper");
}
}
+2 -10
View File
@@ -7,16 +7,8 @@ export class SseNetwork extends SseHook {
network() {
return this.get<NetworkSettings>("/network");
}
async saveNetwork(settings: NetworkSettings, password: string, clearPassword: boolean) {
const res = await fetch(this.base + "/network", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ ...settings, password, clearPassword }),
});
const body = (await res.json().catch(() => ({}))) as NetworkSettings & { error?: string };
if (!res.ok) throw new Error(body.error || `/network: ${res.status}`);
return body;
saveNetwork(settings: NetworkSettings, password: string, clearPassword: boolean) {
return this.post0<NetworkSettings>("/network", { ...settings, password, clearPassword });
}
async diagnoseNetwork() {
const r = await this.post0<{ probes?: NetworkProbe[] }>("/network/diagnose");
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from "vitest";
import { HttpError, type NetworkSettings } from "./port";
import { SsePort } from "./sse";
afterEach(() => vi.unstubAllGlobals());
function refusing(status: number, refusal: { code: string; error: string }) {
const body = JSON.stringify(refusal);
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status, text: async () => body, json: async () => refusal })));
}
const proxy: NetworkSettings = { mode: "custom", effective: "" };
// The settings panels word a refusal from its code; a port that throws only the
// kernel's text leaves them printing that fallback in whatever language it is.
it.each([
["saveNetwork", 400, "network.rejected", (p: SsePort) => p.saveNetwork(proxy, "", false)],
["uploadWallpaper", 422, "wallpaper.unsupported_type", (p: SsePort) => p.uploadWallpaper(new Blob(["BM"], { type: "image/bmp" }))],
["clearWallpaper", 403, "loopback.unauthorized", (p: SsePort) => p.clearWallpaper()],
])("%s hands the refusal's code to the reader", async (_, status, code, call) => {
refusing(status, { code, error: "refused" });
const err = await call(new SsePort("", "r1")).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HttpError);
expect((err as HttpError).reason?.code).toBe(code);
});
@@ -132,6 +132,18 @@ export interface WorktreeSelection {
attachments?: string[];
}
export interface WorktreePreparation {
id: string;
phase: string;
sourceRoot: string;
ref: string;
startedAt: number;
branch?: string;
workspaceRoot?: string;
code?: string;
detail?: string;
}
export interface CommitFile {
path: string;
// git raw status letter: "A", "M", "D", "T".
@@ -102,6 +102,8 @@ rules are written in.
- The separator between crumbs is a written character, not the 1px hairline
`.sep`. Sharing that name puts the glyph in a 1px-wide box with a background,
and the strokes spill out of it.
- Project and checkout context shrink together inside `.crumb-context`; the
conversation title keeps its four-em floor even when multiple labels are visible.
- The desktop sidebar's divider owns collapse, expansion and width adjustment.
- Its grip remains visible when collapsed; the separator supports Enter/Space for toggling, arrow keys for sizing,
and Home or a double click for restoring the default width. The open width survives collapse and reload.
+2 -1
View File
@@ -201,7 +201,7 @@
.btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); }
.picker { position: relative; flex: none }
.isolab { font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: var(--track-cap); color: var(--ok-ink); padding: 1.5px 6px; border-radius: var(--r-pill); background: color-mix(in srgb, var(--ok) 13%, transparent) }
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: var(--track-cap); color: var(--ok-ink); padding: 1.5px 6px; border-radius: var(--r-pill); background: color-mix(in srgb, var(--ok) 13%, transparent) }
.isolab[hidden] { display: none }
.hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) }
@@ -3111,6 +3111,7 @@
.mp-row { grid-template-columns: repeat(4, minmax(0, 1fr)) }
.mp-row .me-name { grid-column: 1 / -1 }
.mp-row .why { grid-column: 1 / -1 }
.mp-foreign { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px }
.mp-row select { padding: 6px 8px; font-size: var(--fs-meta) }
.ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
.ml-row input::placeholder { color: var(--ghost) }
@@ -6,6 +6,8 @@
.studio-model-control .model-picker .nm { max-width: none; }
}
.chrome .r-fold { display: contents; }
.chrome .crumb-context { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
.chrome .crumb-context .isolab { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
[data-fold~="bar"] .chrome .r-fold { display: none; position: absolute; top: calc(100% + 6px); right: 12px; z-index: 80; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); box-shadow: 0 12px 32px -14px #000; }
[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; }
[data-fold~="bar"] .tab { flex: 0 1 auto; min-width: 0; padding-inline: 6px; overflow: hidden; text-overflow: ellipsis; }
@@ -0,0 +1,20 @@
.peerthread {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-left: 4px;
padding: 0;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.peerthread svg { width: 13px; height: 13px; }
.peerthread:hover { background: var(--hair); color: var(--text); }
.peerthread:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.peerthread[data-state="replied"] { color: var(--accent); }
.peerthread[data-state="declined"] { color: var(--err); }
@@ -19,3 +19,20 @@ it.each(Object.entries(CSS))("excludes disabled navigation from every hover over
expect(stylesheet.match(/\.prefs-nav button:hover(?:\s|\{)/)).toBeNull();
expect(stylesheet.match(/\.prefs-nav button:not\(:disabled\):hover\s*\{[^}]*background:/)).not.toBeNull();
});
it("keeps the selected settings section's fill under the pointer in the light theme", () => {
const studio = CSS["./studio.css"];
const hover = studio.lastIndexOf(':root[data-theme="light"] .prefs-nav button:not(:disabled):hover');
const selected = studio.match(/:root\[data-theme="light"\] \.prefs-nav button\[aria-selected="true"\]:not\(:disabled\):hover\s*\{([^}]*)\}/);
expect(hover).toBeGreaterThan(-1);
expect(selected).not.toBeNull();
expect(selected![1]).toMatch(/background:\s*var\(--border\)/);
expect(studio.indexOf(selected![0])).toBeGreaterThan(hover);
});
it("keeps the selected settings section's icon ink under the pointer", () => {
const studio = CSS["./studio.css"];
const rule = studio.match(/\.prefs-nav button\[aria-selected="true"\]:not\(:disabled\):hover svg\s*\{([^}]*)\}/);
expect(rule).not.toBeNull();
expect(rule![1]).toMatch(/color:\s*var\(--text\)/);
});
@@ -3384,6 +3384,12 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
background: var(--overlay);
color: var(--text);
}
:root[data-theme="light"] .prefs-nav button[aria-selected="true"]:not(:disabled):hover {
background: var(--border);
}
.prefs-nav button[aria-selected="true"]:not(:disabled):hover svg {
color: var(--text);
}
/* Run analysis is the readable front door to the existing trajectory. Raw
events and the execution graph stay in Runtime details; this surface answers
@@ -0,0 +1,16 @@
.workspace-path { display: flex; align-items: flex-start; gap: 6px; min-width: 0; color: var(--muted); font: var(--fs-meta)/1.5 var(--ui) }
.workspace-path details { flex: 1; min-width: 0 }
.workspace-path summary { display: flex; align-items: center; gap: 8px; min-height: 38px; padding: 6px 4px; border-radius: 6px; list-style: none; cursor: pointer }
.workspace-path summary::-webkit-details-marker { display: none }
.workspace-path summary > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis }
.workspace-path summary > .studio-icon { width: 17px; height: 17px; flex-shrink: 0 }
.workspace-path details[open] summary > .studio-icon:last-child { transform: rotate(180deg) }
.workspace-path summary:hover { color: var(--text); background: var(--float-hi) }
.workspace-path code { display: block; padding: 6px 4px 10px; overflow-wrap: anywhere; user-select: text; color: var(--text); font: var(--fs-micro)/1.7 var(--mono) }
.workspace-path .workspace-path-copy { display: grid; place-items: center; width: 36px; min-width: 36px; height: 38px; margin: 0; padding: 6px; color: var(--muted); background: none; border: 0; border-radius: 6px; cursor: pointer }
.workspace-path .workspace-path-copy:hover { color: var(--text); background: var(--float-hi) }
.workspace-path .workspace-path-copy .studio-icon { width: 18px; height: 18px }
.workspace-path :is(summary, button):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
.workspace-branch-value { display: flex; min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap }
.workspace-branch-value > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis }
.workspace-branch-value > span:last-child { flex-shrink: 0 }
@@ -0,0 +1,50 @@
.worktree-stage-host { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain }
.worktree-setup { width: min(580px, calc(100% - 48px)); margin: 0 auto; padding: clamp(24px, 7vh, 78px) 0 28px; color: var(--text); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) }
.worktree-setup-context { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 24px }
.worktree-setup-context > .studio-icon { color: var(--brand); width: 24px; height: 24px; flex-shrink: 0 }
.worktree-setup-context strong { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-lead); font-weight: var(--w-med) }
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-micro) }
.worktree-setup h1 { margin: 0; font: var(--w-med) var(--fs-display)/1.3 var(--ui); letter-spacing: -.025em }
.worktree-setup-description { margin: 10px 0 22px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.7 }
.worktree-setup-phase { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 13px 14px; border-radius: 9px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-ui) }
.worktree-setup-phase > span:first-child { display: flex; align-items: center; gap: 10px; min-width: 0 }
.worktree-setup-elapsed { white-space: nowrap; color: var(--muted); font-size: var(--fs-micro) }
.worktree-setup-phase .studio-icon { width: 19px; height: 19px; flex-shrink: 0 }
.worktree-spinner { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; border: 2px solid var(--hair); border-top-color: var(--brand); border-right-color: var(--brand); border-radius: 50%; animation: worktree-spin 1.2s linear infinite }
.worktree-setup-branches { padding: 20px 0; border-bottom: 1px solid var(--hair) }
.worktree-setup dl { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; margin: 0; font-size: var(--fs-meta) }
.worktree-setup dt { color: var(--muted) }
.worktree-setup dd { display: flex; align-items: center; gap: 8px; margin: 0; overflow-wrap: anywhere; min-width: 0 }
.worktree-setup dd > .studio-icon { width: 16px; height: 16px; color: var(--muted); flex-shrink: 0 }
.worktree-setup-draft { padding: 20px 0; border-bottom: 1px solid var(--hair) }
.worktree-setup-label { color: var(--muted); font-size: var(--fs-meta) }
.worktree-setup-task { margin: 7px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui); max-height: 150px; overflow: auto }
.worktree-setup-files { margin-top: 18px }
.worktree-setup-files > div { display: flex; align-items: center; gap: 9px; padding: 8px 0; font-size: var(--fs-meta) }
.worktree-setup-files .studio-icon { width: 18px; height: 18px; flex-shrink: 0; color: var(--muted) }
.worktree-setup-files > div > span { overflow-wrap: anywhere; min-width: 0 }
.worktree-setup-files small { margin-left: auto; color: var(--muted); font-size: var(--fs-micro); white-space: nowrap }
.worktree-setup-location { padding: 8px 0; border-bottom: 1px solid var(--hair) }
.worktree-setup-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; position: sticky; bottom: 0; z-index: 1; padding: 16px 0 4px; background: var(--surface) }
.worktree-setup-actions > div { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 9px }
.worktree-setup-actions .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 38px; padding: 8px 14px; border-radius: 8px; font-size: var(--fs-ui) }
.worktree-setup-actions .studio-icon { width: 17px; height: 17px; flex-shrink: 0 }
.worktree-setup-detail-button { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; padding: 8px 0; border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; font-size: var(--fs-meta); cursor: pointer }
.worktree-setup-detail-button:hover { color: var(--text) }
.worktree-setup-handoff { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: var(--fs-meta) }
.worktree-setup-slow { margin: 10px 0 0; color: var(--muted); font-size: var(--fs-meta); line-height: 1.7 }
.worktree-setup-details { padding: 16px 0 0; font-size: var(--fs-micro) }
.worktree-setup-details dd { font-family: var(--mono); font-size: var(--fs-micro) }
.worktree-setup-details .copy { padding: 3px; flex-shrink: 0 }
.worktree-setup-details .copy .studio-icon { width: 14px; height: 14px }
.worktree-setup-details pre { padding: 12px; background: var(--raised); border-radius: 7px; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fs-micro)/1.7 var(--mono) }
.worktree-setup[data-phase="failed"] .worktree-setup-phase, .worktree-setup[data-phase="cleanup_failed"] .worktree-setup-phase { background: color-mix(in srgb, var(--err) 8%, transparent); color: var(--err) }
.worktree-setup[data-phase="ready"] .worktree-setup-phase, .worktree-setup[data-phase="retained"] .worktree-setup-phase { background: color-mix(in srgb, var(--ok) 8%, transparent); color: var(--ok) }
.worktree-setup[data-phase="cancelled"] .worktree-setup-phase { background: var(--raised); color: var(--muted) }
.worktree-setup :is(button, summary):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
.dropzone[data-worktree-setup] .fmain, .dropzone[data-worktree-setup] .shots, .dropzone[data-worktree-setup] .composeguide { display: none }
.dropzone[data-worktree-setup] { opacity: .65 }
@keyframes worktree-spin { to { transform: rotate(360deg) } }
@media (max-height: 800px) { .worktree-setup { padding-top: 20px } .worktree-setup-context { margin-bottom: 15px } .worktree-setup-description { margin-bottom: 16px } .worktree-setup-branches, .worktree-setup-draft { padding-block: 15px } }
@media (max-width: 600px) { .worktree-setup { width: calc(100% - 28px); padding-top: 22px } .worktree-setup-description { font-size: var(--fs-ui) } .worktree-setup-phase { flex-wrap: wrap; gap: 8px; font-size: var(--fs-meta) } .worktree-setup dl { grid-template-columns: 70px minmax(0, 1fr); gap: 10px } .worktree-setup-actions { flex-wrap: wrap } .worktree-setup-actions > div { margin-left: auto } }
@media (prefers-reduced-motion: reduce) { .worktree-spinner { animation: none } }
+37 -25
View File
@@ -1,28 +1,40 @@
.worktree-trigger .lb { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
:root .compose .mode.worktree-trigger[data-on] { color: var(--brand); background: var(--brand-wash) }
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--brand) }
.worktree-panel { position: fixed; z-index: 1000; width: min(370px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 18px; border: 1px solid var(--hair); border-radius: 20px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: calc(14px * var(--fs-scale, 1))/1.5 var(--ui) }
.worktree-heading { color: var(--muted); margin-bottom: 16px; font-size: calc(13px * var(--fs-scale, 1)) }
.worktree-toggle { display: flex; align-items: center; gap: 12px; padding: 4px 0 18px; border-bottom: 1px solid var(--hair); margin-bottom: 8px }
.worktree-toggle > div { flex: 1; min-width: 0 }
.worktree-toggle span { font-size: calc(16px * var(--fs-scale, 1)) }
.worktree-toggle small { display: block; color: var(--muted); font-size: calc(12px * var(--fs-scale, 1)); margin-top: 4px }
.worktree-panel .studio-icon { width: 20px; height: 20px; flex-shrink: 0 }
.worktree-panel .sw { flex-shrink: 0; width: 42px; height: 24px; border-radius: 14px }
.worktree-panel .sw i { width: 18px; height: 18px; margin-left: 3px }
.worktree-panel .sw[aria-checked="true"] { background: var(--text) }
.worktree-panel .sw[aria-checked="true"] i { transform: translateX(18px) }
.worktree-row { display: flex; align-items: center; gap: 12px; min-height: 48px; width: 100%; padding: 8px 0; border: 0; border-radius: 6px; background: none; color: inherit; font: inherit; text-align: left }
button.worktree-row:hover { background: var(--float-hi) }
.worktree-value { color: var(--muted); margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 170px }
.worktree-note { border-top: 1px solid var(--hair); padding-top: 14px; margin: 6px 0 0; color: var(--muted); font-size: calc(12px * var(--fs-scale, 1)) }
.worktree-panel input { width: 100%; border: 1px solid var(--hair); border-radius: 8px; padding: 9px 12px; background: var(--raised); color: inherit; font: inherit }
.worktree-sources { max-height: 260px; overflow: auto; margin: 10px 0 }
.worktree-sources > div > small { display: block; padding: 8px; color: var(--muted); font-size: calc(12px * var(--fs-scale, 1)) }
.worktree-sources button { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 38px; border: 0; border-radius: 6px; background: none; color: inherit; padding: 8px; text-align: left; font: inherit }
.worktree-sources button span { overflow-wrap: anywhere; flex: 1 }
.worktree-sources button small { color: var(--muted); flex-shrink: 0 }
.worktree-sources button:hover { background: var(--float-hi) }
.worktree-sources button[aria-selected="true"] { color: var(--brand); background: var(--brand-wash) }
.worktree-back { border: 0; background: none; color: var(--muted); padding: 6px; font: inherit }
.worktree-panel :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
.workspace-palette { position: fixed; z-index: 1000; width: min(400px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 16px; border: 1px solid var(--hair); border-radius: 16px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui) }
.workspace-palette .studio-icon { width: 18px; height: 18px; flex-shrink: 0 }
.workspace-palette-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--hair); font-size: var(--fs-ui); font-weight: var(--w-med) }
.workspace-palette-close { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: var(--muted); background: none; cursor: pointer }
.workspace-palette-close:hover { background: var(--float-hi); color: var(--text) }
.workspace-palette-identity { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 16px }
.workspace-palette-identity > .studio-icon { width: 23px; height: 23px; color: var(--brand) }
.workspace-palette-identity > strong { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: var(--w-med) }
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--brand); background: var(--brand-wash); font-size: var(--fs-micro) }
.worktree-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; margin: 0 0 14px; background: var(--raised); border: 1px solid var(--hair); border-radius: 9px }
.worktree-modes button { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 6px; min-height: 36px; padding: 6px; color: var(--muted); background: none; font: inherit; font-size: var(--fs-meta); cursor: pointer }
.worktree-modes button[aria-pressed="true"] { background: var(--float); border-color: var(--hair); color: var(--text) }
.workspace-palette-base { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin-bottom: 12px; font-size: var(--fs-meta) }
.workspace-palette-base > span, .workspace-palette-base > small { color: var(--muted) }
.workspace-palette-base > strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-weight: var(--w-med) }
.workspace-palette-base > small { grid-column: 1 / -1; font-size: var(--fs-micro) }
.workspace-palette-search { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 11px; margin-bottom: 10px; border: 1px solid var(--hair); border-radius: 9px; color: var(--muted); background: var(--raised) }
.workspace-palette-search:focus-within { border-color: var(--focus) }
.workspace-palette-search input { flex: 1; min-width: 0; width: 100%; border: 0; padding: 0; border-radius: 0; background: transparent; color: var(--text); font: inherit; outline: none }
.worktree-sources { max-height: 250px; overflow: auto; overscroll-behavior: contain }
.workspace-palette-group { display: block; padding: 8px; color: var(--muted); font-size: var(--fs-micro) }
.worktree-sources button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; background: none; color: var(--text); padding: 10px 8px; text-align: left; font: inherit; cursor: pointer }
.worktree-sources > div > button:last-child { border-bottom-color: transparent }
.worktree-sources button .workspace-palette-branch { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-ui) }
.worktree-sources button small { color: var(--muted); font-size: var(--fs-micro); white-space: nowrap }
.worktree-sources button > .studio-icon:last-child { width: 14px; height: 14px; color: var(--muted) }
.worktree-sources button:not(:disabled):hover { background: var(--float-hi); border-radius: 7px }
.worktree-sources button[aria-selected="true"] { color: var(--brand); background: var(--brand-wash); border-radius: 8px; border-bottom-color: transparent }
.worktree-sources button[aria-selected="true"] > .studio-icon:last-child { color: var(--brand) }
.worktree-sources button[aria-selected="true"] small { color: var(--brand); background: var(--brand-wash); padding: 2px 5px; border-radius: 4px }
.worktree-sources button:disabled { color: var(--muted); cursor: not-allowed }
.workspace-palette-note { margin: 12px 0 14px; color: var(--muted); font-size: var(--fs-micro); line-height: 1.6 }
.workspace-palette-footer { padding-top: 8px; border-top: 1px solid var(--hair) }
.workspace-palette-empty { padding: 18px 8px; text-align: center; color: var(--muted); font-size: var(--fs-meta) }
.workspace-palette-empty p { margin: 0 0 10px }
.workspace-palette :is(button, summary):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
@media (max-width: 480px) { .workspace-palette { padding: 12px } .workspace-palette-identity { gap: 7px } .worktree-sources button { gap: 7px; padding-inline: 5px } .worktree-sources button small { max-width: 92px; white-space: normal; font-size: var(--fs-micro) } }
+8 -13
View File
@@ -9,9 +9,10 @@ import { reason } from "../i18n/kernel";
import { t } from "../i18n";
import type { AccountState, AgentPort, ProviderSetup } from "../port/port";
import type { HubPort, RuntimeView } from "../port/hub";
import { workspaceContains } from "../port/hub";
import { Chrome } from "./Chrome";
import { RailToggle } from "./RailToggle";
import { useLaunchHealth } from "./launchhealth";
import { useFirstRunRead, useLaunchHealth } from "./launchhealth";
import { Nav } from "./Nav";
import { useTreeWindow, type TreePaging } from "./treewindow";
import { useNavRail } from "./navrail";
@@ -44,6 +45,7 @@ import { usePaint } from "./paint";
import { useZoomKeys } from "./zoomkeys";
import { rememberActivePane, savedActivePane } from "./activepane";
import { Sidebar } from "./Sidebar";
import { PeerThreadsProvider } from "./peerthreads";
import { Sky } from "./Sky";import { useAddWorkspace } from "./addws";
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
import { PaneTabs } from "./PaneTabs";
@@ -297,17 +299,7 @@ export function App({ hub }: { hub: HubPort }) {
useEffect(() => startUpdateWatch(machinePort), [machinePort]);
useFeedbackUnread(machineRt ? machinePort : null, machineRt?.id ?? "", feedbackUnread, setFeedbackUnread, feedback !== null);
useEffect(() => {
if (!activePort) return;
let alive = true;
activePort.providerSetup().then((v) => alive && setSetup(v)).catch(() => alive && setSetup(null));
// A machine that cannot answer has met the app before as far as we care:
// the sequence must never be what stands between someone and their session.
activePort.welcomeSeen().then((v) => alive && setWelcomed(v)).catch(() => alive && setWelcomed(true));
return () => {
alive = false;
};
}, [activePort]);
useFirstRunRead(activePort, setSetup, setWelcomed);
// A kernel that does not carry sign-in refuses this with a code and a
// sentence. Dropping it left the panel saying "checking…" for the rest of
@@ -393,6 +385,7 @@ export function App({ hub }: { hub: HubPort }) {
[hub, reloadPanes],
);
const openPeerSession = useCallback((path: string) => void openPane({ sessionPath: path }).catch(fail), [openPane, fail]);
const adder = useAddWorkspace(hub, reloadTree, fail, (workspace) => openPane({ root: workspace.root }));
// 每个窗口都有一个根 —— 没选过项目时那是它碰巧启动的地方。两者读起来一样,
// 于是「从哪加项目」这句问题永远问不出口;只有内核说的 remembered 分得开。
@@ -492,7 +485,7 @@ export function App({ hub }: { hub: HubPort }) {
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 activeWorkspace = tree.find((ws) => ws.root === activeRuntime?.root) ?? tree[0];
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;
@@ -561,6 +554,7 @@ export function App({ hub }: { hub: HubPort }) {
}
return (
<PeerThreadsProvider port={activePort} hub={hub} onOpen={openPeerSession}>
<div
className="app"
data-run={report.run}
@@ -796,5 +790,6 @@ export function App({ hub }: { hub: HubPort }) {
<RailToggle rail={rail} onRail={() => setRail((v) => !v)} />
<StopHint armed={stopArmed} />
</div>
</PeerThreadsProvider>
);
}
+4 -15
View File
@@ -1,8 +1,9 @@
import { useCallback, useId, useState, type CSSProperties } from "react";
import { useId, type CSSProperties } from "react";
import { t } from "../i18n";
import type { AgentPort, WorkspaceBranch, WorkspaceGit } from "../port/port";
import type { AgentPort, WorkspaceGit } from "../port/port";
import { Picker, type MenuItem } from "./Menu";
import { StudioIcon } from "./StudioIcon";
import { useWorkspaceBranches } from "./useWorkspaceBranches";
interface Props {
port: AgentPort;
@@ -51,15 +52,11 @@ function fitCard(anchor: HTMLDivElement) {
// the pane's tree refresh; this component owns only the choosing.
export function BranchChip({ port, git, changeCount, countElsewhere = false, onChanged, onSwitched, onError }: Props) {
const branchTipId = useId();
const [moving, setMoving] = useState<string | null>(null);
const { locals, moving, loadBranches, switchTo } = useWorkspaceBranches(port, onChanged, onSwitched, onError);
const busy = moving !== null;
const [locals, setLocals] = useState<WorkspaceBranch[]>([]);
// The list re-reads on open: a terminal or another window can move HEAD
// while this pane is not looking, and a stale list would offer a switch
// the repository no longer means.
const loadBranches = useCallback(() => {
void port.branches().then((r) => setLocals(r.branches)).catch(() => setLocals([]));
}, [port]);
// A branch another worktree holds is listed but closed: git would refuse
// the switch, so the row says where the branch lives instead of failing.
const branchItems: MenuItem[] = [
@@ -71,14 +68,6 @@ export function BranchChip({ port, git, changeCount, countElsewhere = false, onC
desc: b.worktree ? t("已由其他工作树检出") : undefined,
})),
];
const switchTo = (name: string) => {
if (busy) return;
setMoving(name);
void port.switchBranch(name)
.then((git) => { onChanged(); onSwitched?.(git); })
.catch(onError)
.finally(() => setMoving(null));
};
if (git && !git.repo && git.absent) {
const refused = git.absent === "refused";
+26 -29
View File
@@ -55,15 +55,12 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
const openProject = () => {
if (hub && root) void hub.openWorkspace(root).catch((e) => onError?.(e));
};
// Only for the "隔离" tag: the folder list and the switch itself moved to the
// sidebar, where adding one and opening one are the same gesture.
const [ws, setWs] = useState<WorkspaceInfo | null>(null);
useEffect(() => {
if (!port) {
setWs(null);
return;
}
port.workspaces().then(setWs).catch(() => setWs(null));
let current = true;
setWs(null);
if (port) port.workspaces().then((value) => { if (current) setWs(value); }).catch(() => { if (current) setWs(null); });
return () => { current = false; };
}, [port, root]);
const gathered = useFolded("bar");
@@ -87,29 +84,29 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
<div className="chrome" onDoubleClick={zoomOnTitleBar}>
<div className="crumb">
<button
type="button"
className="crumb-folder"
data-action="chrome.open-project"
aria-label={t("打开项目文件夹")}
title={t("打开项目文件夹")}
disabled={!canOpenProject}
onClick={openProject}
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 4.5h4l1.2 1.4h5.8v6.6h-11z" /></svg>
</button>
<span className="crumb-proj" title={root}>
{project}
<span className="crumb-context">
<button
type="button"
className="crumb-folder"
data-action="chrome.open-project"
aria-label={t("打开项目文件夹")}
title={t("打开项目文件夹")}
disabled={!canOpenProject}
onClick={openProject}
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 4.5h4l1.2 1.4h5.8v6.6h-11z" /></svg>
</button>
<span className="crumb-proj" title={root}>
{project}
</span>
<span className="isolab" hidden={!ws || ws.current !== root} title={root}>
{ws?.isolated ? t("隔离目录") : t("原目录")}
</span>
<span className="isolab hostlab" hidden={!host}>
{host}
</span>
<span className="crumbsep">/</span>
</span>
<span className="isolab" hidden={!ws?.isolated}>
{t("隔离")}
</span>
{/* Same slot as the isolated tag: both answer "this workspace is not an
ordinary folder on this machine", so they read as one language. */}
<span className="isolab hostlab" hidden={!host}>
{host}
</span>
<span className="crumbsep">/</span>
<b title={status?.sessionPath}>{sessionName(title, status?.sessionPath)}</b>
</div>
+42 -73
View File
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { UNCONFIGURED_REF } from "./ConnectNote";
import { reason } from "../i18n/kernel";
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, Attachment, WorkspaceGit } from "../port/port";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
import { useWorktreeSetup } from "./useWorktreeSetup";
import { useWorktreeSetup, WorktreeSetupFailure } from "./useWorktreeSetup";
import { WorktreeSetupPage } from "./WorktreeSetupPage";
import { Policy } from "./Policy";
import { chosenGroups, groupHeadings, modelMenu, narrowToGroups } from "./modelmenu";
import { ModelGroupChips } from "./ModelGroupChips";
@@ -32,8 +34,11 @@ import { useDraft } from "./useDraft";
import { touchKeyboard } from "./touchKeyboard";
import { ImagePreview } from "./ImagePreview";
import { SideAskButton } from "./SideAskButton";
import { chipId, chipName, isPicture, quoteBlock, releaseChip, type Chip } from "./composerChips";
interface Props {
worktreeTarget?: HTMLElement | null;
onWorktreePhase?: (phase: string | null) => void;
fresh?: boolean;
port: AgentPort;
status: SessionStatus | null;
@@ -73,62 +78,15 @@ interface Props {
draftKey?: string;
}
// What is riding along with this turn. An attachment travels as bytes or a path
// the kernel already saved; a long paste is held out of the box so eight hundred
// lines of log do not become the composer.
type Chip =
| {
k: "attachment";
id: string;
name: string;
state: "adding" | "ready" | "failed";
a?: Attachment;
blob?: File;
url?: string;
error?: string;
}
| PasteChip
| { k: "quote"; id: string; body: string; turn?: number; source?: string; lines: number };
function chipName(c: Chip): string {
if (c.k === "quote" && c.source) return t("引用 {name}", { name: c.source.split("/").at(-1) ?? c.source });
if (c.k === "quote") return c.turn === undefined ? t("引用回复") : t("引用第 {n} 轮回复", { n: c.turn });
return c.k === "paste" ? c.name ?? t("粘贴的文本") : c.name;
}
// What the model reads instead of a bare blockquote. Which reply this is about
// is the kernel's turn number, so it is stated rather than left to be inferred
// from the words; a rebuilt transcript with no turn says nothing instead of
// guessing one. English like the kernel's other host-authored prefixes: the
// line is addressed to the model, not to the reader.
function quoteBlock(c: Extract<Chip, { k: "quote" }>): string {
// The path is a file name the workspace chose, so it cannot end the line or close the bracket.
const where = c.source?.replace(/[\u0000-\u001f\u007f\u2028\u2029[\]]/g, " ").trim();
const head = where
? `[Quoting text selected in the workspace file ${where}.]`
: c.turn === undefined
? "[Quoting an earlier assistant reply in this conversation.]"
: `[Quoting the assistant reply from turn ${c.turn}.]`;
return [head, ...c.body.split("\n").map((line) => `> ${line}`)].join("\n");
}
function isPicture(c: Chip): boolean {
return c.k === "attachment" && (c.a?.image === true || (c.a?.image !== false && c.blob?.type.startsWith("image/") === true));
}
function releaseChip(c: Chip) {
if (c.k === "attachment" && c.url) URL.revokeObjectURL(c.url);
}
let chipSeq = 0;
const chipId = () => `c${++chipSeq}`;
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false }: Props) {
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
const touch = touchKeyboard();
const providerOrder = useProviderOrder();
const modelGroups = useModelFilter();
const [submitting, setSubmitting] = useState(false);
const worktree = useWorktreeSetup(port, fresh && !!git?.repo, status?.workspaceRoot ?? "", onChanged, onTreeChanged);
const worktree = useWorktreeSetup(port, fresh && !!git?.repo, status?.workspaceRoot ?? "", onChanged, onTreeChanged, status?.sessionPath ?? "");
const [setupDraft, setSetupDraft] = useState<{ text: string; files: string[] }>({ text: "", files: [] });
const setupPhase = worktree.stage?.phase ?? null;
useEffect(() => { onWorktreePhase?.(setupPhase); }, [onWorktreePhase, setupPhase]);
const { text, setText, beginSubmit, finishSubmit } = useDraft(draftKey, submitting);
// The caret decides which token is being completed, so it is state here
// rather than something read off the element when a menu happens to open.
@@ -143,6 +101,11 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
const chosenModelGroups = chosenGroups(menuItems, modelGroups);
const [busy, setBusy] = useState<Record<string, boolean>>({});
const box = useRef<HTMLTextAreaElement>(null);
const hadSetup = useRef(false);
useEffect(() => {
if (hadSetup.current && !setupPhase) box.current?.focus();
hadSetup.current = !!setupPhase;
}, [setupPhase]);
const refine = usePromptRefine(port, text, (next) => {
setText(next);
setCaret(next.length);
@@ -262,6 +225,15 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
const line = compose(v);
const call = chips.call(compose);
const draft = { text, shots, caret: caretRef.current, chips: chips.held() };
if (worktree.selection || worktree.stage) setSetupDraft({ text: v, files: shots.map(chipName) });
const restoreDraft = () => {
finishSubmit(false);
pending.current = draft.caret;
type(draft.text, draft.caret);
chips.restore(draft.chips);
setShots((current) => [...draft.shots, ...current]);
if (!worktree.selection && !worktree.stage) queueMicrotask(() => box.current?.focus());
};
beginSubmit(text);
submittingRef.current = true;
setSubmitting(true);
@@ -269,28 +241,23 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
setShots([]);
void (async () => {
try {
if (worktree.selection) await worktree.prepare(draft.shots.flatMap((chip) => chip.k === "attachment" && chip.a ? [chip.a.path] : chip.k === "paste" && chip.saved ? [chip.saved.path] : []));
if (worktree.selection || worktree.stage) {
const prepared = await worktree.prepare(draft.shots.flatMap((chip) => chip.k === "attachment" && chip.a ? [chip.a.path] : chip.k === "paste" && chip.saved ? [chip.saved.path] : []));
if (!prepared) { restoreDraft(); return; }
}
const sent = await (call ? onSubmit(line, call) : onSubmit(line));
if (sent) {
finishSubmit(true);
worktree.dismiss();
draft.shots.forEach(releaseChip);
}
else {
finishSubmit(false);
pending.current = draft.caret;
type(draft.text, draft.caret);
chips.restore(draft.chips);
setShots((current) => [...draft.shots, ...current]);
queueMicrotask(() => box.current?.focus());
restoreDraft();
worktree.dismiss();
}
} catch (e) {
finishSubmit(false);
pending.current = draft.caret;
type(draft.text, draft.caret);
chips.restore(draft.chips);
setShots((current) => [...draft.shots, ...current]);
queueMicrotask(() => box.current?.focus());
onError(e);
restoreDraft();
if (!(e instanceof WorktreeSetupFailure)) onError(e);
} finally {
submittingRef.current = false;
setSubmitting(false);
@@ -421,11 +388,13 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
const hasDraft = text.trim().length > 0 || shots.some((c) => c.k !== "attachment" || c.state === "ready");
const lines = countLines(text);
const showCount = text.length >= 240 || lines > 3;
const sendDisabled = submitting || stopping || adding || failed || !hasDraft;
const sendDisabled = submitting || stopping || adding || failed || !hasDraft || !!worktree.stage;
const warnPictures = status?.vision === false && shots.some((c) => c.k === "attachment" && c.state === "ready" && isPicture(c));
return (
<div className="dropzone" ref={dropzone}>
const setupPage = worktree.stage && <WorktreeSetupPage stage={worktree.stage} task={setupDraft.text} files={setupDraft.files} busy={submitting} onRetry={send} onCancel={() => void worktree.cancel()} onEdit={worktree.dismiss}/>;
return (<>
{setupPage && (worktreeTarget ? createPortal(setupPage, worktreeTarget) : setupPage)}
<div className="dropzone" ref={dropzone} data-worktree-setup={worktree.stage ? "" : undefined} inert={!!worktree.stage}>
{menu.open && (
<CompletionMenu
id={completionId}
@@ -694,7 +663,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("计划")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
/>
</div>
{fresh && worktree.sources?.available ? <WorktreeChip changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources} selection={worktree.selection} branch={git?.branch ?? "HEAD"} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
kernel turns off the moment a plan is approved. The lifecycle is a
separate reading — an approved plan is still running, and saying so
@@ -795,6 +764,6 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
</button>}
</span>
</div>
</div>
</div></>
);
}
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { act, 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 { SessionStatus } from "../port/port";
import type { WorktreePreparation } from "../port/workspace";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
it.each(["failed", "cancelled"])("restores composer focus after dismissing %s preparation", async (phase) => {
const port = new MockPort();
vi.spyOn(port, "worktreeSources").mockResolvedValue({ available: true, sourceRoot: "/source", sources: [{ ref: "refs/heads/main", name: "main", current: true, remote: false }] });
let resolve!: (value: WorktreePreparation) => void;
const start = vi.spyOn(port, "prepareWorktree").mockImplementation(() => new Promise((done) => { resolve = done; }));
const onSubmit = vi.fn(async () => true);
const st = { workspaceRoot: "/source", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", effort: "auto", toolApprovalMode: "ask" } as SessionStatus;
render(<Composer port={port} status={st} running={false} fresh git={{ repo: true, branch: "main", name: "project", detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()}/>);
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(screen.getByRole("button", { name: "新建 worktree" }));
fireEvent.click(screen.getByRole("button", { name: "关闭工作目录配置" }));
const box = screen.getByLabelText("任务输入") as HTMLTextAreaElement;
box.focus();
// jsdom lacks inert focus suppression; mirror the browser boundary locally.
const focus = box.focus.bind(box);
vi.spyOn(box, "focus").mockImplementation((options) => { if (!box.closest("[inert]")) focus(options); });
fireEvent.change(box, { target: { value: "保留这份任务", selectionStart: 3 } });
box.blur();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(start).toHaveBeenCalledOnce());
await act(async () => resolve({ id: start.mock.calls[0][0], phase, sourceRoot: "/source", ref: "refs/heads/main", startedAt: Date.now() }));
fireEvent.click(await screen.findByRole("button", { name: "返回编辑" }));
await waitFor(() => expect(document.activeElement).toBe(box));
expect(box.closest("[inert]")).toBeNull();
expect(box.value).toBe("保留这份任务");
expect(onSubmit).not.toHaveBeenCalled();
});
@@ -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[]>) {
function setup(search: (q: string) => Promise<HistoryHit[]>, workspaces = tree) {
const onOpen = vi.fn(() => Promise.resolve());
render(
<RailSearch>
<ContentMatches hub={{ searchHistory: search }} tree={tree} onOpen={onOpen} onError={() => {}} />
<ContentMatches hub={{ searchHistory: search }} tree={workspaces} onOpen={onOpen} onError={() => {}} />
</RailSearch>,
);
return onOpen;
@@ -51,4 +51,14 @@ describe("ContentMatches", () => {
await act(async () => void vi.advanceTimersByTime(400));
expect(document.querySelector("[data-state='failed']")).not.toBeNull();
});
it("opens a grouped content match in the session's working directory", async () => {
const onOpen = setup(() => Promise.resolve([hit]), [{ ...tree[0], sessions: [
{ ...tree[0].sessions[0], workspaceRoot: "/worktrees/a" },
] }]);
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "zebra" } });
await act(async () => void vi.advanceTimersByTime(400));
fireEvent.click(document.querySelector("[data-action='session.open-match']")!);
expect(onOpen).toHaveBeenCalledWith({ root: "/worktrees/a", sessionPath: hit.path });
});
});
@@ -14,7 +14,11 @@ function titleOf(hit: HistoryHit, tree: TreeWorkspace[]): string {
}
function rootOf(hit: HistoryHit, tree: TreeWorkspace[]): string | undefined {
return tree.find((w) => w.sessions.some((s) => s.path === hit.path))?.root ?? (hit.workspace || undefined);
for (const workspace of tree) {
const session = workspace.sessions.find((s) => s.path === hit.path);
if (session) return session.workspaceRoot || workspace.root;
}
return hit.workspace || undefined;
}
/** Conversations whose words, not names, match what the rail is searching for.
@@ -9,7 +9,7 @@ import type { DisplayCurrencyMode, DisplayCurrencySettings } from "../port/bound
afterEach(cleanup);
const base: DisplayCurrencySettings = { mode: "auto", path: "/u/config.toml" };
const base: DisplayCurrencySettings = { mode: "auto", path: "/u/config.toml", unconverted: [] };
function portWith(save: (m: DisplayCurrencyMode) => Promise<DisplayCurrencySettings>) {
return { displayCurrency: vi.fn(async () => base), saveDisplayCurrency: vi.fn(save) } as unknown as AgentPort & { saveDisplayCurrency: ReturnType<typeof vi.fn> };
@@ -51,3 +51,19 @@ describe("the display currency setting", () => {
expect(changed).not.toHaveBeenCalled();
});
});
describe("a price the pinned currency cannot be shown for", () => {
it("names the model, its currency and what to do, and says nothing when there is none", async () => {
const stuck = { ...base, mode: "CNY" as const, unconverted: [{ provider: "deepseek", model: "deepseek-flash", currency: "USD" }] };
const port = { displayCurrency: vi.fn(async () => stuck), saveDisplayCurrency: vi.fn() } as unknown as AgentPort;
render(<DisplayCurrency port={port} onChanged={() => {}} />);
expect(await screen.findByText(/deepseek-flash(USD)/)).toBeTruthy();
expect(screen.getByText(/恢复官方价/)).toBeTruthy();
cleanup();
const fine = { displayCurrency: vi.fn(async () => ({ ...base, mode: "CNY" as const })), saveDisplayCurrency: vi.fn() } as unknown as AgentPort;
render(<DisplayCurrency port={fine} onChanged={() => {}} />);
await screen.findByRole("button", { name: "自动" });
expect(screen.queryByText(/恢复官方价/)).toBeNull();
});
});
@@ -54,6 +54,13 @@ export function DisplayCurrency({ port, onChanged }: { port: AgentPort; onChange
))}
</div>
</div>
{state.mode !== "auto" && state.unconverted.length > 0 && (
<div className="why">
{t("这些模型的价格不在官方价目表里,无法换算成所选币种,费用只能按原币种显示:{models}。到「服务商」编辑该模型,点「恢复官方价」即可。", {
models: state.unconverted.map((u) => t("{model}({currency})", { model: u.model, currency: u.currency })).join(t("、")),
})}
</div>
)}
{error && <div className="why">{error}</div>}
</div>
)}
@@ -57,3 +57,29 @@ describe("pricing a model on a connection", () => {
expect((edit.mock.calls[0]![0] as ProviderEdit).modelPrices).toEqual({ alpha: null });
});
});
describe("a price on a vendor's own endpoint that is not one of the vendor's", () => {
const official: Partial<ProviderEntry> = { kind: "openai", baseUrl: "https://api.deepseek.com", models: ["deepseek-flash"], default: "deepseek-flash", officialPricing: true };
const damaged = { input: 0.3, output: 1.2, cacheHit: 0, currency: "USD", official: false };
it("says so, warns that a zero cached price makes cost too low, and restores the official price on a click", async () => {
const { edit } = await open(entry({ ...official, modelPrices: { "deepseek-flash": damaged } }));
expect(screen.getByText("这个价格不在官方价目表里,选了人民币或美元显示时无法换算。")).toBeTruthy();
expect(screen.getByText("缓存命中价是 0:命中的 token 会按免费计,费用会偏低。")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "deepseek-flash 恢复官方价" }));
expect(field("deepseek-flash 的输入单价").value).toBe("");
expect(screen.queryByText("这个价格不在官方价目表里,选了人民币或美元显示时无法换算。")).toBeNull();
await userEvent.click(save());
await waitFor(() => expect(edit).toHaveBeenCalled());
expect((edit.mock.calls[0]![0] as ProviderEdit).modelPrices).toEqual({ "deepseek-flash": null });
});
it("offers nothing for an official card or a relay, which has no table", async () => {
await open(entry({ ...official, modelPrices: { "deepseek-flash": { ...damaged, cacheHit: 0.006, official: true } } }));
expect(screen.queryByRole("button", { name: /恢复官方价/ })).toBeNull();
cleanup();
await open(entry({ ...official, officialPricing: false, baseUrl: "https://relay.example/v1", modelPrices: { "deepseek-flash": damaged } }));
expect(screen.queryByRole("button", { name: /恢复官方价/ })).toBeNull();
});
});
+1 -1
View File
@@ -350,7 +350,7 @@ export function EditConn({
{(picked.length > 1 || picked.some((m) => entry.modelLimits?.[m])) && (
<ModelLimits models={picked} value={limitText} onChange={setLimitText} inherited={inheritedLimitsFor} />
)}
{picked.length > 0 && <ModelPrices models={picked} value={priceText} onChange={setPriceText} />}
{picked.length > 0 && <ModelPrices models={picked} value={priceText} onChange={setPriceText} official={entry.officialPricing === true} stored={entry.modelPrices ?? {}} />}
</div>
<div className="mlist">
@@ -212,8 +212,8 @@ describe("who may reply", () => {
expect(screen.queryByRole("textbox")).toBeNull();
});
it("offers nothing on received, wont-fix, duplicate, closed and unreadable reports", async () => {
const items = (["received", "wontfix", "duplicate", "closed"] as const).map((status, i) => item({ receipt: `FB-AAAA-000${i + 1}`, status }));
it("offers nothing on wont-fix, duplicate, closed and unreadable reports", async () => {
const items = (["wontfix", "duplicate", "closed"] as const).map((status, i) => item({ receipt: `FB-AAAA-000${i + 1}`, status }));
items.push(item({ receipt: "FB-AAAA-0009", status: "needs_info", needsInput: true, statusUnavailable: true }));
render(<FeedbackMine port={portWith(() => mine(items))} onFile={() => {}} />);
await screen.findByText("FB-AAAA-0001");
@@ -221,6 +221,21 @@ describe("who may reply", () => {
expect(screen.queryByRole("textbox")).toBeNull();
});
it("takes a reply on a released received report, and none while it is under review", async () => {
const items = [
item({ receipt: "FB-AAAA-0001", status: "received", replies: [reply(5, "maintainer", "Which OS?")] }),
item({ receipt: "FB-AAAA-0002", status: "received", underReview: true }),
];
const port = portWith(() => mine(items));
render(<FeedbackMine port={port} onFile={() => {}} />);
await screen.findByText("FB-AAAA-0001");
expect(within(row("FB-AAAA-0002")).queryByRole("button", { name: "回复" })).toBeNull();
await userEvent.click(within(row("FB-AAAA-0001")).getByRole("button", { name: "回复" }));
await userEvent.type(box(), "macOS 15");
await userEvent.click(sendButton());
await waitFor(() => expect(port.replyFeedback).toHaveBeenCalledWith("FB-AAAA-0001", "macOS 15"));
});
it("lets the reporter say a fix did not hold, and says that reopens the report", async () => {
render(<FeedbackMine port={portWith(() => mine([item({ status: "fixed", issueNumber: 11350 })]))} onFile={() => {}} />);
await screen.findByText("FB-AAAA-0001");
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent } from "react";
import { current, t } from "../i18n";
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, isUnderReview, type FeedbackItem, type FeedbackLimits, type FeedbackReply } from "../port/feedback";
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, canReplyTo, isUnderReview, type FeedbackItem, type FeedbackLimits, type FeedbackReply } from "../port/feedback";
import type { AgentPort } from "../port/port";
import { replyFailure, type FeedbackFailure } from "./feedbackfailure";
import { FeedbackShot } from "./FeedbackShot";
@@ -123,7 +123,7 @@ export function FeedbackReplyBox({ port, item, limit, limits = null, offline, on
},
[limits],
);
const replyable = !item.statusUnavailable && FEEDBACK_REPLYABLE.includes(item.status);
const replyable = canReplyTo(item);
if (!replyable) return null;
const reopens = item.status === "fixed";
if (offline) return <p className="fbk-hint">{t("离线时暂时不能回复。")}</p>;
+13 -1
View File
@@ -53,11 +53,13 @@ export function pricesToSend(picked: string[], text: Record<string, PriceText>,
}
export function ModelPrices({
models, value, onChange,
models, value, onChange, official = false, stored = {},
}: {
models: string[];
value: Record<string, PriceText>;
onChange: (next: Record<string, PriceText>) => void;
official?: boolean;
stored?: Record<string, ModelRate>;
}) {
const set = (model: string, patch: Partial<PriceText>) =>
onChange({ ...value, [model]: { ...(value[model] ?? blank()), ...patch } });
@@ -71,6 +73,8 @@ export function ModelPrices({
{models.map((model) => {
const cur = value[model] ?? blank();
const own = cur.inp !== "" || cur.out !== "";
const foreign = official && own && stored[model] !== undefined && stored[model]!.official === false;
const freeCached = foreign && cur.hit !== "" && Number(cur.hit) === 0 && Number(cur.inp) > 0;
return (
<li key={model} className="ml-row mp-row" data-model={model} data-own={own || undefined}>
<span className="me-name" title={model}>{shortId(model)}</span>
@@ -94,6 +98,14 @@ export function ModelPrices({
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
{incomplete(cur) && <span className="why">{t("输入价和输出价需要都填写,才会保存这一行。")}</span>}
{foreign && (
<span className="why mp-foreign">
<span>{t("这个价格不在官方价目表里,选了人民币或美元显示时无法换算。")}</span>
{freeCached && <span>{t("缓存命中价是 0:命中的 token 会按免费计,费用会偏低。")}</span>}
<button type="button" className="act" data-action="provider.draft" data-value="model-price-restore" aria-label={t("{model} 恢复官方价", { model })}
onClick={() => set(model, { inp: "", out: "", hit: "" })}>{t("恢复官方价")}</button>
</span>
)}
</li>
);
})}
+8 -4
View File
@@ -61,6 +61,7 @@ import { SidePanel } from "./SidePanel";
import { cancelJob } from "./jobcancel";
import { ConnectNote } from "./ConnectNote";
import { useStatusPoll } from "./useStatusPoll";
import { useStatusRefresh } from "./useStatusRefresh";
import { useCheckpointRefresh } from "./useCheckpointRefresh";
import { LiveWork, useLiveWork } from "../state/foldpref";
import { loadChangedFilesPref, useShowsChangedFiles } from "../state/changedfiles";
@@ -94,6 +95,8 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const ready = sessionRead.kind === "settled";
const [tab, showView] = useShowView(rt.id, onManualBrowser);
const [sideAsk, setSideAsk] = useState(false);
const [worktreeTarget, setWorktreeTarget] = useState<HTMLDivElement | null>(null);
const [worktreePhase, setWorktreePhase] = useState<string | null>(null);
const [mcp, setMcp] = useState<McpEntry[]>([]);
const [askFocus, setAskFocus] = useState(0);
const [tree, setTree] = useState<WorkspaceChanges | null>(null);
@@ -178,7 +181,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
}, []);
const readJob = useCallback((id: string) => port.jobOutput(id), [port]);
const refreshStatus = useCallback(() => port.status().then((st) => { applyStatus(st); statusRead(); }).catch(() => {}), [port, applyStatus, statusRead]);
const refreshStatus = useStatusRefresh(port, applyStatus, statusRead);
const [wallet, refreshWallet] = useWallet(port, accountOf(status?.modelRef));
const hideAmounts = useHidesAmounts();
@@ -473,6 +476,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
<div className="pbody rewind-notice-host" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
<RewindNotice key={JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath])} readUndo={onReadUndo} onUndo={onUndoAvailable} busy={!!restoreNotice?.working || !!reloadFailure?.working} refreshKey={checkpoints} moved={s.rewindMoved} running={running} shown={visible && tab === "flow" && !rt.readOnly} />
<div className="pviews">
<div className="worktree-stage-host" ref={setWorktreeTarget} hidden={tab !== "flow" || !worktreePhase}/>
<ReceiptPrefContext.Provider value={receiptPref}>
<PaneShown.Provider value={shown}>
@@ -490,7 +494,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
takeovers={s.takeovers}
waiting={s.waiting}
scroll={flow}
hidden={tab !== "flow"}
hidden={tab !== "flow" || !!worktreePhase}
focus={null}
find={find.at}
query={find.query}
@@ -565,7 +569,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
{/* Everything stacked above the input box shares one ceiling, so no
child of this region may grow without bound. */}
<PlanFold plan={s.plan} goal={status?.goal} shown={tab === "flow"} paused={blocked} />
{tab === "flow" && <RunLine label={chipLabel(s, running)} running={running} blocked={blocked} since={s.running ? s.runSince : undefined} sent={sent} received={received} estimated={s.outLive > 0} />}
{tab === "flow" && <RunLine label={worktreePhase ? t(["failed", "cleanup_failed"].includes(worktreePhase) ? "创建失败" : worktreePhase === "cancelled" ? "创建已取消" : "准备工作目录") : chipLabel(s, running)} running={running} blocked={blocked} since={s.running ? s.runSince : undefined} sent={sent} received={received} estimated={s.outLive > 0} />}
<div className="composeaux">
<Queue
queue={queue}
@@ -601,7 +605,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
</div>
{alert && <div className="cmpalert">{alert}</div>}
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
<Composer port={port} fresh={ready && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<Composer worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<MeterRail
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost}
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
@@ -5,6 +5,7 @@ import { arrowTabs } from "./tablist";
import { pinToViewport } from "./place";
import { useMarker } from "./marker";
import { useDismiss } from "./dismiss";
import { PeerThreadMark } from "./peerthreads";
export interface TabView {
rt: RuntimeView;
@@ -182,6 +183,7 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
{/* Which machine is running this. Panes from two hosts sit side by
side, and a command lands wherever the focused tab points. */}
{rt.host && <span className="ptab-host">{rt.host}</span>}
<PeerThreadMark path={rt.sessionPath} />
<button
className="ptab-x"
data-action="pane.close"
+12 -9
View File
@@ -2,6 +2,7 @@ import { memo, useState } from "react";
import { useRailQuery } from "./railsearch";
import { t } from "../i18n";
import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
import { workspaceContains } from "../port/hub";
import { REMOTE_STEP_LABEL, REMOTE_STEPS, type RemoteHost } from "../port/remote";
import { RemoteDirs } from "./RemoteDirs";
import { workspacesOf } from "./Remotes";
@@ -94,9 +95,9 @@ export interface RemoteWorkspace extends TreeWorkspace {
// one opened over there still appears without having been written down here.
export function remoteWorkspaces(host: RemoteHost, tree: TreeWorkspace[] | null | undefined): RemoteWorkspace[] {
const known = workspacesOf(host);
const out: RemoteWorkspace[] = tree ? tree.map((ws) => ({ ...ws, booked: known.includes(ws.root) })) : [];
const out: RemoteWorkspace[] = tree ? tree.map((ws) => ({ ...ws, booked: known.some((root) => workspaceContains(ws, root)) })) : [];
for (const dir of known) {
if (!out.some((ws) => ws.root === dir)) out.push({ root: dir, name: leaf(dir), sessions: [], booked: true });
if (!out.some((ws) => workspaceContains(ws, dir))) out.push({ root: dir, name: leaf(dir), sessions: [], booked: true });
}
return out;
}
@@ -174,14 +175,16 @@ function RemoteHostsView({ hub, hosts, runtimes, active, onOpen, onFocus, reload
// Dropping a folder from this window's book. Nothing on the far machine is
// touched, which is what makes it a one-click action rather than a question.
const drop = async (host: string, dir: string) => {
setBusy(host + dir);
const drop = async (host: RemoteHost, workspace: RemoteWorkspace) => {
setBusy(host.name + workspace.root);
try {
// Idle panes in the folder close here so this list stays in step; the
// kernel refuses the removal while any of them is running.
const open = runtimes.filter((rt) => rt.host === host && rt.root === dir).map((rt) => rt.id);
const open = runtimes.filter((rt) => rt.host === host.name && workspaceContains(workspace, rt.root)).map((rt) => rt.id);
if (open.length && liveIds(open).length === 0) await onClose(open);
await hub.removeRemoteWorkspace(host, dir);
for (const root of workspacesOf(host).filter((root) => workspaceContains(workspace, root))) {
await hub.removeRemoteWorkspace(host.name, root);
}
await reload();
} catch (e) {
onError(e);
@@ -392,7 +395,7 @@ function RemoteHostsView({ hub, hosts, runtimes, active, onOpen, onFocus, reload
aria-label={t("不再列出 {path}", { path: ws.root })}
onClick={(ev) => {
ev.stopPropagation();
void drop(host.name, ws.root);
void drop(host, ws);
}}
>
<Cross />
@@ -434,10 +437,10 @@ function RemoteHostsView({ hub, hosts, runtimes, active, onOpen, onFocus, reload
data-live={held ? "" : undefined}
data-unread={session.unread ? "" : undefined}
data-busy={busy === host.name + session.path ? "" : undefined}
onClick={() => (held ? onFocus(held.id) : void open(host, ws.root, session.path))}
onClick={() => (held ? onFocus(held.id) : void open(host, session.workspaceRoot || ws.root, session.path))}
>
<i className="pip" />
<span className="sesstitle" title={name}><span>{name}</span></span>
<span className="sesstitle" title={name}><span>{name}</span>{session.isolated && <em className="sessarch" title={session.workspaceRoot}>{t("隔离")}</em>}</span>
{session.unread && <UnreadDot />}
<span className="sessmeta">{session.turns ? t("{n} 轮", { n: session.turns }) : t("空会话")}</span>
<button
@@ -0,0 +1,32 @@
import { t } from "../i18n";
import { CopyButton } from "./CopyButton";
import { StudioIcon } from "./StudioIcon";
import "../styles/workspace-path.css";
export function compactWorkspaceValue(value: string, length = 38): string {
const chars = Array.from(value);
if (chars.length <= length) return value;
const tail = Math.min(16, Math.floor(length / 2));
return chars.slice(0, length - tail - 1).join("") + "…" + chars.slice(-tail).join("");
}
export function workspaceName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) || path;
}
export function WorkspaceBranchName({ value }: { value: string }) {
const chars = Array.from(value);
return <span className="workspace-branch-value" title={value}>{chars.length > 32 ? <><span>{chars.slice(0, -12).join("")}</span><span>{chars.slice(-12).join("")}</span></> : value}</span>;
}
export function WorkspacePath({ path, onResize }: { path: string; onResize?: () => void }) {
const parts = path.split(/[\\/]/).filter(Boolean);
const short = parts.length > 2 ? "…/" + parts.slice(-2).join("/") : path;
return <div className="workspace-path">
<details data-action="workspace.configure" onToggle={onResize}>
<summary aria-label={t("查看完整路径")} title={path}><StudioIcon name="folder"/><span>{short}</span><StudioIcon name="down"/></summary>
<code>{path}</code>
</details>
<CopyButton text={path} iconOnly className="workspace-path-copy" label={t("复制完整路径")}/>
</div>;
}
+10 -4
View File
@@ -5,6 +5,7 @@ import { onSessionQuickActionsChange, showsSessionQuickActions } from "../state/
import { createPortal } from "react-dom";
import { t } from "../i18n";
import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub";
import { workspaceContains } from "../port/hub";
import type { Adder } from "./addws";
import { pinToViewport } from "./place";
import { useRailQuery } from "./railsearch";
@@ -25,6 +26,7 @@ import { SessionMenu } from "./SessionMenu";
import { pinnedFirst } from "./pinorder";
import { Confirm, removeHint, sessionDeleteHint } from "./WorkspaceConfirm";
import { UnreadCount, UnreadDot } from "./UnreadMark";
import { PeerThreadMark } from "./peerthreads";
import { NO_PAGING, atCap, sessionCount, unloaded, type TreePaging } from "./treewindow";
import { SessionBatch, useSelectedRows } from "./SessionBatch";
import { LocalMachine } from "./LocalMachine";
@@ -177,7 +179,7 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
return;
}
try {
await onOpen({ root: ws.root, sessionPath: session.path });
await onOpen({ root: session.workspaceRoot || ws.root, sessionPath: session.path });
} catch (e) {
onError(e);
}
@@ -204,7 +206,10 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
}
});
const panesOf = (root: string) => runtimes.filter((rt) => rt.root === root).map((rt) => rt.id);
const panesOf = (root: string) => {
const workspace = tree.find((ws) => ws.root === root);
return runtimes.filter((rt) => !rt.host && (workspace ? workspaceContains(workspace, rt.root) : rt.root === root)).map((rt) => rt.id);
};
const dropWorkspace = async (ws: TreeWorkspace) => {
if (confirm !== ws.root) {
@@ -588,9 +593,10 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
) : (
<span className="sesstitle" title={rowLabel(session)}>
{pinned.has(session.path) && <StudioIcon name="pin" className="sesspin" aria-label={t("已置顶")} />}
<span>{rowLabel(session)}</span>{session.archived && <em className="sessarch">{t("已归档")}</em>}
<span>{rowLabel(session)}</span>{session.isolated && <em className="sessarch" title={session.workspaceRoot}>{t("隔离")}</em>}{session.archived && <em className="sessarch">{t("已归档")}</em>}
</span>
)}
<PeerThreadMark path={session.path} />
{session.unread && <UnreadDot />}
{kept.length > 0 && (
<button
@@ -657,7 +663,7 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
}}
onArchive={() => archive(session)}
onBranch={onBranch && session.runtimeId ? () => { setSessionMenu(""); void onBranch(session.runtimeId!, `${rowLabel(session)} · ${t("分支")}`).catch(onError); } : undefined}
onCopyInfo={() => { void copyText(sessionInfoText(session, ws.root)).catch(onError); setSessionMenu(""); }}
onCopyInfo={() => { void copyText(sessionInfoText(session, session.workspaceRoot || ws.root)).catch(onError); setSessionMenu(""); }}
onExport={() => void saveSession(session)}
onDelete={() => { askDelete(session.path); setSessionMenu(""); }}
/>
+71 -62
View File
@@ -1,15 +1,22 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import type { WorktreeSelection, WorktreeSources } from "../port/workspace";
import type { WorkspaceInfo, WorktreeSelection, WorktreeSources } from "../port/workspace";
import type { AgentPort, WorkspaceGit } from "../port/port";
import { useWorkspaceBranches } from "./useWorkspaceBranches";
import { StudioIcon } from "./StudioIcon";
import { Switch } from "./Switch";
import { WorkspacePath, WorkspaceBranchName, compactWorkspaceValue, workspaceName } from "./WorkspacePath";
import { listenAction } from "./listen";
import { beside, pinToViewport, useFollow, zoom } from "./place";
import "../styles/worktree.css";
interface Props {
port: AgentPort;
onChanged: () => void;
onSwitched?: (git: WorkspaceGit) => void;
onError: (error: unknown) => void;
sources: WorktreeSources;
canCreate: boolean;
selection: WorktreeSelection | null;
branch: string;
busy: boolean;
@@ -18,95 +25,97 @@ interface Props {
refresh: () => Promise<WorktreeSources>;
}
export function WorktreeChip({ sources, selection, branch, busy, changeCount, onSelect, refresh }: Props) {
export function WorktreeChip({ sources, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
const [open, setOpen] = useState(false);
const [choosing, setChoosing] = useState(false);
const [query, setQuery] = useState("");
const [error, setError] = useState(false);
const [sourceError, setSourceError] = useState(false);
const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const search = useRef<HTMLInputElement>(null);
const title = useId();
const returnFocus = useRef(false);
const close = () => { returnFocus.current = true; setOpen(false); setQuery(""); };
const { locals, moving, reading, loadBranches, switchTo } = useWorkspaceBranches(port, onChanged, (git) => { onSwitched?.(git); close(); }, onError, sources.sourceRoot);
useEffect(() => {
if (!open && moving === null && returnFocus.current) { returnFocus.current = false; trigger.current?.focus(); }
}, [open, moving]);
const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated;
const chosen = sources.sources.find((source) => source.ref === selection?.ref);
const label = chosen?.name ?? selection?.ref ?? branch;
const close = () => { setOpen(false); setChoosing(false); setQuery(""); trigger.current?.focus(); };
useEffect(() => {
let live = true;
returnFocus.current = false; setWorkspace(null); setOpen(false); setQuery("");
void port.workspaces().then((value) => { if (live) setWorkspace(value); }).catch(() => {});
return () => { live = false; };
}, [port, sources.sourceRoot]);
const put = useCallback(() => {
if (!trigger.current || !panel.current) return;
const anchor = trigger.current.getBoundingClientRect();
const height = panel.current.offsetHeight * zoom();
pinToViewport(panel.current, anchor.left, beside(anchor, height, innerHeight, "above", 10), 12);
}, []);
useFollow(open, put, `${choosing}:${query}:${error}:${!!selection}`);
useFollow(open, put, `${query}:${reading}:${sourceError}:${!!selection}:${locals.length}`);
useEffect(() => {
if (!open) return;
if (choosing) search.current?.focus();
else panel.current?.querySelector<HTMLButtonElement>("button")?.focus();
search.current?.focus();
const outside = (event: MouseEvent) => {
if (!panel.current?.contains(event.target as Node) && !trigger.current?.contains(event.target as Node)) close();
};
const key = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
if (choosing) { setChoosing(false); setQuery(""); } else close();
}
if (event.key === "Escape") { event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const stops = Array.from(panel.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input") ?? []);
const stops = Array.from(panel.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input, summary") ?? []);
const edge = event.shiftKey ? stops[0] : stops.at(-1);
if (document.activeElement === edge) {
event.preventDefault();
(event.shiftKey ? stops.at(-1) : stops[0])?.focus();
}
if (document.activeElement === edge) { event.preventDefault(); (event.shiftKey ? stops.at(-1) : stops[0])?.focus(); }
};
const stopOutside = listenAction(window, "mousedown", { action: "workspace.configure", listener: outside as EventListener });
const stopKey = listenAction(window, "keydown", { action: "workspace.configure", listener: key as EventListener }, true);
return () => { stopOutside(); stopKey(); };
}, [open, choosing]);
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(put);
if (panel.current) observer?.observe(panel.current);
return () => { stopOutside(); stopKey(); observer?.disconnect(); };
}, [open, put]);
useEffect(() => { if (busy) setOpen(false); }, [busy]);
const show = () => {
if (open) { close(); return; }
setOpen(true);
setError(false);
void refresh().catch(() => setError(true));
const reload = () => {
setSourceError(false); loadBranches();
if (canCreate) void refresh().catch(() => setSourceError(true));
};
const toggle = () => onSelect(selection ? null : {
sourceRoot: sources.sourceRoot,
ref: sources.sources.find((source) => source.current)?.ref ?? "HEAD",
});
const shown = sources.sources.filter((source) => source.name.toLowerCase().includes(query.toLowerCase()));
const show = () => { if (open) { close(); return; } setOpen(true); setQuery(""); reload(); };
const selectWorktree = () => {
setQuery("");
onSelect({ sourceRoot: sources.sourceRoot, ref: (sources.sources.find((source) => !source.remote && source.name === branch) ?? sources.sources.find((source) => source.current))?.ref ?? "HEAD" });
};
const choices = selection ? sources.sources.map((source) => ({ ...source, worktree: "" })) : locals.map((source) => ({ ...source, ref: source.name, remote: false }));
const selected = (source: typeof choices[number]) => selection ? selection.ref === source.ref : !!source.current;
const shown = choices.filter((source) => source.name.toLowerCase().includes(query.toLowerCase())).sort((a, b) => Number(selected(b)) - Number(selected(a)));
const failed = selection ? sourceError : reading === "failed";
const loading = !selection && (reading === "idle" || reading === "loading") && locals.length === 0;
return <div className="studio-branch-pop">
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} aria-haspopup="dialog" aria-expanded={open} disabled={busy} onClick={show}>
<StudioIcon name="branch" /><span className="lb">{selection ? t("独立 worktree · {branch}", { branch: label }) : t("当前目录 · {branch}", { branch })}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down" />
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={selection ? label : branch} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立 worktree · {branch}", { branch: compactWorkspaceValue(label) }) : t("当前目录 · {branch}", { branch: compactWorkspaceValue(branch) })}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
</button>
{open && createPortal(<div ref={panel} className="worktree-panel composer-menu" role="dialog" aria-labelledby={title}>
<div className="worktree-heading" id={title}>{choosing ? t("选择起始分支") : t("配置工作目录")}</div>
{error ? <p role="alert">{t("分支读取失败,请关闭后重试")}</p> : choosing ? <>
<input data-action-change="workspace.source-search" data-action-keydown="workspace.source-navigate" ref={search} type="search" aria-label={t("搜索分支")} placeholder={t("搜索分支…")} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector<HTMLElement>('[role="option"]')?.focus(); }
}} />
<div data-action="workspace.source-navigate" className="worktree-sources" role="listbox" aria-label={t("起始分支")} onKeyDown={(event) => {
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button"));
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "ArrowDown" ? at + 1 : event.key === "ArrowUp" ? at - 1 : at;
if (next !== at) { event.preventDefault(); items[(next + items.length) % items.length]?.focus(); }
}}>
{[false, true].map((remote) => <div key={String(remote)}>
{shown.some((source) => source.remote === remote) && <small>{remote ? t("远程分支") : t("本地分支")}</small>}
{shown.filter((source) => source.remote === remote).map((source) => <button data-action="workspace.source-select" key={source.ref} type="button" role="option" aria-selected={selection?.ref === source.ref} onClick={() => {
onSelect({ ref: source.ref, sourceRoot: sources.sourceRoot }); setChoosing(false); setQuery("");
}}><span>{source.name}</span>{source.current && <small>{t("当前分支")}</small>}{selection?.ref === source.ref && <StudioIcon name="check" />}</button>)}
</div>)}
{shown.length === 0 && <p>{t("没有匹配的分支")}</p>}
</div>
<button data-action="workspace.source-open" className="worktree-back" onClick={() => setChoosing(false)}>{t("返回")}</button>
</> : <>
<div className="worktree-toggle"><StudioIcon name="expand" /><div><span>{t("新建 worktree")}</span><small>{t("为此会话创建独立的代码目录")}</small></div><Switch data-action="workspace.worktree-toggle" on={!!selection} busy={busy || !sources.available} label={t("新建 worktree")} onClick={toggle} /></div>
{selection ? <>
<button data-action="workspace.source-open" className="worktree-row" onClick={() => setChoosing(true)}><StudioIcon name="branch" /><span>{t("起始分支")}</span><span className="worktree-value">{label}</span><StudioIcon name="chevron" /></button>
<div className="worktree-row"><StudioIcon name="branch" /><span>{t("任务分支")}</span><span className="worktree-value">{t("自动创建")}</span></div>
<p className="worktree-note">{t("基于所选分支的已提交版本创建,不包含未提交修改")}</p>
</> : <p className="worktree-note">{t("使用当前目录,与该目录中的其他会话共享文件")}</p>}
</>}
{open && createPortal(<div ref={panel} className="workspace-palette" role="dialog" aria-labelledby={title}>
<div className="workspace-palette-heading"><span id={title}>{t("配置工作目录")}</span><button data-action="workspace.configure" className="workspace-palette-close" aria-label={t("关闭工作目录配置")} onClick={close}><StudioIcon name="close"/></button></div>
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong><span>{t(selection ? "新建 worktree" : isolated ? "独立 worktree" : "当前目录")}</span></div>
{canCreate && <div className="worktree-modes"><button data-action="workspace.worktree-toggle" aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("使用当前目录")}</button><button data-action="workspace.worktree-toggle" aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("新建 worktree")}</button></div>}
{selection && <div className="workspace-palette-base"><span>{t("起始分支")}</span><strong title={label}>{compactWorkspaceValue(label)}</strong><small>{t("任务分支自动创建")}</small></div>}
<div className="workspace-palette-search"><StudioIcon name="search"/><input data-action-change="workspace.source-search" data-action-keydown="workspace.source-navigate" ref={search} type="search" aria-label={t("搜索分支")} placeholder={t(selection ? "搜索起始分支…" : "搜索本地分支…")} value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector<HTMLElement>('[role="option"]:not(:disabled)')?.focus(); }
}}/></div>
{failed ? <div className="workspace-palette-empty" role="alert"><p>{t("分支读取失败")}</p><button data-action="workspace.source-open" className="btn" onClick={reload}>{t("重新读取")}</button></div> : loading ? <div className="workspace-palette-empty" role="status">{t("正在读取分支…")}</div> : <div className="worktree-sources" role="listbox" aria-label={t(selection ? "起始分支" : "本地分支")} data-action="workspace.source-navigate" onKeyDown={(event) => {
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"));
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "ArrowDown" ? at + 1 : event.key === "ArrowUp" ? at - 1 : at;
if (next !== at) { event.preventDefault(); items[(next + items.length) % items.length]?.focus(); }
if (event.key === "Home" || event.key === "End") { event.preventDefault(); (event.key === "Home" ? items[0] : items.at(-1))?.focus(); }
}}>
{[false, true].map((remote) => <div key={String(remote)}>{remote && shown.some((source) => source.remote) && <small className="workspace-palette-group">{t("远程分支")}</small>}{shown.filter((source) => source.remote === remote).map((source) => <button data-action={selection ? "workspace.source-select" : "git.branch"} key={source.ref} type="button" role="option" aria-label={source.name} aria-selected={selected(source)} disabled={!selection && (!!source.worktree || moving !== null)} title={source.worktree ? `${source.name}\n${source.worktree}` : source.name} onClick={() => {
if (selection) onSelect({ ref: source.ref, sourceRoot: sources.sourceRoot }); else switchTo(source.name);
}}><StudioIcon name="branch"/><span className="workspace-palette-branch"><WorkspaceBranchName value={source.name}/></span>{source.worktree ? <small>{t("其他 worktree 占用")}</small> : selected(source) && <small>{t(selection ? "起点" : "当前")}</small>}<StudioIcon name={selected(source) ? "check" : "chevron"}/></button>)}</div>)}
{shown.length === 0 && <p className="workspace-palette-empty">{t("没有匹配的分支")}</p>}
</div>}
<p className="workspace-palette-note">{t(selection ? "从已提交版本创建,任务和附件将在就绪后发送" : "选择后立即切换,同目录会话也会受影响")}</p>
<div className="workspace-palette-footer"><WorkspacePath path={sources.sourceRoot} onResize={put}/></div>
</div>, document.body)}
</div>;
}
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { WorktreeSetupPage } from "./WorktreeSetupPage";
import type { WorktreePreparation } from "../port/workspace";
afterEach(() => { cleanup(); vi.useRealTimers(); });
const status = (phase: string): WorktreePreparation => ({ id: "operation", phase, ref: "refs/heads/main", sourceRoot: "/source/project", workspaceRoot: "/worktrees/project", branch: "reasonix/task", startedAt: Date.now() });
const draw = (phase: string, code?: string) => {
const retry = vi.fn(), cancel = vi.fn(), edit = vi.fn();
const view = render(<WorktreeSetupPage stage={{ ...status(phase), code, detail: "diagnostic" }} task="保留的任务" files={["input.txt"]} busy={false} onRetry={retry} onCancel={cancel} onEdit={edit}/>);
return { ...view, retry, cancel, edit };
};
it("retains task and attachments during a long wait and lets the user cancel", () => {
vi.useFakeTimers();
const { cancel } = draw("checking_out");
expect(screen.getByText("保留的任务")).toBeTruthy();
expect(screen.getByText("input.txt")).toBeTruthy();
act(() => vi.advanceTimersByTime(16000));
expect(screen.getByRole("status").textContent).toContain("仍在等待");
fireEvent.click(screen.getByRole("button", { name: "取消创建" }));
expect(cancel).toHaveBeenCalledOnce();
});
it("keeps failure details behind disclosure and retries without losing the task", () => {
const { retry } = draw("failed", "workspace.worktree_timeout");
expect(screen.queryByText("diagnostic")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "查看详情" }));
expect(screen.getByText("diagnostic")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重试创建" }));
expect(retry).toHaveBeenCalledOnce();
expect(screen.getByText("保留的任务")).toBeTruthy();
});
it("does not offer another creation while cleanup is uncertain", () => {
draw("cleanup_failed", "workspace.worktree_cleanup_failed");
expect(screen.queryByRole("button", { name: "重试创建" })).toBeNull();
const cancel = screen.queryByRole("button", { name: "取消创建" }) as HTMLButtonElement | null;
expect(cancel === null || cancel.disabled).toBe(true);
expect(screen.getByRole("note").textContent).toContain("会话菜单");
expect(screen.getByRole("note").textContent).toContain("源目录");
expect(screen.getByText("已保留")).toBeTruthy();
});
it("restores editing after confirmed cancellation and exposes the retained destination", () => {
const { edit } = draw("cancelled");
fireEvent.click(screen.getByRole("button", { name: "返回编辑" }));
expect(edit).toHaveBeenCalledOnce();
expect(screen.getByText("input.txt")).toBeTruthy();
});
it("does not claim automatic submission when a late cancellation retained the workspace", () => {
draw("retained");
expect(screen.getByText("目录已经创建成功,取消未能撤回目录切换。任务尚未发送。")).toBeTruthy();
expect(screen.queryByText("即将进入对话,自动开始任务。")).toBeNull();
});
it("moves keyboard focus to the preparation heading on entry", () => {
draw("checking_out");
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
});
it("recovers focus when cancellation removes its button without stealing focus from details", () => {
const { rerender } = draw("checking_out");
const props = { task: "task", files: [], busy: false, onRetry: vi.fn(), onCancel: vi.fn(), onEdit: vi.fn() };
screen.getByRole("button", { name: "查看详情" }).focus();
rerender(<WorktreeSetupPage {...props} stage={status("creating")}/>);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "查看详情" }));
screen.getByRole("button", { name: "取消创建" }).focus();
rerender(<WorktreeSetupPage {...props} stage={status("cancelled")}/>);
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
});
it.each(["failed", "cancelled", "retained", "ready", "unreachable", "cleanup_failed"])("does not describe %s as still waiting", (phase) => {
draw(phase);
expect(screen.queryByText(/已等待 \d+ 秒/)).toBeNull();
});
it("names retained cleanup failure directly even without an error code", () => {
draw("cleanup_failed");
expect(screen.getByText("清理未完成")).toBeTruthy();
expect(screen.queryByText("正在等待状态确认")).toBeNull();
});
it("identifies details disclosure separately from workspace configuration", () => {
draw("failed");
expect(screen.getByRole("button", { name: "查看详情" }).getAttribute("data-action")).toBe("workspace.prepare-details");
expect(screen.getByRole("button", { name: "返回编辑" }).getAttribute("data-action")).toBe("workspace.prepare-edit");
});
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/port";
import type { WorktreePreparation } from "../port/workspace";
import { StudioIcon } from "./StudioIcon";
import { CopyButton } from "./CopyButton";
import { WorkspacePath, compactWorkspaceValue, workspaceName } from "./WorkspacePath";
import "../styles/worktree-setup.css";
const phases: Record<string, string> = {
starting: "正在提交创建请求", validating: "正在检查起始分支", creating: "正在创建工作目录",
checking_out: "正在检出已提交文件", copying_attachments: "正在复制任务附件", binding: "正在绑定工作目录",
cleaning: "正在清理未完成目录", cancelling: "正在中止并清理", ready: "工作目录已绑定",
cleanup_failed: "清理未完成", failed: "创建未完成", cancelled: "创建已中止,目录已清理",
retained: "工作目录已绑定", unreachable: "正在等待状态确认",
};
interface Props {
stage: WorktreePreparation;
task: string;
files: string[];
busy: boolean;
onRetry: () => void;
onCancel: () => void;
onEdit: () => void;
}
export function WorktreeSetupPage({ stage, task, files, busy, onRetry, onCancel, onEdit }: Props) {
const [now, setNow] = useState(Date.now);
const [details, setDetails] = useState(false);
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => { heading.current?.focus(); }, [stage.id]);
useEffect(() => { if (document.activeElement === document.body) heading.current?.focus(); }, [stage.phase]);
const failed = stage.phase === "failed";
const cancelled = stage.phase === "cancelled";
const retained = stage.phase === "retained";
const uncertain = stage.phase === "unreachable" || stage.phase === "cleanup_failed";
const ready = stage.phase === "ready";
const cancelling = stage.phase === "cancelling";
const active = !failed && !cancelled && !retained && !ready && !uncertain;
useEffect(() => {
if (!active) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [active]);
useEffect(() => { setDetails(false); setNow(Date.now()); }, [stage.id]);
const elapsed = Math.max(0, Math.floor((now - stage.startedAt) / 1000));
const slow = active && !cancelling && elapsed >= 15;
const headline = failed ? "worktree 创建失败" : cancelled ? "已取消创建" : retained || ready ? "工作目录已就绪" : uncertain ? "需要确认创建结果" : cancelling ? "正在取消创建…" : "正在创建 worktree…";
const description = failed ? "任务尚未发送,输入和附件已为你保留。" : cancelled ? "任务尚未发送,可以继续编辑。" : retained ? "目录已经创建成功,取消未能撤回目录切换。任务尚未发送。" : ready ? "即将进入对话,自动开始任务。" : uncertain ? "暂不能确认操作或清理结果,请先查看详情并核对。" : cancelling ? "正在中止操作并清理未完成的目录。" : slow ? "创建耗时较长,尚未收到完成确认。" : "准备完成后将自动开始任务。";
const error = stage.code ? reason(new HttpError(409, stage.detail || stage.code, { code: stage.code })) : "";
const icon = ready || retained ? "check" : failed || uncertain ? "warning" : cancelled ? "close" : "clock";
const phase = error || t(phases[stage.phase] || "正在等待状态确认");
return <section className="worktree-setup" data-phase={stage.phase} aria-label={t("工作目录准备")}>
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立 worktree")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
<div className="worktree-setup-phase"><span>{active ? <span className="worktree-spinner"/> : <StudioIcon name={icon}/>}<span>{phase}</span></span>{active && <span className="worktree-setup-elapsed">{t("已等待 {n} 秒", { n: elapsed })}</span>}</div>
{slow && <p className="worktree-setup-slow" role="status">{t("仍在等待创建完成。你可以继续等待、查看详情,或取消创建。")}</p>}
{stage.phase === "cleanup_failed" && <p className="worktree-setup-slow" role="note">{t("请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。")}</p>}
<div className="worktree-setup-branches"><dl><dt>{t("起始分支")}</dt><dd title={stage.ref}><StudioIcon name="branch"/>{compactWorkspaceValue(stage.ref.replace(/^refs\/(heads|remotes)\//, ""), 48)}</dd><dt>{t("任务分支")}</dt><dd title={stage.branch}><StudioIcon name="branch"/>{stage.branch ? compactWorkspaceValue(stage.branch, 48) : t("自动创建")}</dd></dl></div>
<div className="worktree-setup-draft"><span className="worktree-setup-label">{t("用户任务")}</span><p className="worktree-setup-task">{task || t("请查看附件")}</p>{files.length > 0 && <div className="worktree-setup-files"><span className="worktree-setup-label">{t("附件({n})", { n: files.length })}</span>{files.map((file, index) => <div key={`${index}:${file}`}><StudioIcon name="file"/><span>{file}</span><small>{t(ready ? "待交接" : failed || cancelled || retained || uncertain ? "已保留" : "待发送")}</small></div>)}</div>}</div>
<div className="worktree-setup-location"><WorkspacePath path={failed || cancelled ? stage.sourceRoot : stage.workspaceRoot || stage.sourceRoot}/></div>
{details && <div className="worktree-setup-details"><dl><dt>{t("操作编号")}</dt><dd>{stage.id}<CopyButton text={stage.id} iconOnly label={t("复制操作编号")}/></dd><dt>{t("当前状态")}</dt><dd>{stage.phase}</dd></dl>{stage.detail && <pre>{stage.detail}</pre>}</div>}
<footer className="worktree-setup-actions"><button className="worktree-setup-detail-button" data-action="workspace.prepare-details" onClick={() => setDetails(!details)} aria-expanded={details}><StudioIcon name="file"/>{t(details ? "收起详情" : "查看详情")}</button><div>{cancelled || retained || failed ? <button className="btn" data-action="workspace.prepare-edit" disabled={busy} onClick={onEdit}>{t("返回编辑")}</button> : null}{failed || (uncertain && stage.phase !== "cleanup_failed") ? <button className="btn" data-primary="" data-action="workspace.isolate" disabled={busy} onClick={onRetry}><StudioIcon name="refresh"/>{t(failed ? "重试创建" : "再次核对")}</button> : null}{ready ? <span className="worktree-setup-handoff"><StudioIcon name="check"/>{t("任务和附件正在交接…")}</span> : active || stage.phase === "unreachable" ? <button className="btn" data-action="workspace.prepare-cancel" disabled={cancelling} onClick={onCancel}>{t(cancelling ? "正在取消…" : "取消创建")}</button> : null}</div></footer>
</section>;
}
@@ -0,0 +1,49 @@
import { t } from "../i18n";
import type { Attachment } from "../port/port";
import type { PasteChip } from "./pastekeep";
// What is riding along with this turn. An attachment travels as bytes or a path
// the kernel already saved; a long paste is held out of the box so eight hundred
// lines of log do not become the composer.
export type Chip =
| {
k: "attachment";
id: string;
name: string;
state: "adding" | "ready" | "failed";
a?: Attachment;
blob?: File;
url?: string;
error?: string;
}
| PasteChip
| { k: "quote"; id: string; body: string; turn?: number; source?: string; lines: number };
export function chipName(c: Chip): string {
if (c.k === "quote" && c.source) return t("引用 {name}", { name: c.source.split("/").at(-1) ?? c.source });
if (c.k === "quote") return c.turn === undefined ? t("引用回复") : t("引用第 {n} 轮回复", { n: c.turn });
return c.k === "paste" ? c.name ?? t("粘贴的文本") : c.name;
}
// Provider-visible quotes use the kernel's turn identity when available.
export function quoteBlock(c: Extract<Chip, { k: "quote" }>): string {
// The path is a file name the workspace chose, so it cannot end the line or close the bracket.
const where = c.source?.replace(/[\u0000-\u001f\u007f\u2028\u2029[\]]/g, " ").trim();
const head = where
? `[Quoting text selected in the workspace file ${where}.]`
: c.turn === undefined
? "[Quoting an earlier assistant reply in this conversation.]"
: `[Quoting the assistant reply from turn ${c.turn}.]`;
return [head, ...c.body.split("\n").map((line) => `> ${line}`)].join("\n");
}
export function isPicture(c: Chip): boolean {
return c.k === "attachment" && (c.a?.image === true || (c.a?.image !== false && c.blob?.type.startsWith("image/") === true));
}
export function releaseChip(c: Chip) {
if (c.k === "attachment" && c.url) URL.revokeObjectURL(c.url);
}
let chipSeq = 0;
export const chipId = () => `c${++chipSeq}`;
@@ -36,3 +36,18 @@ export function useLaunchHealth(port: AgentPort | null, setup: ProviderSetup | n
return () => clearTimeout(timer);
}, [port, setup, welcomed]);
}
/** Reads what the active machine says about setup and the welcome. A machine
* that cannot answer has met the app before as far as we care: the sequence
* must never be what stands between someone and their session. */
export function useFirstRunRead(port: AgentPort | null, setSetup: (v: ProviderSetup | null) => void, setWelcomed: (v: boolean) => void) {
useEffect(() => {
if (!port) return;
let alive = true;
port.providerSetup().then((v) => alive && setSetup(v)).catch(() => alive && setSetup(null));
port.welcomeSeen().then((v) => alive && setWelcomed(v)).catch(() => alive && setWelcomed(true));
return () => {
alive = false;
};
}, [port, setSetup, setWelcomed]);
}
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import "./testkit";
import { MockPort } from "../port/mock";
import type { WireEvent } from "../port/wire";
import { Pane, type PaneReport } from "./Pane";
afterEach(() => { cleanup(); vi.useRealTimers(); });
// A pane the person is not looking at. Its state of running comes from two
// reads of the kernel — the frames of its stream, and the status snapshot a turn
// boundary asks for — and when the network drops under the second one the pane
// has no other way to hear that the turn ended.
async function mount() {
const port = new MockPort();
const snapshot = await port.status();
const kernel = { running: false, failing: 0, reads: 0 };
vi.spyOn(port, "status").mockImplementation(async () => {
kernel.reads++;
if (kernel.failing > 0) {
kernel.failing--;
throw new TypeError("network down");
}
return { ...snapshot, running: kernel.running };
});
let deliver: ((event: WireEvent) => void) | undefined;
vi.spyOn(port, "subscribe").mockImplementation((onEvent) => {
deliver = onEvent;
return () => { deliver = undefined; return true; };
});
const onReport = vi.fn<(id: string, report: PaneReport) => void>();
const view = render(
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath: "/sessions/a.jsonl" }} title="a" active={false}
visible={false} sideHost={null} side={false} onFocus={() => {}} onReport={onReport} onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}}
needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}} theme="light" dockW={320} dockMax={640} onDockW={() => {}} />,
);
const send = (kind: WireEvent["kind"]) => act(() => deliver?.({ kind }));
const lastLive = () => onReport.mock.calls.at(-1)?.[1].live;
return { kernel, send, onReport, lastLive, view };
}
it("hears that a turn ended even when the read that confirms it fails", async () => {
const pane = await mount();
pane.kernel.running = true;
await pane.send("turn_started");
await waitFor(() => expect(pane.lastLive()).toBe(true));
pane.kernel.running = false;
pane.kernel.failing = 1;
await pane.send("turn_done");
await waitFor(() => expect(pane.lastLive()).toBe(false), { timeout: 4000 });
});
it("keeps asking while the network stays down, and not in a tight loop", async () => {
const pane = await mount();
pane.kernel.running = true;
await pane.send("turn_started");
await waitFor(() => expect(pane.lastLive()).toBe(true));
pane.kernel.running = false;
pane.kernel.failing = 3;
const before = pane.kernel.reads;
await pane.send("turn_done");
await waitFor(() => expect(pane.lastLive()).toBe(false), { timeout: 9000 });
const asked = pane.kernel.reads - before;
expect(asked).toBeGreaterThanOrEqual(4);
expect(asked).toBeLessThan(8);
}, 15000);
it("stops asking when the pane goes away", async () => {
const pane = await mount();
pane.kernel.running = true;
await pane.send("turn_started");
await waitFor(() => expect(pane.lastLive()).toBe(true));
pane.kernel.failing = 100;
await pane.send("turn_done");
pane.view.unmount();
const at = pane.kernel.reads;
await new Promise((resolve) => setTimeout(resolve, 2500));
expect(pane.kernel.reads).toBe(at);
}, 10000);
@@ -5,7 +5,7 @@ import "./testkit";
import { Pane } from "./Pane";
import { ToastStack } from "./ToastStack";
import { MockPort } from "../port/mock";
import { boot, STORAGE } from "../i18n";
import { boot, STORAGE, t } from "../i18n";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/port";
import type { AgentPort } from "../port/port";
@@ -61,13 +61,14 @@ describe("the explorer during a turn", () => {
// not live.
it("updates the composer's branch chip after a turn ends", async () => {
const pane = open();
expect(await screen.findByText("main")).toBeTruthy();
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
expect(chip.textContent).toContain("main");
pane.send({ kind: "turn_started" });
await waitFor(() => expect(pane.changes).toHaveBeenCalled());
pane.port.branchState = { ...pane.port.branchState, branch: "studio" };
pane.send({ kind: "turn_done" });
await waitFor(() => expect(screen.getByText("studio")).toBeTruthy());
await waitFor(() => expect(chip.textContent).toContain("studio"));
});
});
@@ -76,7 +77,7 @@ describe("branch switch results in the pane", () => {
for (const changesFail of [false, true]) {
it(`uses the switch response while the change list ${changesFail ? "fails" : "is pending"}`, async () => {
const pane = open();
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
const reading = vi.spyOn(pane.port, "workspaceGit").mockImplementation(() => new Promise(() => {}));
pane.changes.mockClear();
pane.changes.mockImplementation(() => changesFail
@@ -85,9 +86,9 @@ describe("branch switch results in the pane", () => {
const change = vi.spyOn(pane.port, "switchBranch");
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(chip.getAttribute("aria-label")).toContain("studio"));
await waitFor(() => expect(chip.textContent).toContain("studio"));
expect(change).toHaveBeenCalledExactlyOnceWith("studio");
expect(pane.changes).toHaveBeenCalledTimes(1);
expect(reading).not.toHaveBeenCalled();
@@ -103,16 +104,16 @@ describe("branch switch results in the pane", () => {
describe("branch switch in flight", () => {
it("says which branch it is moving to until the switch answers, then names the branch", async () => {
const pane = open();
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
let land: (git: Awaited<ReturnType<typeof pane.port.switchBranch>>) => void = () => {};
const done = new Promise<Awaited<ReturnType<typeof pane.port.switchBranch>>>((resolve) => { land = resolve; });
vi.spyOn(pane.port, "switchBranch").mockImplementation(() => done);
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(chip.textContent).toContain("正在切换到 studio…"));
expect(chip.getAttribute("aria-label")).toContain("main");
expect(chip.title).toContain("main");
expect(chip.hasAttribute("data-pending")).toBe(true);
land({ ...pane.port.branchState, branch: "studio" });
@@ -122,10 +123,10 @@ describe("branch switch in flight", () => {
it("goes back to the current branch when the switch is refused", async () => {
const pane = open();
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
vi.spyOn(pane.port, "switchBranch").mockRejectedValue(new HttpError(409, "busy", { code: "branch.turn_running", error: "busy" }));
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(chip.textContent).not.toContain("正在切换"));
expect(chip.textContent).toContain("main");
});
@@ -142,16 +143,15 @@ describe("branch switch refusals in the pane", () => {
const refusal = reason(error);
expect(refusal).not.toBe("fixture fallback");
const change = vi.spyOn(pane.port, "switchBranch").mockRejectedValue(error);
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
expect(await screen.findByText(refusal)).toBeTruthy();
expect(change).toHaveBeenCalledWith("studio");
expect(screen.queryByText("fixture fallback")).toBeNull();
await waitFor(() => expect(chip.hasAttribute("disabled")).toBe(false));
expect(chip.textContent).toContain("main");
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(change).toHaveBeenCalledTimes(2));
});
}
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { MockPort } from "../port/mock";
import type { PeerThread, PeerThreadState } from "../port/boundary";
import { PeerThreadMark, PeerThreadsProvider } from "./peerthreads";
afterEach(cleanup);
const thread = (state: PeerThreadState, over: Partial<PeerThread> = {}): PeerThread => ({
key: "peer-1",
title: "Docs session",
project: "site",
sessionPath: "/sessions/docs.jsonl",
state,
since: "2026-10-16T10:00:00Z",
messageId: "m1",
...over,
});
function portWith(threads: PeerThread[]) {
const port = new MockPort();
vi.spyOn(port, "peers").mockResolvedValue({ enabled: true, peers: [], held: [], threads });
return port;
}
function draw(port: MockPort, onOpen = vi.fn(), listeners: { fire: () => void } = { fire: () => {} }) {
const hub = { onTreeChanged: (l: () => void) => { listeners.fire = l; return () => {}; } };
render(
<PeerThreadsProvider port={port} hub={hub} onOpen={onOpen}>
<PeerThreadMark path="/sessions/docs.jsonl" />
<PeerThreadMark path="/sessions/other.jsonl" />
</PeerThreadsProvider>,
);
return { onOpen, listeners };
}
describe("a dispatch indicator on the peer's session", () => {
it.each<[PeerThreadState, RegExp]>([
["waiting", /等对方的用户批准/],
["working", /已放行/],
["replied", /回复了你/],
["declined", /拒绝了你/],
])("says %s with the peer's title and project", async (state, words) => {
draw(portWith([thread(state)]));
const mark = await screen.findByRole("button", { name: words });
expect(mark.getAttribute("data-state")).toBe(state);
expect(mark.getAttribute("title")).toMatch(/Docs session/);
expect(mark.getAttribute("title")).toMatch(/site/);
});
it("draws nothing on a session that was never written to", async () => {
draw(portWith([thread("replied")]));
await screen.findByRole("button", { name: /回复了你/ });
expect(screen.getAllByRole("button")).toHaveLength(1);
});
it("draws nothing while the kernel lists no threads", async () => {
const port = portWith([]);
draw(port);
await waitFor(() => expect(port.peers).toHaveBeenCalled());
expect(screen.queryByRole("button")).toBeNull();
});
it("jumps to the peer's session when clicked, and only that", async () => {
const { onOpen } = draw(portWith([thread("replied")]));
await userEvent.click(await screen.findByRole("button", { name: /回复了你/ }));
expect(onOpen).toHaveBeenCalledExactlyOnceWith("/sessions/docs.jsonl");
});
it("does not let the click reach the row it sits in", async () => {
const rowClick = vi.fn();
const port = portWith([thread("working")]);
render(
<PeerThreadsProvider port={port} hub={{}} onOpen={() => {}}>
<div onClick={rowClick}><PeerThreadMark path="/sessions/docs.jsonl" /></div>
</PeerThreadsProvider>,
);
await userEvent.click(await screen.findByRole("button", { name: /已放行/ }));
expect(rowClick).not.toHaveBeenCalled();
});
it("reads /peers again when the tree changes, and follows the new state", async () => {
const port = portWith([thread("waiting")]);
const { listeners } = draw(port);
await screen.findByRole("button", { name: /等对方的用户批准/ });
vi.spyOn(port, "peers").mockResolvedValue({ enabled: true, peers: [], held: [], threads: [thread("replied")] });
act(() => listeners.fire());
expect(await screen.findByRole("button", { name: /回复了你/ })).toBeTruthy();
expect(screen.queryByRole("button", { name: /等对方的用户批准/ })).toBeNull();
});
});
@@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useEffect, useMemo, type MouseEvent, type ReactNode } from "react";
import { t } from "../i18n";
import type { PeerThread, PeerThreadState } from "../port/boundary";
import type { AgentPort } from "../port/port";
import { StudioIcon, type StudioIconName } from "./StudioIcon";
import { usePeers } from "./usePeers";
interface Threads {
by: ReadonlyMap<string, PeerThread>;
open: (path: string) => void;
}
const NONE: Threads = { by: new Map(), open: () => {} };
const PeerThreadsContext = createContext<Threads>(NONE);
/** The sessions this one has written to, keyed by the peer's transcript path.
* The kernel's ledger decides each state; the tree-change event that already
* moves held messages is what asks for a new reading. */
export function PeerThreadsProvider({ port, hub, onOpen, children }: {
port: AgentPort | null;
hub: { onTreeChanged?: (listener: () => void) => () => void };
onOpen: (path: string) => void;
children: ReactNode;
}) {
const { state, refresh } = usePeers(port, 0);
useEffect(() => hub.onTreeChanged?.(() => void refresh()), [hub, refresh]);
const by = useMemo(() => {
const map = new Map<string, PeerThread>();
if (state.enabled) for (const th of state.threads ?? []) if (th.sessionPath) map.set(th.sessionPath, th);
return map;
}, [state]);
const value = useMemo(() => ({ by, open: onOpen }), [by, onOpen]);
return <PeerThreadsContext.Provider value={value}>{children}</PeerThreadsContext.Provider>;
}
const ICON: Record<PeerThreadState, StudioIconName> = {
waiting: "clock",
working: "refresh",
replied: "chat",
declined: "close",
};
function wording(th: PeerThread): string {
const p = { title: th.title, project: th.project };
switch (th.state) {
case "waiting": return t("已发给「{title}」({project}),等对方的用户批准", p);
case "working": return t("「{title}」({project})已放行,正在处理你的消息", p);
case "replied": return t("「{title}」({project})回复了你", p);
case "declined": return t("「{title}」({project})的用户拒绝了你的消息", p);
}
}
/** One small icon on the row or tab of a session this one has written to. */
export function PeerThreadMark({ path }: { path?: string }) {
const { by, open } = useContext(PeerThreadsContext);
const th = path ? by.get(path) : undefined;
const go = useCallback((ev: MouseEvent) => {
ev.stopPropagation();
if (th?.sessionPath) open(th.sessionPath);
}, [th, open]);
if (!th) return null;
const label = wording(th);
return (
<button type="button" className="peerthread" data-state={th.state} data-action="peer.thread-open" title={label} aria-label={label} onClick={go}>
<StudioIcon name={ICON[th.state]} />
</button>
);
}
@@ -35,6 +35,13 @@ describe("the folders listed under a remote machine", () => {
expect(warm[0].sessions).toHaveLength(3);
});
it("keeps a booked worktree inside the far kernel's original project", () => {
const project = { ...far("/srv/train", 2), workspaceRoots: ["/srv/train", "/state/worktrees/train"] };
const warm = remoteWorkspaces(host({ workspaces: ["/state/worktrees/train"] }), [project]);
expect(roots(warm)).toEqual(["/srv/train"]);
expect(warm[0].booked).toBe(true);
});
it("keeps a folder added here that has never been opened over there", () => {
// Otherwise adding a project makes it vanish the moment the host connects.
const warm = remoteWorkspaces(host({ workspaces: ["/srv/train", "/srv/eval"] }), [far("/srv/train", 2)]);
+8 -5
View File
@@ -3,11 +3,12 @@ import type { AgentPort, PeersState } from "../port/port";
const EMPTY: PeersState = { enabled: false, peers: [], held: [] };
export function usePeers(port: AgentPort, everyMs: number) {
export function usePeers(port: AgentPort | null, everyMs: number) {
const [state, setState] = useState<PeersState>(EMPTY);
const [failed, setFailed] = useState(false);
const alive = useRef(true);
const refresh = useCallback(async () => {
if (!port) return;
try {
const next = await port.peers();
if (alive.current) {
@@ -21,12 +22,14 @@ export function usePeers(port: AgentPort, everyMs: number) {
useEffect(() => {
alive.current = true;
void refresh();
const timer = setInterval(() => {
if (typeof document === "undefined" || !document.hidden) void refresh();
}, everyMs);
const timer = everyMs > 0
? setInterval(() => {
if (typeof document === "undefined" || !document.hidden) void refresh();
}, everyMs)
: null;
return () => {
alive.current = false;
clearInterval(timer);
if (timer) clearInterval(timer);
};
}, [refresh, everyMs]);
return { state, failed, refresh };
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef } from "react";
import type { AgentPort, SessionStatus } from "../port/port";
const FIRST_MS = 1000;
const CAP_MS = 15_000;
/** One way to ask the kernel for a pane's status that does not give up. A turn
* boundary is the moment the snapshot is asked for, and the snapshot is what
* closes or vouches for a run, so a read lost to the network leaves the pane
* believing what it last heard — for a pane nobody is looking at, nothing else
* asks again. A failed read is therefore asked again with growing gaps until it
* answers, and stops when the pane goes away. */
export function useStatusRefresh(port: AgentPort, adopt: (status: SessionStatus) => void, settled: () => void) {
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const tries = useRef(0);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
clearTimeout(timer.current);
tries.current = 0;
};
}, [port]);
const refresh = useCallback((): Promise<void> => port.status().then(
(status) => {
clearTimeout(timer.current);
tries.current = 0;
adopt(status);
settled();
},
() => {
if (!alive.current) return;
clearTimeout(timer.current);
const gap = Math.min(CAP_MS, FIRST_MS * 2 ** tries.current++);
timer.current = setTimeout(() => void refresh(), gap);
},
), [port, adopt, settled]);
return refresh;
}
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import { useWorkspaceBranches } from "./useWorkspaceBranches";
import type { WorkspaceGit } from "../port/port";
afterEach(cleanup);
it("releases the switch state on port change and ignores the old completion", async () => {
const old = new MockPort(), next = new MockPort();
let resolve!: (value: WorkspaceGit) => void;
vi.spyOn(old, "switchBranch").mockImplementation(() => new Promise((done) => { resolve = done; }));
const changed = vi.fn(), switched = vi.fn(), error = vi.fn();
const { result, rerender } = renderHook(({ port }) => useWorkspaceBranches(port, changed, switched, error), { initialProps: { port: old } });
act(() => result.current.switchTo("studio"));
expect(result.current.moving).toBe("studio");
rerender({ port: next });
expect(result.current.moving).toBeNull();
await act(async () => { resolve(await next.workspaceGit()); });
expect(changed).not.toHaveBeenCalled();
expect(switched).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
@@ -0,0 +1,26 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentPort, WorkspaceBranch, WorkspaceGit } from "../port/port";
export function useWorkspaceBranches(port: AgentPort, onChanged: () => void, onSwitched: ((git: WorkspaceGit) => void) | undefined, onError: (error: unknown) => void, root = "") {
const [locals, setLocals] = useState<WorkspaceBranch[]>([]);
const [moving, setMoving] = useState<string | null>(null);
const [reading, setReading] = useState<"idle" | "loading" | "ready" | "failed">("idle");
const generation = useRef(0);
useEffect(() => { generation.current++; setLocals([]); setMoving(null); setReading("idle"); return () => { generation.current++; }; }, [port, root]);
const loadBranches = useCallback(() => {
const current = generation.current;
setReading("loading");
void port.branches().then((result) => { if (current === generation.current) { setLocals(result.branches); setReading("ready"); } }).catch((error) => { if (current === generation.current) { setReading("failed"); onError(error); } });
}, [port, onError, root]);
const switchTo = (name: string) => {
if (moving !== null) return;
const current = generation.current;
setMoving(name);
void port.switchBranch(name).then((git) => {
if (current !== generation.current) return;
onChanged(); onSwitched?.(git);
}).catch((error) => { if (current === generation.current) onError(error); })
.finally(() => { if (current === generation.current) setMoving(null); });
};
return { locals, moving, reading, loadBranches, switchTo };
}
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import { useWorktreeSetup } from "./useWorktreeSetup";
import type { WorktreePreparation, WorktreeSources } from "../port/workspace";
afterEach(cleanup);
const selection = { sourceRoot: "/source", ref: "refs/heads/main" };
const state = (id: string, phase: string): WorktreePreparation => ({ id, phase, ...selection, startedAt: Date.now(), workspaceRoot: "/target", branch: "task" });
const sources = (name: string, root = "/source"): WorktreeSources => ({ available: true, sourceRoot: root, sources: [{ ref: `refs/heads/${name}`, name, remote: false, current: true }] });
it.each(["root", "port", "session"])("does not publish an old explicit source refresh after %s changes", async (change) => {
const old = new MockPort(), next = new MockPort();
const nextRoot = change === "root" ? "/target" : "/source";
const read = vi.spyOn(old, "worktreeSources").mockResolvedValue(sources("before"));
vi.spyOn(next, "worktreeSources").mockResolvedValue(sources("after", nextRoot));
const { result, rerender } = renderHook(({ port, root, session }) => useWorktreeSetup(port, true, root, vi.fn(), undefined, session),
{ initialProps: { port: old, root: "/source", session: "before" } });
await waitFor(() => expect(result.current.sources?.sources[0].name).toBe("before"));
let resolve!: (value: WorktreeSources) => void;
read.mockImplementationOnce(() => new Promise((done) => { resolve = done; })).mockResolvedValue(sources("after", nextRoot));
let refresh!: Promise<WorktreeSources>;
act(() => { refresh = result.current.refresh(); });
rerender({ port: change === "port" ? next : old, root: nextRoot, session: change === "session" ? "after" : "before" });
await waitFor(() => expect(result.current.sources?.sources[0].name).toBe("after"));
await act(async () => { resolve(sources("stale")); await refresh; });
expect(result.current.sources?.sources[0].name).toBe("after");
});
it("keeps a manual source refresh when an older initial read completes later", async () => {
const port = new MockPort();
let resolve!: (value: WorktreeSources) => void;
vi.spyOn(port, "worktreeSources").mockImplementationOnce(() => new Promise((done) => { resolve = done; })).mockResolvedValue(sources("current"));
const { result } = renderHook(() => useWorktreeSetup(port, true, "/source", vi.fn()));
await act(async () => { await result.current.refresh(); });
await act(async () => { resolve(sources("stale")); });
expect(result.current.sources?.sources[0].name).toBe("current");
});
it("cancels an old port and never hands its completed workspace to the new pane", async () => {
const old = new MockPort(), next = new MockPort();
let resolve!: (value: WorktreePreparation) => void;
const start = vi.spyOn(old, "prepareWorktree").mockImplementation(() => new Promise((done) => { resolve = done; }));
const cancel = vi.spyOn(old, "cancelWorktreePreparation").mockImplementation(async (id) => state(id, "cancelled"));
const changed = vi.fn();
const { result, rerender } = renderHook(({ port }) => useWorktreeSetup(port, true, "/source", changed), { initialProps: { port: old } });
await act(async () => { result.current.setSelection(selection); });
let preparation!: Promise<boolean>;
act(() => { preparation = result.current.prepare([]); });
rerender({ port: next });
await act(async () => { resolve(state(start.mock.calls[0][0], "ready")); expect(await preparation).toBe(false); });
expect(cancel).toHaveBeenCalledWith(start.mock.calls[0][0]);
expect(changed).not.toHaveBeenCalled();
expect(result.current.stage).toBeNull();
});
it("keeps a published workspace but does not send when cancellation loses publication", async () => {
const port = new MockPort();
let resolve!: (value: WorktreePreparation) => void;
const start = vi.spyOn(port, "prepareWorktree").mockImplementation(() => new Promise((done) => { resolve = done; }));
vi.spyOn(port, "cancelWorktreePreparation").mockImplementation(async (id) => state(id, "ready"));
const { result } = renderHook(() => useWorktreeSetup(port, true, "/source", vi.fn()));
await act(async () => { result.current.setSelection(selection); });
let preparation!: Promise<boolean>;
act(() => { preparation = result.current.prepare([]); });
await act(async () => { await result.current.cancel(); });
await act(async () => { resolve(state(start.mock.calls[0][0], "ready")); expect(await preparation).toBe(false); });
expect(result.current.stage?.phase).toBe("retained");
});
it("does not create a replacement when the first operation cannot be reconciled", async () => {
const port = new MockPort();
const start = vi.spyOn(port, "prepareWorktree").mockRejectedValue(new Error("lost acknowledgement"));
const read = vi.spyOn(port, "worktreePreparation").mockRejectedValue(new Error("offline"));
const { result } = renderHook(() => useWorktreeSetup(port, true, "/source", vi.fn()));
await act(async () => { result.current.setSelection(selection); });
await act(async () => { await expect(result.current.prepare([])).rejects.toThrow(); });
await waitFor(() => expect(result.current.stage?.phase).toBe("unreachable"));
await act(async () => { await expect(result.current.prepare([])).rejects.toThrow(); });
expect(start).toHaveBeenCalledOnce();
expect(read.mock.calls.every(([id]) => id === start.mock.calls[0][0])).toBe(true);
});
+122 -31
View File
@@ -1,47 +1,138 @@
import { useEffect, useRef, useState } from "react";
import { reason } from "../i18n/kernel";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentPort, WorkspaceGit } from "../port/port";
import type { WorktreeSelection, WorktreeSources } from "../port/workspace";
import type { WorktreePreparation, WorktreeSelection, WorktreeSources } from "../port/workspace";
export function useWorktreeSetup(port: AgentPort, fresh: boolean, root: string, onChanged: () => void, onTreeChanged?: (git: WorkspaceGit) => void) {
export class WorktreeSetupFailure extends Error {}
interface Attempt {
id: string;
port: AgentPort;
selection: WorktreeSelection;
session: string;
status: WorktreePreparation;
valid: boolean;
cancelRequested: boolean;
}
const terminal = (phase: string) => ["ready", "failed", "cancelled", "cleanup_failed"].includes(phase);
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export function useWorktreeSetup(port: AgentPort, fresh: boolean, root: string, onChanged: () => void, onTreeChanged?: (git: WorkspaceGit) => void, session = "") {
const [sources, setSources] = useState<WorktreeSources | null>(null);
const [selection, setSelection] = useState<WorktreeSelection | null>(null);
const prepared = useRef(false);
useEffect(() => {
let live = true;
setSources(null);
setSelection(null);
prepared.current = false;
if (fresh) void port.worktreeSources().then((value) => {
if (live) setSources(value);
}).catch(() => { if (live) setSources(null); });
return () => { live = false; };
}, [port, fresh, root]);
const refresh = async () => {
const [stage, setStage] = useState<WorktreePreparation | null>(null);
const attempt = useRef<Attempt | null>(null);
const prepared = useRef("");
const mounted = useRef(true);
const sourceRead = useRef(0);
const refresh = useCallback(async () => {
const read = ++sourceRead.current;
const value = await port.worktreeSources();
setSources(value);
if (mounted.current && read === sourceRead.current) setSources(value);
return value;
}, [port]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
const current = attempt.current;
if (current) {
current.valid = false;
if (!terminal(current.status.phase)) void current.port.cancelWorktreePreparation(current.id).catch(() => {});
}
};
}, [port]);
useEffect(() => {
sourceRead.current++;
const current = attempt.current;
const ownMove = current && root !== current.selection.sourceRoot && root === current.status.workspaceRoot;
const same = current?.port === port && ((root === current.selection.sourceRoot && session === current.session) || ownMove);
if (!same) {
if (current) {
current.valid = false;
if (!terminal(current.status.phase)) void current.port.cancelWorktreePreparation(current.id).catch(() => {});
}
attempt.current = null;
setStage(null);
prepared.current = "";
setSelection(null);
setSources(null);
}
if (fresh && !same) void refresh().catch(() => {});
return () => { sourceRead.current++; };
}, [port, fresh, root, session, refresh]);
const show = (current: Attempt, value: WorktreePreparation) => {
current.status = value;
if (mounted.current && current.valid && attempt.current === current) setStage(value);
};
const prepare = async (attachments: string[]) => {
if (!selection || prepared.current) return;
if (prepared.current === root && root) return true;
if (!selection && !attempt.current) return true;
let current = attempt.current;
const reconcile = current && !["failed", "cancelled"].includes(current.status.phase);
if (!reconcile) {
if (!selection) throw new WorktreeSetupFailure("worktree source is unavailable");
const id = crypto.randomUUID();
current = { id, port, selection: attachments.length ? { ...selection, attachments } : selection, session, valid: true, cancelRequested: false,
status: { id, phase: "starting", sourceRoot: selection.sourceRoot, ref: selection.ref, startedAt: Date.now() } };
attempt.current = current;
}
if (!current) return false;
const active = current;
show(active, active.status);
try {
await port.isolateWorkspace(attachments.length ? { ...selection, attachments } : selection);
} catch (error) {
const current = await port.status().catch(() => null);
if (current?.workspaceRoot && current.workspaceRoot !== selection.sourceRoot) {
prepared.current = true;
let value: WorktreePreparation;
try {
value = reconcile ? await active.port.worktreePreparation(active.id) : await active.port.prepareWorktree(active.id, active.selection);
} catch {
value = await active.port.worktreePreparation(active.id);
}
while (active.valid && mounted.current) {
show(active, value);
if (terminal(value.phase)) break;
await pause(400);
if (!active.valid || !mounted.current) return false;
value = await active.port.worktreePreparation(active.id);
}
if (!active.valid || !mounted.current) return false;
if (value.phase === "ready" && value.workspaceRoot) {
prepared.current = value.workspaceRoot;
setSelection(null);
setSources(null);
onChanged();
void port.workspaceGit().then(onTreeChanged).catch(() => {});
void active.port.workspaceGit().then(onTreeChanged).catch(() => {});
if (active.cancelRequested) { show(active, { ...value, phase: "retained" }); return false; }
await pause(650);
return active.valid && mounted.current && !active.cancelRequested;
}
throw error;
if (value.phase === "cancelled") return false;
throw new WorktreeSetupFailure(value.detail || value.code || "worktree creation failed");
} catch (error) {
if (active.valid && mounted.current && !(error instanceof WorktreeSetupFailure)) {
show(active, { ...active.status, phase: "unreachable", detail: reason(error) });
}
throw error instanceof WorktreeSetupFailure ? error : new WorktreeSetupFailure("worktree status could not be reconciled");
}
prepared.current = true;
setSelection(null);
setSources(null);
onChanged();
void port.workspaceGit().then(onTreeChanged).catch(() => {});
};
return { sources, selection, setSelection, refresh, prepare };
const cancel = async () => {
const current = attempt.current;
if (!current) return;
current.cancelRequested = true;
show(current, { ...current.status, phase: "cancelling" });
try {
const value = await current.port.cancelWorktreePreparation(current.id);
show(current, value.phase === "ready" ? { ...value, phase: "retained" } : value);
} catch (error) {
show(current, { ...current.status, phase: "unreachable", detail: reason(error) });
}
};
const dismiss = () => {
const current = attempt.current;
if (current && !["ready", "retained", "failed", "cancelled"].includes(current.status.phase)) return;
setStage(null);
if (current?.status.phase === "failed" || current?.status.phase === "cancelled") attempt.current = null;
if (fresh && prepared.current !== root) void refresh().catch(() => {});
};
return { sources, selection, setSelection, refresh, prepare, stage, cancel, dismiss };
}
@@ -5,23 +5,25 @@ import { Composer } from "./Composer";
import { MockPort } from "../port/mock";
import type { SessionStatus } from "../port/port";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
function draw(fresh = true) {
function draw(fresh = true, branch = "main", isolated = false) {
const port = new MockPort();
const readSources = port.worktreeSources.bind(port);
vi.spyOn(port, "worktreeSources").mockImplementation(async () => ({ ...await readSources(), sourceRoot: "/work/demo" }));
vi.spyOn(port, "status").mockResolvedValue({ workspaceRoot: "~/projects/DeepSeek-Reasonix" } as SessionStatus);
vi.spyOn(port, "workspaces").mockResolvedValue({ current: "/work/demo", canSwitch: true, canIsolate: true, recents: [], isolated });
const isolate = vi.spyOn(port, "isolateWorkspace").mockResolvedValue();
const submit = vi.fn(async () => true);
const error = vi.fn();
const status = { workspaceRoot: "/work/demo", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", toolApprovalMode: "ask", plan: false } as SessionStatus;
render(<Composer port={port} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch: "main", detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
render(<Composer port={port} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch, detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
return { port, isolate, submit, error };
}
async function choose() {
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(screen.getByRole("switch", { name: "新建 worktree" }));
fireEvent.click(screen.getByRole("button", { name: /起始分支/ }));
fireEvent.click(await screen.findByRole("button", { name: "新建 worktree" }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "studio" } });
fireEvent.click(screen.getByRole("option", { name: /studio/ }));
fireEvent.keyDown(window, { key: "Escape" });
@@ -37,7 +39,7 @@ it("selects a source without switching the checkout and creates only when sendin
expect(switchBranch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(submit).toHaveBeenCalledWith("添加搜索功能"));
expect(isolate).toHaveBeenCalledExactlyOnceWith({ ref: "refs/heads/studio", sourceRoot: "~/projects/DeepSeek-Reasonix" });
expect(isolate).toHaveBeenCalledExactlyOnceWith({ ref: "refs/heads/studio", sourceRoot: "/work/demo" });
expect(isolate.mock.invocationCallOrder[0]).toBeLessThan(submit.mock.invocationCallOrder[0]);
});
@@ -46,9 +48,9 @@ it("restores the draft on creation failure and retries without submitting in the
isolate.mockRejectedValueOnce(new Error("disk full"));
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe("添加搜索功能"));
await waitFor(() => expect((screen.getByLabelText("任务输入") as HTMLTextAreaElement).value).toBe("添加搜索功能"));
expect(submit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
fireEvent.click(screen.getByRole("button", { name: "重试创建" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(isolate).toHaveBeenCalledTimes(2);
});
@@ -58,15 +60,134 @@ it("reuses the prepared worktree when the first submission is refused", async ()
submit.mockResolvedValueOnce(false);
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe("添加搜索功能"));
await waitFor(() => expect((screen.getByLabelText("任务输入") as HTMLTextAreaElement).value).toBe("添加搜索功能"));
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(submit).toHaveBeenCalledTimes(2));
expect(isolate).toHaveBeenCalledOnce();
});
it("retains the branch switcher for existing conversations", async () => {
it("keeps workspace configuration and branch switching in existing conversations", async () => {
const { port } = draw(false);
expect(screen.queryByRole("button", { name: "配置工作目录" })).toBeNull();
expect(screen.getByRole("button", { name: /当前 Git 分支/ }).closest(".turntools")).not.toBeNull();
expect(vi.spyOn(port, "worktreeSources")).not.toHaveBeenCalled();
const move = vi.spyOn(port, "switchBranch");
const trigger = await screen.findByRole("button", { name: "配置工作目录" });
expect(trigger.closest(".turntools")).not.toBeNull();
fireEvent.click(trigger);
expect(screen.getByLabelText("查看完整路径")).toBeTruthy();
expect(screen.queryByRole("button", { name: "新建 worktree" })).toBeNull();
fireEvent.click(await screen.findByRole("option", { name: /studio/ }));
await waitFor(() => expect(move).toHaveBeenCalledWith("studio"));
});
it("shows a completed worktree's actual identity, full branch and path", async () => {
const branch = "reasonix/delivery-20261017-170557-387acbfc21";
draw(false, branch, true);
const trigger = await screen.findByRole("button", { name: "配置工作目录" });
await waitFor(() => expect(trigger.textContent).toContain("独立 worktree"));
expect(trigger.title).toBe(branch);
fireEvent.click(trigger);
expect(screen.getByLabelText("查看完整路径")).toBeTruthy();
expect(screen.getByRole("searchbox", { name: "搜索分支" })).toBeTruthy();
expect(screen.getByText("选择后立即切换,同目录会话也会受影响")).toBeTruthy();
expect(screen.queryByRole("button", { name: "新建 worktree" })).toBeNull();
});
it("shows real preparation state and restores the draft only after confirmed cancellation", async () => {
const { port, submit } = draw();
let phase = "checking_out";
const reading = { id: "", phase, sourceRoot: "~/projects/DeepSeek-Reasonix", ref: "refs/heads/studio", startedAt: Date.now(), branch: "reasonix/task", workspaceRoot: "/isolated/project" };
vi.spyOn(port, "prepareWorktree").mockImplementation(async (id) => ({ ...reading, id }));
vi.spyOn(port, "worktreePreparation").mockImplementation(async (id) => ({ ...reading, id, phase }));
const cancel = vi.spyOn(port, "cancelWorktreePreparation").mockImplementation(async (id) => { phase = "cancelled"; return { ...reading, id, phase }; });
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await screen.findByRole("heading", { name: "正在创建 worktree…" });
expect(screen.getByText("正在检出已提交文件")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "取消创建" }));
await screen.findByRole("heading", { name: "已取消创建" });
await waitFor(() => expect((screen.getByLabelText("任务输入") as HTMLTextAreaElement).value).toBe("添加搜索功能"));
expect(cancel).toHaveBeenCalledOnce();
expect(submit).not.toHaveBeenCalled();
});
it("queries a lost acknowledgement before retrying and submits once in the prepared workspace", async () => {
const { port, submit } = draw();
const start = vi.spyOn(port, "prepareWorktree").mockRejectedValue(new Error("connection lost"));
const read = vi.spyOn(port, "worktreePreparation").mockImplementation(async (id) => ({ id, phase: "ready", sourceRoot: "~/projects/DeepSeek-Reasonix", ref: "refs/heads/studio", startedAt: Date.now(), branch: "reasonix/task", workspaceRoot: "/isolated/project" }));
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(start).toHaveBeenCalledOnce();
expect(read).toHaveBeenCalledWith(start.mock.calls[0][0]);
});
it("offers local branch switching while worktree mode is off", async () => {
const { port } = draw();
const move = vi.spyOn(port, "switchBranch");
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(await screen.findByRole("option", { name: /studio/ }));
await waitFor(() => expect(move).toHaveBeenCalledWith("studio"));
});
it("defaults a new worktree to the actual branch after an earlier branch switch", async () => {
draw(true, "studio");
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(await screen.findByRole("button", { name: "新建 worktree" }));
expect(screen.getByRole("option", { name: "studio" }).getAttribute("aria-selected")).toBe("true");
});
it("searches branches immediately and prevents switching an occupied branch", async () => {
const { port } = draw(false);
const move = vi.spyOn(port, "switchBranch");
vi.spyOn(port, "branches").mockResolvedValue({ repo: true, branches: [{ name: "main", current: true }, { name: "studio", current: false, worktree: "/other/tree" }] });
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
const occupied = await screen.findByRole("option", { name: "studio" });
expect((occupied as HTMLButtonElement).disabled).toBe(true);
expect(occupied.title).toContain("/other/tree");
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "studio" } });
expect(screen.queryByRole("option", { name: "main" })).toBeNull();
fireEvent.click(occupied);
expect(move).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "missing" } });
expect(screen.getByText("没有匹配的分支")).toBeTruthy();
});
it("reveals and copies the full path without expanding it by default", async () => {
draw(false);
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
const disclosure = screen.getByLabelText("查看完整路径").closest("details")!;
expect(disclosure.open).toBe(false);
fireEvent.click(screen.getByLabelText("查看完整路径"));
expect(disclosure.open).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "复制完整路径" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("/work/demo"));
vi.unstubAllGlobals();
});
it("recovers a failed branch read without treating it as an empty search", async () => {
const { port } = draw(false);
vi.spyOn(port, "branches").mockRejectedValueOnce(new Error("offline"));
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
await screen.findByRole("alert");
expect(screen.queryByText("没有匹配的分支")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
await screen.findByRole("option", { name: "studio" });
});
it("skips occupied branches with the keyboard and returns focus on Escape", async () => {
const { port } = draw(false);
vi.spyOn(port, "branches").mockResolvedValue({ repo: true, branches: [{ name: "main", current: true }, { name: "blocked", current: false, worktree: "/other" }, { name: "studio", current: false }] });
const trigger = await screen.findByRole("button", { name: "配置工作目录" });
fireEvent.click(trigger);
await screen.findByRole("option", { name: "main" });
const input = screen.getByRole("searchbox");
expect(document.activeElement).toBe(input);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getByRole("option", { name: "main" }));
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getByRole("option", { name: "studio" }));
fireEvent.keyDown(window, { key: "Escape" });
expect(document.activeElement).toBe(trigger);
expect(screen.queryByRole("dialog")).toBeNull();
});
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Workspaces } from "./Workspaces";
import { Chrome } from "./Chrome";
import { MockPort } from "../port/mock";
import type { TreeWorkspace } from "../port/hub";
afterEach(cleanup);
const root = "/projects/site";
const copy = "/state/worktrees/one/site";
const path = "/sessions/isolated.jsonl";
function draw() {
const onOpen = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn().mockResolvedValue(undefined);
const removeWorkspace = vi.fn().mockResolvedValue(undefined);
const tree = [{ root, name: "site", remembered: true, workspaceRoots: [root, copy], sessions: [
{ path, name: "isolated", title: "隔离中的工作", workspaceRoot: copy, isolated: true },
] }] as TreeWorkspace[];
render(<Workspaces hub={{ removeWorkspace } as never} tree={tree} treeRead
runtimes={[{ id: "r1", base: "/rt/r1", root: copy, name: "site" }]}
active="r1" folded={new Set()} onFold={vi.fn()} reload={async () => {}}
onOpen={onOpen} onFocus={vi.fn()} onClose={onClose} liveIds={() => []}
runs={{}} onRename={vi.fn()} onError={vi.fn()}
adder={{ add: vi.fn(), close: vi.fn(), at: null } as never} />);
return { onOpen, onClose, removeWorkspace };
}
it("opens grouped history in its worktree and new conversations in the original directory", async () => {
const { onOpen } = draw();
await userEvent.click(screen.getByText("隔离中的工作"));
expect(onOpen).toHaveBeenLastCalledWith({ root: copy, sessionPath: path });
await userEvent.click(screen.getByRole("button", { name: "在 site 下新建会话" }));
expect(onOpen).toHaveBeenLastCalledWith({ root });
expect(document.querySelectorAll(".wsnode")).toHaveLength(1);
expect(document.querySelector(".wsnode[data-current]")).not.toBeNull();
});
it("closes the project's worktree panes when removing the grouped project", async () => {
const { onClose, removeWorkspace } = draw();
await userEvent.click(screen.getByRole("button", { name: "项目操作:site" }));
await userEvent.click(screen.getByRole("menuitem", { name: /从列表移除/ }));
await userEvent.click(screen.getByRole("button", { name: "移除" }));
expect(onClose).toHaveBeenCalledWith(["r1"]);
expect(removeWorkspace).toHaveBeenCalledWith(root);
});
it.each([[false, "原目录"], [true, "隔离目录"]])("identifies the conversation's directory when isolated=%s", async (isolated, label) => {
const port = new MockPort();
const current = isolated ? copy : root;
vi.spyOn(port, "workspaces").mockResolvedValue({ current, isolated, canSwitch: true, canIsolate: true, recents: [] });
render(<Chrome port={port} status={{ workspaceRoot: current, sessionPath: path } as never}
title="工作" steer={0} onSettings={vi.fn()} account={null} theme="dark" onTheme={vi.fn()}
onFind={vi.fn()} view="flow" rows={0} surfaces={0} onView={vi.fn()} />);
expect(await screen.findByText(label)).toBeTruthy();
expect(screen.getByText(label).getAttribute("title")).toBe(current);
});
it("ignores old checkout metadata when switching conversations before it arrives", async () => {
const old = new MockPort();
const next = new MockPort();
let finish!: (value: Awaited<ReturnType<MockPort["workspaces"]>>) => void;
vi.spyOn(old, "workspaces").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.spyOn(next, "workspaces").mockResolvedValue({ current: root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
const props = { title: "工作", steer: 0, onSettings: vi.fn(), account: null, theme: "dark" as const,
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
const { rerender } = render(<Chrome {...props} port={old} status={{ workspaceRoot: copy } as never} />);
rerender(<Chrome {...props} port={next} status={{ workspaceRoot: root } as never} />);
expect(await screen.findByText("原目录")).toBeTruthy();
await act(async () => finish({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
expect(screen.getByText("原目录").hidden).toBe(false);
expect(screen.queryByText("隔离目录")).toBeNull();
});
+17 -3
View File
@@ -140,7 +140,7 @@ allow = ["Bash(go test:*)"] # never prompted
# egress_unlisted = "allow" # unattended long tasks (user config only): hosts outside allowed_domains pass without asking, said once each; denied_domains still refuse
[peers]
# enabled = false # your sessions on this machine can message each other; every message waits for that session's user to approve it (user config only)
# enabled = false # your sessions on this machine can message each other; every message waits for that session's user to approve it; also a switch in Settings, applied at the next turn (user config only)
# dispatch = false # the model may hand a task to a session in another of your projects, via dispatch_to_project; needs enabled (user config only)
# release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply (user config only)
@@ -885,6 +885,19 @@ Composer shortcuts:
Remembered projects have Move up / Move down in their project menu. Open it with the keyboard, navigate with arrows or Home/End, and use Escape to return to the trigger. Moving a project changes sidebar order without changing the launch project.
Ordinary Reasonix-managed worktree chats appear under their original project,
without a separate project row for each checkout. The chat header shows Local
checkout or Worktree, with its actual working directory available on hover;
worktree chats also carry a sidebar marker.
Reopening history uses that chat's working directory, while starting a chat
from the project uses the original directory. Ordering, search, paging, pins
and archives span the project.
Removing the project closes its idle panes and retains every directory and
transcript. Existing transcripts stay in place and group on restart without migration.
Worktrees added separately outside Reasonix-managed storage remain independent projects.
The list holds up to 32 projects; adding another requires removing one first. `serve-workspaces.json` now stores an object with `paths` and `launch`. Older binaries read only arrays and cannot preserve this format: do not share the same state home with an older binary after reordering.
Menus and controls:
@@ -1226,12 +1239,13 @@ headers = { IJ_MCP_SERVER_PROJECT_PATH = "${REASONIX_WORKSPACE_ROOT}" }
For a remote HTTP server without a static `Authorization` header, an
authentication challenge (HTTP 401 with a `Bearer` challenge) is reported as
needing sign-in. Run `reasonix mcp auth <name>` in the CLI; Studio has no
sign-in control yet. Reasonix performs OAuth metadata discovery, dynamic
needing sign-in. Run `reasonix mcp auth <name>` in the CLI. Reasonix performs OAuth metadata discovery, dynamic
client registration, PKCE S256 authorization, and refresh-token
rotation. Discovery and token requests use the same Reasonix network-proxy
settings as the MCP connection.
Before a browser opens, Reasonix names the authorization host the server chose and asks. "Always" is remembered for that server and host only, beside its OAuth state, and cleared with it.
OAuth client and token state is kept outside the workspace in the server's
private Reasonix state directory, written with mode `0600`, and bound to the
full configured resource URL. An explicit static `Authorization` header always
+13 -2
View File
@@ -149,7 +149,7 @@ allow = ["Bash(go test:*)"] # 从不询问
# egress_unlisted = "allow" # 无人值守长任务用(只认用户级配置):allowed_domains 之外的主机不再询问,每个主机提示一次;denied_domains 仍然拒绝
[peers]
# enabled = false # 本机你的各个会话可以互发消息;每条消息都要对方会话的用户批准(只认用户级配置)
# enabled = false # 本机你的各个会话可以互发消息;每条消息都要对方会话的用户批准;设置里也有开关,下个回合起生效(只认用户级配置)
# dispatch = false # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话;需要 enabled(只认用户级配置)
# release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧(只认用户级配置)
@@ -793,6 +793,15 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u
已保存项目的操作菜单提供上移和下移。用键盘打开菜单后,方向键或 Home/End 可导航,Escape 会返回触发按钮。移动只改变侧栏顺序,不改变启动项目。
Reasonix 管理的普通 worktree 会话统一列在原项目下,不再为隔离目录单列同名项目。
会话顶部显示“原目录”或“隔离目录”,悬停可查看实际工作目录;侧栏也会标记隔离会话。
打开历史会话仍使用它原来的工作目录,在项目上新建会话则使用原目录。
排序、搜索、分页、置顶和归档覆盖整个项目。
移除项目会关闭所属的空闲面板,但保留所有工作目录和会话文件。
历史会话无需搬移,重启后直接按原项目归组;Reasonix 管理目录以外的 worktree 仍作为独立项目使用。
项目列表最多保存 32 项,新增第 33 项前需先移除一项。`serve-workspaces.json` 现在使用含 `paths` 和 `launch` 的对象;旧版本只能读取数组,无法保留新格式。排序后不要与旧版本共用同一状态目录。
## 权限与沙盒
@@ -968,10 +977,12 @@ headers = { IJ_MCP_SERVER_PROJECT_PATH = "${REASONIX_WORKSPACE_ROOT}" }
```
远程 HTTP server 未配置静态 `Authorization` header 时,认证要求(HTTP 401 且带 `Bearer` 质询)会被识别为需要登录。
CLI 可运行 `reasonix mcp auth <name>`;Studio 暂时还没有登录入口。
CLI 可运行 `reasonix mcp auth <name>`。
Reasonix 会执行 OAuth 元数据发现、动态客户端注册、PKCE S256 授权与
refresh token 轮换;发现和 token 请求与 MCP 连接使用相同的 Reasonix 网络代理设置。
打开浏览器之前,Reasonix 会先说明服务器指定的授权主机并询问;“始终允许”只对该服务器和该主机生效,记在其 OAuth 状态旁,清除认证时一并清掉。
OAuth client 与 token 状态保存在工作区之外、该 server 私有的 Reasonix 状态目录中,文件权限
为 `0600`,并绑定完整的 resource URL。显式静态 `Authorization` header 始终优先。
**清除认证** 只删除 Reasonix 本地 OAuth 状态,不会退出第三方浏览器会话。Reasonix 仅在用户
+142
View File
@@ -0,0 +1,142 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-17
---
# Workspace preparation / 工作目录准备
## User flow / 用户流程
In a new conversation, **Configure workspace** offers **Use current folder**
and **New worktree**. The current-folder branch picker switches local branches
immediately through the existing guarded branch API. Branches checked out in
another worktree remain visible with their occupied path and cannot be selected.
新会话中的「配置工作目录」提供「使用当前目录」和「新建 worktree」。当前目录的
分支选择器通过既有受保护接口立即切换本地分支;其他 worktree 占用的分支显示
占用路径,并禁止切换。
The same entry stays available after the first message and inside an existing
worktree. Its panel shows the full branch and folder path, and keeps branch
switching available. Creating another worktree starts from a new source session.
首条消息发送后和已有 worktree 内仍保留同一入口。面板显示完整分支名、目录路径,
并继续支持切换分支。需要另建 worktree 时,从源目录新会话开始。
The palette exposes search and branch choices immediately. Full paths are
collapsed by default and can be revealed or copied. Typography uses the existing
UI, metadata and helper tokens, shared with the preparation states.
面板直接展示搜索与分支列表,完整路径默认折叠,可展开或复制。创建、成功、失败和
取消页统一使用现有正文、元信息、辅助说明字号;完整诊断按需展开。
Worktree mode selects a local or remote-tracking starting ref without switching
the source checkout. Sending starts preparation from its committed version.
The preparation screen retains the task and attachment names, shows actual
host phases, and exposes details and cancellation.
Only a ready workspace can
receive the original task. Failure and cancellation preserve the draft.
worktree 模式可以选择本地或远程跟踪分支作为起点,不会切换原目录。发送消息时
基于起点的已提交版本创建目录。准备页面保留任务和附件名称,显示后端真实阶段,
提供详情和取消。目录就绪后才发送原任务;失败或取消时保留草稿。
## Operation contract / 操作协议
| Endpoint | Behavior / 行为 |
| --- | --- |
| `POST /workspace/prepare` | `{id, selection:{sourceRoot, ref, attachments?}}`; return current operation / 返回当前操作 |
| `GET /workspace/prepare/{id}` | Reconcile the same operation / 核对同一操作 |
| `POST /workspace/prepare/{id}/cancel` | Request cancellation; return current state / 请求取消并返回当前状态 |
The operation ID is generated before sending. An identical repeated POST returns
the same result; a different payload with the same ID is rejected.
A lost POST
acknowledgement is reconciled by ID before any new creation is allowed. The
per-runtime registry retains the latest 32 completed attempts in memory.
Unknown
IDs after restart or eviction require explicit reconciliation; the client never
silently creates a replacement.
发送前生成操作 ID。同一 ID 和相同参数重复提交返回相同结果;同一 ID 的不同参数
会被拒绝。创建请求响应丢失时先按 ID 核对,不会静默重复创建。每个运行时在内存中
保留最近 32 次已完成操作;重启或淘汰后未知的 ID 需要人工核对。
Host phases include `validating`, `creating`, `checking_out`,
`copying_attachments`, `binding`, `cleaning`, and `cancelling`. Terminal phases
are `ready`, `failed`, `cancelled`, and `cleanup_failed`.
Error identities use
typed `workspace.*` codes. The frontend additionally represents unreachable
status and a published workspace retained after a late cancellation.
后端阶段包括校验、创建、检出、复制附件、绑定、清理和取消中;终态包括成功、失败、
已取消和清理失败。错误通过 `workspace.*` 类型码传递。前端另行展示状态不可达,
以及取消晚于目录绑定时保留已创建目录的状态。
## Ownership and bounds / 所有权与时限
Cancellation and runtime publication share one commit boundary. Cancellation
before publication stops construction and rolls back only the unpublished
worktree and its unchanged task branch. `cancelled` is acknowledged only after
cleanup.
If publication wins, the workspace stays ready; a user cancellation
still prevents automatic task submission. Cleanup uncertainty blocks replacement
creation and exposes diagnostics instead of claiming success.
取消与运行时发布共用一个提交边界。发布前取消会停止构建,只回收尚未发布的
worktree 及其未变化的任务分支;清理完成后才确认「已取消」。
发布先完成时保留
已绑定目录,但用户取消仍会阻止自动发送任务。无法确认清理结果时禁止重复创建,
并展示诊断信息。
The UI shows a long-wait notice after 15 seconds without inventing a percentage.
The host operation is bounded to 10 minutes; checkout commands to 5 minutes;
rollback to 1 minute. A timed-out operation becomes retryable only after cleanup.
Git cancellation terminates the checkout process group. Runtime assembly follows
request cancellation until successful construction, then transfers its lifetime
to the controller. Changing panes or workspaces invalidates old client attempts.
等待超过 15 秒展示长等待提示,不伪造百分比。后端操作最长 10 分钟,Git 检出最长
5 分钟,回收最长 1 分钟;超时清理完成后才允许重试。取消 Git 操作会终止检出进程组。
运行时构建阶段响应请求取消,成功后生命周期转交给控制器。切换页面或工作区会
使旧前端操作失效。
## Cleanup recovery / 清理失败后的恢复
`cleanup_failed` deliberately blocks another preparation in the same runtime.
1. Save the task and attachments, and reveal or copy the retained path.
2. Reconcile that directory and task branch using Git in a terminal. Preserve
any external changes.
3. Once reconciled, choose **Close conversation** from its menu and start a new
conversation in the source folder to retry. Closing retires the old runtime
and its operation registry.
`cleanup_failed` 会阻止同一运行时再次创建目录。先保存任务与附件,展开或复制保留
目录的路径,再在终端使用 Git 核对该目录与任务分支,保留外部产生的修改。确认处理
完成后,在会话菜单中选择「关闭会话」,再从源目录新建会话重试;关闭会话会退役旧
运行时及其操作记录。
## Regression evidence / 回归验证
Regression coverage lives in `workspace_preparation_test.go`,
`workspace_prepare_test.go`, `build_lifetime_test.go`, worktree `cancel_test.go`,
and the frontend worktree composer and hook tests.
These cover publication versus
cancellation, cleanup before acknowledgement, retry after failure, lost
acknowledgements, real checkout descendant termination, and stale pane results.
上述测试覆盖发布与取消的先后顺序、清理后确认、失败重试、响应丢失、真实 Git 检出
子进程终止,以及旧页面结果失效。真实浏览器验证使用临时仓库与独立 Reasonix
配置目录,模型回复由本地测试服务提供;不代表真实模型或发布安装包已通过验证。
+3
View File
@@ -3422,6 +3422,9 @@ func TestRememberPermissionRuleProcessHelper(t *testing.T) {
for n := range rules {
rule := fmt.Sprintf("Edit(process-%d-file-%02d)", worker, n)
result := rememberPermissionRule(config.Roots{}, workspace, rule)
for time.Now().Before(deadline) && errors.Is(result.Err, config.ErrProjectGrantsUnavailable) {
result = rememberPermissionRule(config.Roots{}, workspace, rule)
}
if result.Err != nil || !result.Saved {
t.Fatalf("remember result = %+v, want saved without error", result)
}
+19 -4
View File
@@ -40,6 +40,7 @@ import (
// what the earlier ones settled and adds its own; none reaches back.
type builder struct {
ctx context.Context
closeLifetime context.CancelFunc
opts Options
timer *phaseTimer
owner *extension.RuntimeOwner
@@ -81,6 +82,7 @@ type toolStage struct {
lsp *lsp.Manager
browser *browser.Session
peerLink *tool.PeerLink
peerTools func(enabled, dispatch bool)
maxSteps int
policy permission.Policy
gate *control.SharedHeadlessGate
@@ -110,9 +112,16 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
b.timer.observe = opts.OnPhase
// The runtime outlives the request that built it (Studio opens a pane with
// one), and its MCP servers and sidecars start on that context later.
b.ctx, b.opts, b.owner, b.fileWriteReceipt = bindRuntimeOwner(context.WithoutCancel(ctx), opts)
life, detach, closeLifetime := constructionLifetime(ctx)
b.closeLifetime = closeLifetime
b.ctx, b.opts, b.owner, b.fileWriteReceipt = bindRuntimeOwner(life, opts)
defer b.retireUnownedSidecars()
delivered := false
defer func() {
if !delivered {
closeLifetime()
}
}()
if b.owner.Notices != nil && b.opts.Sink != nil {
window := b.owner.Notices.Begin(b.opts.Sink)
b.opts.Sink = window
@@ -131,6 +140,12 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
b.tools.candidates.bind(ctrl)
b.tools.isolation.bind(ctrl)
res, err := b.freeze(ctrl)
if err == nil {
if cancelErr := detach(); cancelErr != nil {
ctrl.Close()
return nil, cancelErr
}
}
delivered = err == nil
return res, err
}
@@ -323,7 +338,7 @@ func (b *builder) wireTools() error {
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
addViewImage(t.reg, cfg, b.model.entry, env, root)
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
t.peerLink = addPeerTools(t.reg, cfg)
t.peerLink, t.peerTools = peerTools(t.reg, cfg)
addSystemOne(t.reg, cfg.Tools.Enabled, cfg, b.keyedClient)
addAdvisor(t.reg, cfg, b.proxy, b.sink)
addWebSearch(t.reg, cfg, b.proxy, b.sink)
@@ -546,7 +561,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
ModelEntry: entry,
SystemPrompt: b.prompt.prompt,
SessionDir: b.session.dir,
Peers: peersOptions(cfg, t.peerLink),
Peers: peersOptions(cfg, t.peerLink, t.peerTools),
Host: t.host,
Commands: t.cmds,
Skills: b.prompt.skills,
@@ -562,7 +577,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
Hooks: t.hookRunner,
Memory: b.prompt.memory,
// Read at Close time: freeze chains the extension runtime set onto it.
Cleanup: func() { b.cleanup() },
Cleanup: func() { defer b.closeLifetime(); b.cleanup() },
Balance: opts.BalanceStore.Cache(b.keyedClient, entry.BalanceURL, entry.APIKey()),
Feedback: control.FeedbackOptions{Service: feedbackService(b.roots.Home(), b.proxy, opts.AccountsURL), ProviderKind: feedbackProviderKind(entry), Surface: opts.FeedbackSurface},
Jobs: b.session.jobs,
+18
View File
@@ -0,0 +1,18 @@
package boot
import "context"
// constructionLifetime relays request cancellation until successful assembly;
// the controller's cleanup owns the detached lifetime afterward.
func constructionLifetime(request context.Context) (context.Context, func() error, context.CancelFunc) {
life, cancel := context.WithCancel(context.WithoutCancel(request))
stop := context.AfterFunc(request, cancel)
detach := func() error {
if !stop() || request.Err() != nil {
cancel()
return request.Err()
}
return nil
}
return life, detach, func() { stop(); cancel() }
}
@@ -0,0 +1,46 @@
package boot
import (
"context"
"errors"
"testing"
"time"
)
func TestBuildCancellationBeforePublication(t *testing.T) {
_, sink := buildNoticeFixture(t, "")
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
ctrl, err := Build(ctx, Options{Sink: sink, OnPhase: func(phase Phase) {
if phase.Name == "config" {
cancel()
}
}})
if ctrl != nil {
ctrl.Close()
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled construction returned controller=%v err=%v", ctrl != nil, err)
}
}
func TestConstructionLifetimeDetachesOnlyAfterSuccess(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
life, detach, closeLifetime := constructionLifetime(ctx)
defer closeLifetime()
if err := detach(); err != nil {
t.Fatal(err)
}
cancel()
select {
case <-life.Done():
t.Fatal("published lifetime died with its request")
default:
}
closeLifetime()
select {
case <-life.Done():
case <-time.After(time.Second):
t.Fatal("runtime close did not retire its lifetime")
}
}
@@ -90,6 +90,11 @@ disabled_tools = ["write"]
if len(configured) != 1 || configured[0].Entry.Name != "filtered" {
t.Fatalf("%s: configured MCP servers = %+v, want filtered", phase, configured)
}
// Boot discovers a cold catalog and preconnects always-loaded servers.
// Observe that connection before the explicit registration step.
if phase == "fresh" || load == "always" {
waitForMCPServer(t, ctrl.Host(), "filtered")
}
if _, err := ctrl.ConnectMCPServer(configured[0].Entry); err != nil {
t.Fatal(err)
}
@@ -98,7 +103,8 @@ disabled_tools = ["write"]
t.Fatal(err)
}
if len(tools) != 1 || tools[0].Name() != "mcp__filtered__read" {
t.Fatalf("%s: live tools = %v; want only read", phase, tools)
held, _ := ctrl.Host().HeldTools("filtered")
t.Fatalf("%s: live tools = %v; want only read; servers=%+v; held=%+v", phase, tools, ctrl.Host().Servers(), held)
}
if got := ctrl.MCPCatalogTools()["filtered"]; got != 1 {
t.Fatalf("%s: catalog tools=%d, want 1", phase, got)
@@ -458,13 +458,17 @@ func containsMessage(req provider.Request, want string) bool {
// holding the tools may not have cached them yet.
func waitForCachedMCPTool(t *testing.T, server, tool string) {
t.Helper()
path := filepath.Join(config.CacheDir(), "mcp", server+".json")
// Entries are per spec (<server>.<key>.json); any of the server's entries will do.
pattern := filepath.Join(config.CacheDir(), "mcp", server+".*json")
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if body, err := os.ReadFile(path); err == nil && strings.Contains(string(body), `"`+tool+`"`) {
return
entries, _ := filepath.Glob(pattern)
for _, path := range entries {
if body, err := os.ReadFile(path); err == nil && strings.Contains(string(body), `"`+tool+`"`) {
return
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("%s never reached the schema cache at %s", tool, path)
t.Fatalf("%s never reached the schema cache (%s)", tool, pattern)
}
@@ -0,0 +1,78 @@
package boot
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"reasonix/internal/session/control"
)
func (p *peerToolProvider) toolsOfRequest(i int) []string {
p.mu.Lock()
defer p.mu.Unlock()
var names []string
for _, t := range p.reqs[i].Tools {
names = append(names, t.Name)
}
return names
}
func (p *peerToolProvider) toolBytes(i int) string {
p.mu.Lock()
defer p.mu.Unlock()
raw, _ := json.Marshal(p.reqs[i].Tools)
return string(raw)
}
func runTurn(t *testing.T, c interface {
Run(context.Context, string) error
}) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := c.Run(ctx, "hi"); err != nil {
t.Fatal(err)
}
}
// The switch in settings reaches the provider request at the next turn and no
// earlier: a session that never turns it on sends the same tool bytes every
// turn, turning it on adds exactly the peer tools, and turning it off restores
// the original block.
func TestEffectPeerSwitchMovesTheToolBlockOnlyAtTurnBoundaries(t *testing.T) {
home := statedBootHome(t)
t.Setenv("REASONIX_HOME", home)
rec, ctrl, _, _ := buildPeerToolSession(t, false, "nobody", "hello")
runTurn(t, ctrl)
runTurn(t, ctrl)
if rec.toolBytes(0) != rec.toolBytes(1) {
t.Fatal("a session that never touched the switch changed its tool block between turns")
}
base := rec.toolsOfRequest(0)
yes := true
if _, err := ctrl.SavePeersSettings(control.PeersPatch{Enabled: &yes, Dispatch: &yes}); err != nil {
t.Fatal(err)
}
runTurn(t, ctrl)
got := rec.toolsOfRequest(2)
want := map[string]bool{"list_peers": true, "send_peer_message": true, "dispatch_to_project": true}
for _, n := range got {
delete(want, n)
}
if len(want) != 0 || len(got) != len(base)+3 {
t.Fatalf("after turning on: %d tools (was %d), still missing %v", len(got), len(base), want)
}
no := false
if _, err := ctrl.SavePeersSettings(control.PeersPatch{Enabled: &no, Dispatch: &no}); err != nil {
t.Fatal(err)
}
runTurn(t, ctrl)
if !reflect.DeepEqual(rec.toolsOfRequest(3), base) || rec.toolBytes(3) != rec.toolBytes(0) {
t.Fatalf("after turning off the tool block is not what it was:\n%v\n%v", rec.toolsOfRequest(3), base)
}
}
+31 -14
View File
@@ -8,21 +8,38 @@ import (
"reasonix/internal/tools/builtin"
)
// addPeerTools registers the tools that let the model see and message other
// sessions, and only in a session that turned messages between sessions on, so
// the tool block of every other session is unchanged.
func addPeerTools(reg *tool.Registry, cfg *config.Config) *tool.PeerLink {
if !cfg.PeersEnabled() {
return nil
}
// peerToolNames are the tools the peer switches own, so a switch turned off
// withdraws exactly these.
var peerToolNames = []string{"list_peers", "send_peer_message", "dispatch_to_project"}
// peerTools registers the tools that let the model see and message other
// sessions, and only while the user has the switch on, so the tool block of
// every other session is unchanged. The returned function moves the set when the
// user flips a switch; the controller calls it only between turns.
func peerTools(reg *tool.Registry, cfg *config.Config) (*tool.PeerLink, func(enabled, dispatch bool)) {
link := &tool.PeerLink{}
for _, t := range builtin.PeerTools(link) {
if len(cfg.Tools.Enabled) == 0 || slices.Contains(cfg.Tools.Enabled, t.Name()) {
reg.Add(t)
allowed := func(name string) bool {
return len(cfg.Tools.Enabled) == 0 || slices.Contains(cfg.Tools.Enabled, name)
}
set := func(enabled, dispatch bool) {
reg.ShowToProvider(false, peerToolNames...)
for _, name := range peerToolNames {
reg.RemovePrefix(name)
}
if !enabled {
return
}
add := builtin.PeerTools(link)
if dispatch {
add = append(add, builtin.DispatchTool(link))
}
for _, t := range add {
if allowed(t.Name()) {
reg.Add(t)
reg.ShowToProvider(true, t.Name())
}
}
}
if cfg.PeersDispatch() && (len(cfg.Tools.Enabled) == 0 || slices.Contains(cfg.Tools.Enabled, "dispatch_to_project")) {
reg.Add(builtin.DispatchTool(link))
}
return link
set(cfg.PeersEnabled(), cfg.PeersDispatch())
return link, set
}
+8 -8
View File
@@ -6,13 +6,13 @@ import (
"reasonix/internal/session/control"
)
// peersOptions opens a session to messages from other sessions only when the
// user file asked for it; the directory is the one the sandbox already denies
// to confined commands. The link lets the peer tools registered with the same
// switch reach the controller once it exists.
func peersOptions(cfg *config.Config, link *tool.PeerLink) control.PeersOptions {
if !cfg.PeersEnabled() {
return control.PeersOptions{}
// peersOptions gives a session the spool directory the sandbox already denies to
// confined commands, but opens it only when the user file asked for messages
// between sessions; otherwise the session waits on standby for the switch in
// settings. The link lets the peer tools reach the controller once it exists.
func peersOptions(cfg *config.Config, link *tool.PeerLink, tools func(enabled, dispatch bool)) control.PeersOptions {
return control.PeersOptions{
Dir: cfg.Roots().PeersDir(), Link: link, Tools: tools,
Standby: !cfg.PeersEnabled(), Dispatch: cfg.PeersDispatch(),
}
return control.PeersOptions{Dir: cfg.Roots().PeersDir(), Link: link}
}
+3
View File
@@ -5,6 +5,7 @@ package i18n
var notifyEN = NotifyMessages{
UnattendedMessages: unattendedEN,
PaidSearchMessages: PaidSearchMessages{PaidSearchApprovalReasonFmt: "web_search wants to send this query to DeepSeek's paid Search API: %[3]s. DeepSeek bills %[2]s. Allow it for %[1]s?", PaidFetchApprovalReasonFmt: "web_fetch could not read this page from here and wants DeepSeek's paid Fetch API to fetch it: %[3]s. DeepSeek bills %[2]s. Allow it for %[1]s?"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "The MCP server %[1]s wants to send you to %[2]s to sign in. Only continue if you recognise that site; nothing is sent there until you approve."},
AskTimeoutMessages: AskTimeoutMessages{
CmdAskTimeout: "skip a question nobody answers after a wait", AskTimeoutFmt: "ask timeout: %s — set it with /ask-timeout <seconds|off>", AskTimeoutOff: "off", AskTimeoutBadFmt: "ask-timeout: %q is neither a number of seconds nor off", AskSkipFmt: "skipped in %s — press any key to keep it",
},
@@ -20,6 +21,7 @@ var notifyEN = NotifyMessages{
var notifyZH = NotifyMessages{
UnattendedMessages: unattendedZH,
PaidSearchMessages: PaidSearchMessages{PaidSearchApprovalReasonFmt: "web_search 想把这条搜索词发给 DeepSeek 的付费搜索接口:%[3]s。DeepSeek 按次计费:%[2]s。是否允许使用 %[1]s?", PaidFetchApprovalReasonFmt: "web_fetch 在本机读不到这个页面,想用 DeepSeek 的付费抓取接口来抓:%[3]s。DeepSeek 按次计费:%[2]s。是否允许使用 %[1]s?"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "MCP 服务器 %[1]s 想让你去 %[2]s 登录。只有认得这个站点时才继续;批准之前不会向它发送任何东西。"},
AskTimeoutMessages: AskTimeoutMessages{
CmdAskTimeout: "设置提问无人回答多久后自动跳过", AskTimeoutFmt: "提问等待超时:%s — 用 /ask-timeout <秒数|off> 修改", AskTimeoutOff: "关闭", AskTimeoutBadFmt: "ask-timeout:%q 既不是秒数也不是 off", AskSkipFmt: "%s 后自动跳过 — 按任意键保留",
},
@@ -35,6 +37,7 @@ var notifyZH = NotifyMessages{
var notifyZHTW = NotifyMessages{
UnattendedMessages: unattendedZHTW,
PaidSearchMessages: PaidSearchMessages{PaidSearchApprovalReasonFmt: "web_search 想把這條搜尋詞傳給 DeepSeek 的付費搜尋介面:%[3]s。DeepSeek 按次計費:%[2]s。是否允許使用 %[1]s?", PaidFetchApprovalReasonFmt: "web_fetch 在本機讀不到這個頁面,想用 DeepSeek 的付費抓取介面來抓:%[3]s。DeepSeek 按次計費:%[2]s。是否允許使用 %[1]s?"},
MCPSignInMessages: MCPSignInMessages{MCPSignInReasonFmt: "MCP 伺服器 %[1]s 想讓你去 %[2]s 登入。只有認得這個站點時才繼續;批准之前不會向它傳送任何東西。"},
AskTimeoutMessages: AskTimeoutMessages{
CmdAskTimeout: "設定提問無人回答多久後自動跳過", AskTimeoutFmt: "提問等待逾時:%s — 用 /ask-timeout <秒數|off> 修改", AskTimeoutOff: "關閉", AskTimeoutBadFmt: "ask-timeout:%q 既不是秒數也不是 off", AskSkipFmt: "%s 後自動跳過 — 按任意鍵保留",
},
+7
View File
@@ -13,6 +13,7 @@ type NotifyMessages struct {
AskTimeoutMessages
UnattendedMessages
PaidSearchMessages
MCPSignInMessages
}
// AskTimeoutMessages is the /ask-timeout command's text.
@@ -30,3 +31,9 @@ type PaidSearchMessages struct {
PaidSearchApprovalReasonFmt string // %[1]s backend, %[2]s price, %[3]s query
PaidFetchApprovalReasonFmt string // %[1]s backend, %[2]s price, %[3]s address
}
// MCPSignInMessages is the consent card shown before a browser opens for an MCP
// server's sign-in.
type MCPSignInMessages struct {
MCPSignInReasonFmt string // %[1]s server, %[2]s authorization host
}
+1 -1
View File
@@ -28,7 +28,7 @@ func VanishedMidWalk(path string, err error) bool {
if !racing {
return false
}
for range 50 {
for range 500 {
if _, serr := os.Lstat(path); errors.Is(serr, fs.ErrNotExist) {
return true
}
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more