mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui
# Conflicts: # .github/workflows/studio.yml
This commit is contained in:
commit
4f8852dbd2
210 files changed
+7699
-834
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 验收单独记录。
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } }
|
||||
@@ -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) } }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(""); }}
|
||||
/>
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 仅在用户
|
||||
|
||||
@@ -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
|
||||
配置目录,模型回复由本地测试服务提供;不代表真实模型或发布安装包已通过验证。
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 後自動跳過 — 按任意鍵保留",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user