mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4bea95948 | ||
|
|
24be6e684f | ||
|
|
adf4ac72ac | ||
|
|
a5978f08ed | ||
|
|
87b7d130f3 | ||
|
|
9b2c85c9d3 | ||
|
|
d541409e26 | ||
|
|
0d29be5463 | ||
|
|
13c072b5c3 | ||
|
|
c49ea44087 | ||
|
|
5204882948 | ||
|
|
d44e1022b7 | ||
|
|
a1e23402e9 | ||
|
|
e939f2dd26 | ||
|
|
a25ae9ef24 | ||
|
|
984e6f2e1d | ||
|
|
1f93876afe | ||
|
|
303139c612 | ||
|
|
641f7993fd | ||
|
|
150bb87d4d | ||
|
|
b22ef01193 | ||
|
|
33a3454498 | ||
|
|
65f0a7dd49 | ||
|
|
1763067ce1 | ||
|
|
9ae2e399b5 | ||
|
|
ecde0fd8f0 | ||
|
|
cadcc7a39a | ||
|
|
f8b562edd6 | ||
|
|
9416069ca3 | ||
|
|
a90c4e12ff | ||
|
|
e3841cff9e | ||
|
|
3e5eade4a9 | ||
|
|
457c010fb9 | ||
|
|
8fec83cae4 | ||
|
|
7805d07511 | ||
|
|
1687729ca9 | ||
|
|
6b9c0659c3 | ||
|
|
a4086d28da | ||
|
|
19603ef8e5 | ||
|
|
b7640ca095 | ||
|
|
53fb380d12 | ||
|
|
8cc7279c86 | ||
|
|
df7d060929 | ||
|
|
b13f45d886 | ||
|
|
1c6776d9d9 | ||
|
|
f32cff0e33 | ||
|
|
577023f8c3 | ||
|
|
e3c5affab9 | ||
|
|
95b5ef985c | ||
|
|
ec5244584d | ||
|
|
ea57e9bf35 | ||
|
|
1970a6a16c | ||
|
|
b96e3d7938 | ||
|
|
25a21871a3 | ||
|
|
900767d7f2 | ||
|
|
ecddf4802f | ||
|
|
20326ae01b | ||
|
|
a85df817cd | ||
|
|
5620eb6465 | ||
|
|
710f3fb107 | ||
|
|
3a0e33a8e5 | ||
|
|
c561fcd4e4 | ||
|
|
e8f9fa3192 | ||
|
|
c6023144d9 | ||
|
|
0262190972 | ||
|
|
601724c8ac | ||
|
|
b07f6127be | ||
|
|
bd97fecc5b | ||
|
|
0a21f96f0f | ||
|
|
eee45d2a35 | ||
|
|
17016c741b | ||
|
|
0bf2002f42 | ||
|
|
1f43fdc065 | ||
|
|
2167341319 | ||
|
|
eae381d854 | ||
|
|
1284c24920 | ||
|
|
49960a1e53 | ||
|
|
64addf3b28 | ||
|
|
17c873cf02 | ||
|
|
1133d555c5 | ||
|
|
f11321b60d | ||
|
|
98217b6c90 | ||
|
|
d68236de68 | ||
|
|
9754fc395f | ||
|
|
fc8312b052 | ||
|
|
9527016138 | ||
|
|
781dd38fb8 | ||
|
|
af2b63fb0a | ||
|
|
5f74632f03 | ||
|
|
f367862cb7 | ||
|
|
8c7ca1ba31 | ||
|
|
5e1c054084 | ||
|
|
1e7dd07028 | ||
|
|
3e9371225a | ||
|
|
f635378d36 | ||
|
|
0d5258eefd | ||
|
|
b3381e60d8 |
No files matched your search
@@ -321,7 +321,7 @@ jobs:
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
@@ -449,7 +449,7 @@ jobs:
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
|
||||
|
||||
# go:embed of frontend/dist needs a built frontend before the package
|
||||
# compiles, same as the ubuntu desktop leg.
|
||||
@@ -492,6 +492,11 @@ jobs:
|
||||
- name: repo standards
|
||||
run: go run ./tools/repolint
|
||||
|
||||
- name: wails version pin
|
||||
run: |
|
||||
bash scripts/check-wails-pin.sh
|
||||
bash scripts/check-wails-pin.test.sh
|
||||
|
||||
# `make lint-install` reads the same file, so a local run and this job
|
||||
# cannot drift onto different linter versions.
|
||||
- id: golangci
|
||||
|
||||
@@ -371,7 +371,7 @@ jobs:
|
||||
rm -f "$RUNNER_TEMP/cert.p12"
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
run: go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
|
||||
|
||||
# Official Windows releases must fail closed. Forks can still exercise
|
||||
# unsigned packaging without repository secrets, but esengine releases
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v2.13.0
|
||||
@@ -8,6 +8,8 @@ everything you need to get started.
|
||||
- **Go 1.25+** — the project targets the latest stable Go release
|
||||
- **Git** — for version control
|
||||
- **Node.js** (optional) — only if you work on the desktop app (`desktop/`)
|
||||
- **Wails CLI** (desktop only) — run `make wails-install` so the CLI matches
|
||||
`desktop/go.mod`
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ LDFLAGS := -s -w \
|
||||
GOEXE := $(shell go env GOEXE)
|
||||
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
|
||||
GOLANGCI_VERSION := $(shell cat .golangci-version)
|
||||
WAILS_VERSION := $(shell tr -d '[:space:]' < .wails-version)
|
||||
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update test desktop-test desktop-test-short desktop-test-times sdk-test sdk-test-race hooks cross clean
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update wails-install test desktop-test desktop-test-short desktop-test-times sdk-test sdk-test-race hooks cross clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -26,6 +27,8 @@ fmt:
|
||||
# particular never surface in `go vet`.
|
||||
lint: lint-go
|
||||
go run ./tools/repolint
|
||||
bash scripts/check-wails-pin.sh
|
||||
bash scripts/check-wails-pin.test.sh
|
||||
|
||||
lint-go:
|
||||
@command -v golangci-lint >/dev/null || { echo "golangci-lint not installed; run: make lint-install"; exit 1; }
|
||||
@@ -41,6 +44,10 @@ lint-install:
|
||||
lint-update:
|
||||
go run ./tools/repolint -update
|
||||
|
||||
wails-install:
|
||||
bash scripts/check-wails-pin.sh
|
||||
go install "github.com/wailsapp/wails/v2/cmd/wails@$(WAILS_VERSION)"
|
||||
|
||||
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
|
||||
lint-cross:
|
||||
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
|
||||
|
||||
+71
-4
@@ -146,9 +146,74 @@ metered this way precisely because it *does* self-report: if the proxy and
|
||||
`.run-metrics.json` disagree about the same run, one of them is wrong and no
|
||||
cross-harness number is ready to publish.
|
||||
|
||||
`-faults` injects provider failures at fixed request indices through the same
|
||||
proxy, which is what LongRun needs: deterministic 429/500 at the same point of
|
||||
a run, replayable across harnesses.
|
||||
## Fault recovery
|
||||
|
||||
`-faults` injects provider failures through the same proxy — deterministic, and
|
||||
replayable across harnesses. Two forms:
|
||||
|
||||
- `3:429` — a targeted failure at an exact request.
|
||||
- `every:5:500` — a cadence. **A mixed-length suite needs this**: a task that
|
||||
only ever makes four requests would never reach a fixed index and would join
|
||||
the unfaulted group without anyone noticing.
|
||||
|
||||
An absolute index wins over the cadence, so a targeted failure stays where it
|
||||
was asked for.
|
||||
|
||||
```sh
|
||||
go run ./cmd/e2ebench -meter ~/.reasonix/config.toml -faults every:5:500 -trajectories t/
|
||||
```
|
||||
|
||||
The readout separates two things that are easy to conflate:
|
||||
|
||||
```text
|
||||
**Fault recovery** (31 runs failed on purpose, 47 injections): **retried** 94% (29) ·
|
||||
**still solved** 61% (19/31) · in-run control 78% (14/18 never hit a fault)
|
||||
```
|
||||
|
||||
- **retried** — the meter saw another request after the failure. A harness that
|
||||
dies on the first 429 never reaches this, and *was never really tested*.
|
||||
- **still solved** — the task landed anyway. A harness can retry forever and
|
||||
still not finish; that is not recovery.
|
||||
- **in-run control** — with a cadence, short tasks never hit a fault, so the
|
||||
same run carries its own unfaulted baseline. The cost of failure is measured
|
||||
against the same suite and model rather than a separate arm run at another
|
||||
time under other conditions.
|
||||
|
||||
## Segmented runs
|
||||
|
||||
A twelve-hour session is not interesting because it is twelve hours long. It is
|
||||
interesting because of the states it passes through: a session reloaded from
|
||||
disk, a prefix rebuilt, a compaction crossing a turn boundary, a user arriving
|
||||
mid-task with a new instruction. `-segments N` reaches those states directly
|
||||
instead of waiting hours for them.
|
||||
|
||||
```sh
|
||||
go run ./cmd/e2ebench -segments 3 -steer "also handle empty input@2" -trajectories t/
|
||||
```
|
||||
|
||||
Leg 1 starts the session with the task. Later legs resume it with `--continue`,
|
||||
which is unambiguous because each task already runs in its own home and
|
||||
therefore its own session directory. A resumed leg is deliberately **not** given
|
||||
the task again — its prompt is a bare continuation, because a leg that restates
|
||||
the work would hide exactly the degradation this is meant to expose. A `-steer`
|
||||
entry replaces one leg's continuation with a user turn.
|
||||
|
||||
Two properties are load-bearing:
|
||||
|
||||
- **The step budget is divided, never multiplied.** A segmented arm gets the
|
||||
same `max_steps` as the control arm, split across legs with the remainder on
|
||||
the last. Otherwise the arm would win by being allowed to work longer.
|
||||
- **Each leg writes its own metrics file.** They share a work dir, so a single
|
||||
`.run-metrics.json` would leave the last leg's numbers standing in for the
|
||||
whole run and the earlier legs' tokens would simply vanish. `Segments` in the
|
||||
JSON records how many legs a run had.
|
||||
|
||||
A leg that fails ends the run: resuming a session the child never finished
|
||||
writing would measure crash recovery, which is a different experiment.
|
||||
|
||||
Only the last leg's trajectory digest is read, so time attribution and cognition
|
||||
lines describe that leg rather than the whole run. Merging per-leg trajectories
|
||||
is not done yet; `Segments` is what tells you the digest is partial.
|
||||
|
||||
## task.toml schema
|
||||
|
||||
@@ -240,7 +305,9 @@ own outcome).
|
||||
| `-cache` | `cold` | Suite mode: `cold` runs each task as a fresh session (the fair cross-agent comparison arm); `warm` primes the provider prefix cache with a one-step run in the same workdir first, measuring the long-lived-session steady state. Never mix arms in one report — compare them with `-mode compare cold.json warm.json`. |
|
||||
| `-budget` | `800000` | Abort once total tokens cross this (`0` = no cap). Remaining tasks are reported as skipped. |
|
||||
| `-meter` | *(off)* | Suite mode: route the benchmarked provider through the neutral measuring proxy, using this `config.toml` as the source. Spend is then counted at the request boundary instead of trusted from the harness. See [Neutral metering](#neutral-metering). |
|
||||
| `-faults` | *(none)* | Suite mode: inject provider failures at fixed request indices, e.g. `3:429,7:500`. Requires `-meter` — the proxy is the only place a fault can be injected. |
|
||||
| `-faults` | *(none)* | Suite mode: inject provider failures through the meter — absolute indices (`3:429`) and/or a cadence that scales with the run (`every:5:500`). Requires `-meter`. See [Fault recovery](#fault-recovery). |
|
||||
| `-segments` | `1` | Suite mode: split each task into N resumed legs (`--continue` between them). The step budget is **divided**, never multiplied. See [Segmented runs](#segmented-runs). |
|
||||
| `-steer` | *(none)* | Suite mode: deliver a user turn at a leg boundary, e.g. `"also handle empty input@2"`. Requires `-segments` to reach that leg. |
|
||||
|
||||
Diff-mode flags:
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func (s *snapshotter) snapshotIfChanged() {
|
||||
if err := copyDir(s.src, dir); err != nil {
|
||||
return
|
||||
}
|
||||
os.Remove(filepath.Join(dir, ".run-metrics.json"))
|
||||
dropHarnessArtifacts(dir)
|
||||
s.taken = append(s.taken, checkpoint{Seq: len(s.taken) + 1, ElapsedMs: elapsed, dir: dir})
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func dirSignature(root string) uint64 {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if name == ".run-metrics.json" {
|
||||
if isHarnessArtifact(name) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
@@ -352,7 +352,7 @@ func firstUsefulMutation(checkpoints []checkpoint, seedDir, finalDir string) int
|
||||
// solutionFiles maps relative path → final content for every file the run
|
||||
// created or changed; harness artifacts are not part of anyone's solution.
|
||||
func solutionFiles(seedDir, finalDir string) map[string]string {
|
||||
skip := map[string]bool{".run-metrics.json": true, "verify.sh": true}
|
||||
skip := map[string]bool{"verify.sh": true}
|
||||
out := map[string]string{}
|
||||
_ = filepath.WalkDir(finalDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -365,7 +365,7 @@ func solutionFiles(seedDir, finalDir string) map[string]string {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(finalDir, path)
|
||||
if skip[filepath.Base(rel)] {
|
||||
if base := filepath.Base(rel); skip[base] || isHarnessArtifact(base) {
|
||||
return nil
|
||||
}
|
||||
final, err := os.ReadFile(path)
|
||||
@@ -395,3 +395,24 @@ func attachSnapshotter(cfg suiteConfig, t task, work string, startedAt time.Time
|
||||
}
|
||||
return startSnapshotter(work, dir, startedAt), func() { _ = os.RemoveAll(dir) }
|
||||
}
|
||||
|
||||
// isHarnessArtifact reports whether a work-dir entry belongs to the benchmark
|
||||
// rather than to anyone's solution. Segmented runs write one metrics file per
|
||||
// leg, so the name is a prefix match — a hard-coded ".run-metrics.json" would
|
||||
// let every later leg's file read as a change the agent made.
|
||||
func isHarnessArtifact(name string) bool {
|
||||
return strings.HasPrefix(name, ".run-metrics") && strings.HasSuffix(name, ".json")
|
||||
}
|
||||
|
||||
// dropHarnessArtifacts removes them from a snapshot copy.
|
||||
func dropHarnessArtifacts(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && isHarnessArtifact(e.Name()) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// faultRecovery splits a run by whether the meter actually failed it. With a
|
||||
// cadence, short tasks never reach a fault and form an in-run control group,
|
||||
// so the cost of failure is measured against the same suite and model rather
|
||||
// than against a separate arm run at a different time.
|
||||
type faultRecovery struct {
|
||||
faulted, faultedSolved int
|
||||
unfaulted, unfaultSolved int
|
||||
keptGoing int // faulted runs that issued another request
|
||||
injected int
|
||||
}
|
||||
|
||||
func gatherFaultRecovery(results []result) faultRecovery {
|
||||
var f faultRecovery
|
||||
for _, r := range results {
|
||||
if r.Skipped || r.Meter == nil || r.Attempt > 1 {
|
||||
continue
|
||||
}
|
||||
if r.Meter.Injected == 0 {
|
||||
f.unfaulted++
|
||||
if r.Passed {
|
||||
f.unfaultSolved++
|
||||
}
|
||||
continue
|
||||
}
|
||||
f.faulted++
|
||||
f.injected += r.Meter.Injected
|
||||
if r.Meter.RequestsAfterFault > 0 {
|
||||
f.keptGoing++
|
||||
}
|
||||
if r.Passed {
|
||||
f.faultedSolved++
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// renderFaultRecovery prices injected failure. Two different things are worth
|
||||
// separating: whether the harness kept talking after a failure at all, and
|
||||
// whether the task still landed. A harness can retry forever and still never
|
||||
// finish, and that is not recovery.
|
||||
func renderFaultRecovery(results []result) string {
|
||||
f := gatherFaultRecovery(results)
|
||||
if f.faulted == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "**Fault recovery** (%d runs failed on purpose, %d injections): **retried** %s (%d) · **still solved** %s (%d/%d)",
|
||||
f.faulted, f.injected, pct(f.keptGoing, f.faulted), f.keptGoing,
|
||||
pct(f.faultedSolved, f.faulted), f.faultedSolved, f.faulted)
|
||||
if f.unfaulted > 0 {
|
||||
fmt.Fprintf(&b, " · in-run control %s (%d/%d never hit a fault)",
|
||||
pct(f.unfaultSolved, f.unfaulted), f.unfaultSolved, f.unfaulted)
|
||||
}
|
||||
return b.String() + "\n\n"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func faultedRun(id string, injected, afterFault int, passed bool) result {
|
||||
r := result{task: task{ID: id}, Attempt: 1, Passed: passed}
|
||||
r.Meter = &meterUsage{Requests: 10, Injected: injected, RequestsAfterFault: afterFault}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestFaultCadenceScalesWithTheRun(t *testing.T) {
|
||||
script, err := parseFaultScript("every:5:500")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
for _, index := range []int{5, 10, 15} {
|
||||
if status, ok := script.statusFor(index); !ok || status != 500 {
|
||||
t.Fatalf("request %d = %d/%v, want an injected 500", index, status, ok)
|
||||
}
|
||||
}
|
||||
for _, index := range []int{1, 4, 6, 9} {
|
||||
if _, ok := script.statusFor(index); ok {
|
||||
t.Fatalf("request %d must be forwarded", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An absolute index is a targeted failure; a cadence is background pressure.
|
||||
// The targeted one must stay exactly where it was asked for.
|
||||
func TestAbsoluteFaultWinsOverTheCadence(t *testing.T) {
|
||||
script, err := parseFaultScript("10:429,every:5:500")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if status, _ := script.statusFor(10); status != 429 {
|
||||
t.Fatalf("request 10 = %d, want the targeted 429", status)
|
||||
}
|
||||
if status, _ := script.statusFor(5); status != 500 {
|
||||
t.Fatalf("request 5 = %d, want the cadence 500", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaultScriptRejectsMalformedCadence(t *testing.T) {
|
||||
for _, bad := range []string{"every:5", "every:0:500", "every:x:500", "every:5:200"} {
|
||||
if _, err := parseFaultScript(bad); err == nil {
|
||||
t.Fatalf("%q must be rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeterCountsRequestsAfterAFault(t *testing.T) {
|
||||
upstream := okUpstream()
|
||||
script, _ := parseFaultScript("2:429")
|
||||
m, base, stop := meterAgainst(t, upstream, script)
|
||||
defer stop()
|
||||
for range 4 {
|
||||
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
|
||||
}
|
||||
got := m.snapshot()
|
||||
if got.Injected != 1 {
|
||||
t.Fatalf("injected = %d, want 1", got.Injected)
|
||||
}
|
||||
if got.RequestsAfterFault != 2 {
|
||||
t.Fatalf("requests after fault = %d, want 2 (requests 3 and 4)", got.RequestsAfterFault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeterCountsNoRequestsAfterFaultWhenTheHarnessGivesUp(t *testing.T) {
|
||||
script, _ := parseFaultScript("2:429")
|
||||
m, base, stop := meterAgainst(t, okUpstream(), script)
|
||||
defer stop()
|
||||
for range 2 {
|
||||
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
|
||||
}
|
||||
if got := m.snapshot(); got.RequestsAfterFault != 0 {
|
||||
t.Fatalf("requests after fault = %d, want 0 — nothing followed the failure", got.RequestsAfterFault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaultRecoverySeparatesRetryingFromSolving(t *testing.T) {
|
||||
got := renderFaultRecovery([]result{
|
||||
faultedRun("solved-through", 2, 5, true),
|
||||
faultedRun("retried-but-lost", 1, 4, false),
|
||||
faultedRun("gave-up", 1, 0, false),
|
||||
faultedRun("never-faulted", 0, 0, true),
|
||||
})
|
||||
for _, want := range []string{
|
||||
"**Fault recovery** (3 runs failed on purpose, 4 injections)",
|
||||
"**retried** 67% (2)",
|
||||
"**still solved** 33% (1/3)",
|
||||
"in-run control 100% (1/1 never hit a fault)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("recovery line missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaultRecoveryRendersNothingWithoutInjections(t *testing.T) {
|
||||
if got := renderFaultRecovery([]result{faultedRun("clean", 0, 0, true)}); got != "" {
|
||||
t.Fatalf("want no section when nothing was injected, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
+28
-22
@@ -120,6 +120,9 @@ type result struct {
|
||||
// and token aggregates instead of being averaged in as zero, which would
|
||||
// quietly understate every published per-task figure.
|
||||
Unaccounted bool `json:"unaccounted"`
|
||||
// Segments is how many resumed legs the run was split into (1 = a single
|
||||
// leg). Above 1 the trajectory digest covers only the last leg.
|
||||
Segments int `json:"segments,omitempty"`
|
||||
// Partial marks accounting recovered from an in-flight snapshot after the
|
||||
// agent was killed. The numbers are real but stop at the last snapshot, so
|
||||
// they are counted as lower bounds rather than dropped.
|
||||
@@ -227,8 +230,7 @@ func main() {
|
||||
cacheArm := flag.String("cache", "cold", "suite mode: cold (fresh session per task) | warm (prefix-warming one-step run in the same workdir before the graded run)")
|
||||
effort := flag.String("effort", "", "reasoning effort override passed to the agent (model-specific levels, e.g. disabled|low|high|max); empty = model default")
|
||||
checkpoints := flag.Bool("checkpoints", false, "suite mode: snapshot the workdir on every change and grade each snapshot offline after the run, yielding first_correct_ms (TTFCS) and post_solve_waste_ms")
|
||||
meterConfig := flag.String("meter", "", "suite mode: route the benchmarked provider through the neutral measuring proxy, using this config.toml as the source (e.g. ~/.reasonix/config.toml). Spend is then counted at the request boundary instead of trusted from the harness")
|
||||
faultSpec := flag.String("faults", "", "suite mode: inject provider failures at fixed request indices, e.g. 3:429,7:500 (requires -meter)")
|
||||
pressure := registerPressureFlags()
|
||||
policyFlag := flag.String("policy", "", "suite mode: experiment arm — empty (baseline) | ebm (evidence-before-more-mutation nudge) | governor (exploration-phase reasoning governor) | memory-off (hide the memory store: MemoryBench counterfactual arm)")
|
||||
forkCapture := flag.String("fork-capture", "", "suite mode: capture a fork bundle per task at first EBM eligibility into <dir>/<task-id>")
|
||||
bundles := flag.String("bundles", "", "fork mode: directory of captured bundles (<task-id>/bundle.json)")
|
||||
@@ -311,12 +313,12 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
meterSource, faults := meterSettings(*meterConfig, *faultSpec)
|
||||
meterSource, faults, segments, steers := pressure.settings()
|
||||
runSuiteMode(suiteConfig{
|
||||
bin: *bin, model: *model, profile: profile, arm: arm, budget: *budget,
|
||||
trajDir: *trajDir, forcePlanner: *forcePlanner, attempts: *attempts,
|
||||
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
|
||||
forkCapture: *forkCapture, meterConfig: meterSource, meterFaults: faults,
|
||||
forkCapture: *forkCapture, meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
|
||||
}, *suite, *taskFilter, *outMD, *outJSON)
|
||||
}
|
||||
|
||||
@@ -455,7 +457,11 @@ type suiteConfig struct {
|
||||
// meterConfig is the real config.toml whose provider endpoint each run is
|
||||
// redirected through the neutral meter; empty leaves runs unmetered.
|
||||
meterConfig string
|
||||
meterFaults map[int]int
|
||||
meterFaults faultScript
|
||||
// segments splits each task into that many resumed legs; steers delivers a
|
||||
// user turn at a leg boundary. Both are LongRun pressure, not defaults.
|
||||
segments int
|
||||
steers map[int]string
|
||||
}
|
||||
|
||||
// runSuite runs each task in order until the token budget is exhausted;
|
||||
@@ -503,13 +509,15 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
r.PlanForced = true
|
||||
}
|
||||
|
||||
// The per-leg file names are decided in runSegments; only the directory has
|
||||
// to exist before the first child starts, and the digest below reads the
|
||||
// last leg's file.
|
||||
trajPath := ""
|
||||
if cfg.trajDir != "" {
|
||||
if err := os.MkdirAll(cfg.trajDir, 0o755); err != nil {
|
||||
r.Note = "trajectory dir: " + err.Error()
|
||||
return r
|
||||
}
|
||||
trajPath = filepath.Join(cfg.trajDir, t.ID+".trajectory.jsonl")
|
||||
}
|
||||
|
||||
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
|
||||
@@ -533,11 +541,6 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
metricsPath := filepath.Join(work, ".run-metrics.json")
|
||||
args := buildRunTaskArgs(cfg, metricsPath, trajPath, t.MaxSteps, t.Prompt)
|
||||
|
||||
cmd := exec.CommandContext(ctx, cfg.bin, args...)
|
||||
cmd.Dir = work
|
||||
extraEnv, seedNote := taskExperimentEnv(cfg, t, work)
|
||||
if seedNote != "" {
|
||||
r.Note = seedNote
|
||||
@@ -545,27 +548,18 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
mtr := attachMeter(cfg, &r)
|
||||
defer mtr.close()
|
||||
extraEnv = append(extraEnv, mtr.env...)
|
||||
if len(extraEnv) > 0 {
|
||||
cmd.Env = append(os.Environ(), extraEnv...)
|
||||
}
|
||||
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
|
||||
startedAt := time.Now()
|
||||
snap, dropSnapshots := attachSnapshotter(cfg, t, work, startedAt)
|
||||
defer dropSnapshots()
|
||||
runErr := cmd.Run()
|
||||
runErr := runSegments(ctx, cfg, t, work, cfg.trajDir, extraEnv, &r)
|
||||
r.WallMs = time.Since(startedAt).Milliseconds()
|
||||
var taken []checkpoint
|
||||
if snap != nil {
|
||||
taken = snap.halt()
|
||||
}
|
||||
|
||||
if m, err := readMetrics(metricsPath); err == nil {
|
||||
r.runMetrics = m
|
||||
}
|
||||
mtr.record(&r)
|
||||
if trajPath != "" {
|
||||
if trajPath = lastSegmentTrajectory(cfg.trajDir, t.ID, r.Segments); trajPath != "" {
|
||||
if summary, err := summarizeTrajectory(trajPath); err == nil {
|
||||
r.Trajectory = summary
|
||||
}
|
||||
@@ -635,6 +629,18 @@ func buildRunTaskArgs(cfg suiteConfig, metricsPath, trajectoryPath string, maxSt
|
||||
return append(args, prompt)
|
||||
}
|
||||
|
||||
// buildSegmentArgs is buildRunTaskArgs for one leg: a resumed leg adds
|
||||
// --continue, which is unambiguous because each task runs in its own home and
|
||||
// therefore its own session directory.
|
||||
func buildSegmentArgs(cfg suiteConfig, seg segment, metricsPath, trajectoryPath string) []string {
|
||||
args := buildRunTaskArgs(cfg, metricsPath, trajectoryPath, seg.maxSteps, seg.prompt)
|
||||
if !seg.resume {
|
||||
return args
|
||||
}
|
||||
// The prompt is the last argument; --continue must precede it.
|
||||
return append(args[:len(args)-1:len(args)-1], "--continue", args[len(args)-1])
|
||||
}
|
||||
|
||||
// warmPrefix primes the provider prefix cache for work's session shape with a
|
||||
// minimal one-step run before the graded run starts its clock. Its cost is
|
||||
// deliberately untracked: the warm arm measures a long-lived session's steady
|
||||
|
||||
+75
-22
@@ -23,9 +23,10 @@ import (
|
||||
type meter struct {
|
||||
upstream *url.URL
|
||||
client *http.Client
|
||||
faults map[int]int // 1-based request index -> status to return instead
|
||||
faults faultScript
|
||||
|
||||
mu sync.Mutex
|
||||
mu sync.Mutex
|
||||
lastFault int
|
||||
meterUsage
|
||||
}
|
||||
|
||||
@@ -40,35 +41,80 @@ type meterUsage struct {
|
||||
CacheMissTokens int `json:"cache_miss_tokens"`
|
||||
Injected int `json:"injected_faults,omitempty"`
|
||||
WithoutUsage int `json:"responses_without_usage,omitempty"`
|
||||
// RequestsAfterFault counts requests issued after the first injected
|
||||
// failure: the harness's own evidence that it retried rather than died.
|
||||
RequestsAfterFault int `json:"requests_after_fault,omitempty"`
|
||||
}
|
||||
|
||||
// parseFaultScript reads "3:429,7:500" — inject that status instead of
|
||||
// forwarding the Nth request. Deterministic by index so a LongRun arm replays
|
||||
// the same failures across harnesses.
|
||||
func parseFaultScript(spec string) (map[int]int, error) {
|
||||
// faultScript decides which requests fail. Absolute indices pin a failure to
|
||||
// an exact point; a cadence scales with the run, which is what a mixed-length
|
||||
// suite needs — a task that only ever makes four requests would never reach a
|
||||
// fixed index, and would silently join the unfaulted control group.
|
||||
type faultScript struct {
|
||||
at map[int]int // 1-based request index -> status
|
||||
everyN int
|
||||
everyStatus int
|
||||
}
|
||||
|
||||
func (f faultScript) empty() bool { return len(f.at) == 0 && f.everyN == 0 }
|
||||
|
||||
// statusFor reports the status to inject instead of forwarding. An absolute
|
||||
// index wins over the cadence so a targeted failure stays exactly where it was
|
||||
// asked for.
|
||||
func (f faultScript) statusFor(index int) (int, bool) {
|
||||
if status, ok := f.at[index]; ok {
|
||||
return status, true
|
||||
}
|
||||
if f.everyN > 0 && index%f.everyN == 0 {
|
||||
return f.everyStatus, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseFaultScript reads "3:429,every:5:500": fail the 3rd request with 429 and
|
||||
// every 5th with 500. Deterministic either way, so a LongRun arm replays the
|
||||
// same failures across harnesses.
|
||||
func parseFaultScript(spec string) (faultScript, error) {
|
||||
out := faultScript{at: map[int]int{}}
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
return nil, nil
|
||||
return faultScript{}, nil
|
||||
}
|
||||
out := map[int]int{}
|
||||
for field := range strings.SplitSeq(spec, ",") {
|
||||
idx, status, ok := strings.Cut(strings.TrimSpace(field), ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("fault %q: want <request-index>:<status>", field)
|
||||
field = strings.TrimSpace(field)
|
||||
if rest, ok := strings.CutPrefix(field, "every:"); ok {
|
||||
n, status, err := parseFaultPair(field, rest)
|
||||
if err != nil {
|
||||
return faultScript{}, err
|
||||
}
|
||||
out.everyN, out.everyStatus = n, status
|
||||
continue
|
||||
}
|
||||
i, err := strconv.Atoi(strings.TrimSpace(idx))
|
||||
if err != nil || i < 1 {
|
||||
return nil, fmt.Errorf("fault %q: request index must be a positive integer", field)
|
||||
index, status, err := parseFaultPair(field, field)
|
||||
if err != nil {
|
||||
return faultScript{}, err
|
||||
}
|
||||
code, err := strconv.Atoi(strings.TrimSpace(status))
|
||||
if err != nil || code < 400 || code > 599 {
|
||||
return nil, fmt.Errorf("fault %q: status must be 4xx or 5xx", field)
|
||||
}
|
||||
out[i] = code
|
||||
out.at[index] = status
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newMeter(upstream string, faults map[int]int) (*meter, error) {
|
||||
func parseFaultPair(field, pair string) (n, status int, err error) {
|
||||
left, right, ok := strings.Cut(pair, ":")
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("fault %q: want <request-index>:<status> or every:<n>:<status>", field)
|
||||
}
|
||||
n, err = strconv.Atoi(strings.TrimSpace(left))
|
||||
if err != nil || n < 1 {
|
||||
return 0, 0, fmt.Errorf("fault %q: the request count must be a positive integer", field)
|
||||
}
|
||||
status, err = strconv.Atoi(strings.TrimSpace(right))
|
||||
if err != nil || status < 400 || status > 599 {
|
||||
return 0, 0, fmt.Errorf("fault %q: status must be 4xx or 5xx", field)
|
||||
}
|
||||
return n, status, nil
|
||||
}
|
||||
|
||||
func newMeter(upstream string, faults faultScript) (*meter, error) {
|
||||
u, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(upstream), "/"))
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("meter upstream %q is not an absolute URL", upstream)
|
||||
@@ -98,9 +144,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
m.mu.Lock()
|
||||
m.Requests++
|
||||
index := m.Requests
|
||||
status, faulted := m.faults[index]
|
||||
if faulted {
|
||||
status, faulted := m.faults.statusFor(index)
|
||||
switch {
|
||||
case faulted:
|
||||
m.Injected++
|
||||
m.lastFault = index
|
||||
case m.lastFault > 0:
|
||||
// Evidence the harness kept going after being failed. A harness that
|
||||
// gives up on the first 429 never reaches here, and that is the
|
||||
// difference between "recovered" and "was never really tested".
|
||||
m.RequestsAfterFault++
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
+19
-11
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func meterAgainst(t *testing.T, upstream http.Handler, faults map[int]int) (*meter, string, func()) {
|
||||
func meterAgainst(t *testing.T, upstream http.Handler, faults faultScript) (*meter, string, func()) {
|
||||
t.Helper()
|
||||
up := httptest.NewServer(upstream)
|
||||
m, err := newMeter(up.URL, faults)
|
||||
@@ -38,7 +38,7 @@ func TestMeterCountsNonStreamingUsage(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}}`)
|
||||
})
|
||||
m, base, stop := meterAgainst(t, upstream, nil)
|
||||
m, base, stop := meterAgainst(t, upstream, faultScript{})
|
||||
defer stop()
|
||||
|
||||
resp := post(t, base, "/chat/completions", `{"model":"x"}`)
|
||||
@@ -64,7 +64,7 @@ func TestMeterReadsOpenAICachedTokensSpelling(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"usage":{"prompt_tokens":90,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":30}}}`)
|
||||
})
|
||||
m, base, stop := meterAgainst(t, upstream, nil)
|
||||
m, base, stop := meterAgainst(t, upstream, faultScript{})
|
||||
defer stop()
|
||||
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestMeterCountsStreamedUsageAndForwardsFrames(t *testing.T) {
|
||||
io.WriteString(w, "data: {\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}}\n\n")
|
||||
io.WriteString(w, "data: [DONE]\n\n")
|
||||
})
|
||||
m, base, stop := meterAgainst(t, upstream, nil)
|
||||
m, base, stop := meterAgainst(t, upstream, faultScript{})
|
||||
defer stop()
|
||||
|
||||
resp := post(t, base, "/chat/completions", `{"model":"x","stream":true}`)
|
||||
@@ -104,7 +104,7 @@ func TestMeterOptsStreamedRequestsIntoUsage(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
io.WriteString(w, "data: [DONE]\n\n")
|
||||
})
|
||||
_, base, stop := meterAgainst(t, upstream, nil)
|
||||
_, base, stop := meterAgainst(t, upstream, faultScript{})
|
||||
defer stop()
|
||||
post(t, base, "/chat/completions", `{"model":"x","stream":true}`).Body.Close()
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestMeterLeavesNonStreamedRequestsAlone(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
|
||||
})
|
||||
_, base, stop := meterAgainst(t, upstream, nil)
|
||||
_, base, stop := meterAgainst(t, upstream, faultScript{})
|
||||
defer stop()
|
||||
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestMeterReportsResponsesWithoutUsage(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"choices":[]}`)
|
||||
})
|
||||
m, base, stop := meterAgainst(t, upstream, nil)
|
||||
m, base, stop := meterAgainst(t, upstream, faultScript{})
|
||||
defer stop()
|
||||
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestMeterInjectsFaultsByRequestIndex(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
|
||||
})
|
||||
m, base, stop := meterAgainst(t, upstream, map[int]int{2: 429})
|
||||
m, base, stop := meterAgainst(t, upstream, faultScript{at: map[int]int{2: 429}})
|
||||
defer stop()
|
||||
|
||||
for i := range 3 {
|
||||
@@ -182,10 +182,10 @@ func TestParseFaultScript(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got[3] != 429 || got[7] != 500 || len(got) != 2 {
|
||||
if got.at[3] != 429 || got.at[7] != 500 || len(got.at) != 2 {
|
||||
t.Fatalf("faults = %v", got)
|
||||
}
|
||||
if got, err := parseFaultScript(""); err != nil || got != nil {
|
||||
if got, err := parseFaultScript(""); err != nil || !got.empty() {
|
||||
t.Fatalf("empty spec = %v, %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"3", "0:429", "3:200", "x:429", "3:999"} {
|
||||
@@ -196,7 +196,15 @@ func TestParseFaultScript(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewMeterRejectsRelativeUpstream(t *testing.T) {
|
||||
if _, err := newMeter("/v1", nil); err == nil {
|
||||
if _, err := newMeter("/v1", faultScript{}); err == nil {
|
||||
t.Fatal("a relative upstream must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// okUpstream is a minimal usage-reporting upstream for fault tests.
|
||||
func okUpstream() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
|
||||
})
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
// meterSettings validates the metering flags together: a fault script has
|
||||
// nowhere to be injected without the proxy, so asking for one without -meter
|
||||
// is a mistake worth stopping for rather than silently ignoring.
|
||||
func meterSettings(configPath, faultSpec string) (string, map[int]int) {
|
||||
func meterSettings(configPath, faultSpec string) (string, faultScript) {
|
||||
faults, err := parseFaultScript(faultSpec)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "faults:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(faults) > 0 && strings.TrimSpace(configPath) == "" {
|
||||
if !faults.empty() && strings.TrimSpace(configPath) == "" {
|
||||
fmt.Fprintln(os.Stderr, "faults: -faults needs -meter; the proxy is where a fault can be injected")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ func renderBody(results []result) string {
|
||||
b.WriteString(perSolvedLine(s))
|
||||
b.WriteString(requestsBySourceLine(s.bySource))
|
||||
b.WriteString(renderMeterAccounting(results))
|
||||
b.WriteString(renderFaultRecovery(results))
|
||||
b.WriteString(renderTimeAttribution(results))
|
||||
b.WriteString(renderSolveProfiles(results))
|
||||
b.WriteString(renderToolSurface(results))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runSegments drives a task's legs and folds their accounting into one result.
|
||||
// Each leg writes its own metrics file: sharing one path would let the last
|
||||
// leg's numbers stand in for the whole run, and the tokens the earlier legs
|
||||
// spent would simply vanish. Only the final leg's trajectory digest is kept;
|
||||
// Segments records how many legs it does not cover.
|
||||
func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir string, env []string, r *result) error {
|
||||
segs := planSegments(t, cfg.segments, cfg.steers)
|
||||
r.Segments = len(segs)
|
||||
var runErr error
|
||||
for _, seg := range segs {
|
||||
metricsPath := filepath.Join(work, fmt.Sprintf(".run-metrics-%d.json", seg.index))
|
||||
trajPath := segmentTrajectoryPath(trajDir, t.ID, seg, len(segs))
|
||||
args := buildSegmentArgs(cfg, seg, metricsPath, trajPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, cfg.bin, args...)
|
||||
cmd.Dir = work
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
runErr = cmd.Run()
|
||||
|
||||
if m, err := readMetrics(metricsPath); err == nil {
|
||||
foldSegmentMetrics(r, m, seg.index)
|
||||
}
|
||||
// A leg that died takes the run with it: resuming a session the child
|
||||
// never finished writing would measure the harness's crash recovery,
|
||||
// which is a different experiment.
|
||||
if runErr != nil || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return runErr
|
||||
}
|
||||
|
||||
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
|
||||
// the record of the one before it.
|
||||
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
if total < 2 {
|
||||
return filepath.Join(dir, id+".trajectory.jsonl")
|
||||
}
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.seg%d.trajectory.jsonl", id, seg.index))
|
||||
}
|
||||
|
||||
// lastSegmentTrajectory names the file whose digest represents the run. Only
|
||||
// the final leg's is read; Segments in the JSON says how many were not.
|
||||
func lastSegmentTrajectory(dir, id string, segments int) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
if segments < 2 {
|
||||
return filepath.Join(dir, id+".trajectory.jsonl")
|
||||
}
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.seg%d.trajectory.jsonl", id, segments))
|
||||
}
|
||||
|
||||
// foldSegmentMetrics adds one leg's spend to the run. The first leg's metrics
|
||||
// establish the non-additive fields (currency, outcome); later legs contribute
|
||||
// their totals, and the last leg's outcome wins because it is the one that
|
||||
// ended the run.
|
||||
func foldSegmentMetrics(r *result, m runMetrics, index int) {
|
||||
if index == 1 {
|
||||
r.runMetrics = m
|
||||
return
|
||||
}
|
||||
r.PromptTokens += m.PromptTokens
|
||||
r.CompletionTokens += m.CompletionTokens
|
||||
r.CacheHitTokens += m.CacheHitTokens
|
||||
r.CacheMissTokens += m.CacheMissTokens
|
||||
r.Cost += m.Cost
|
||||
r.Steps += m.Steps
|
||||
r.Compactions += m.Compactions
|
||||
r.ToolCalls += m.ToolCalls
|
||||
r.ToolFailures += m.ToolFailures
|
||||
r.SubagentToolCalls += m.SubagentToolCalls
|
||||
r.Retries += m.Retries
|
||||
r.Complete = m.Complete
|
||||
if m.Outcome != "" {
|
||||
r.Outcome = m.Outcome
|
||||
}
|
||||
for name, n := range m.ToolCallsByName {
|
||||
if r.ToolCallsByName == nil {
|
||||
r.ToolCallsByName = map[string]int{}
|
||||
}
|
||||
r.ToolCallsByName[name] += n
|
||||
}
|
||||
for reason, n := range m.PrefixChangeReasonCounts {
|
||||
if r.PrefixChangeReasonCounts == nil {
|
||||
r.PrefixChangeReasonCounts = map[string]int{}
|
||||
}
|
||||
r.PrefixChangeReasonCounts[reason] += n
|
||||
}
|
||||
accumulateSources(r.UsageBySource, m.UsageBySource)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// requireShellStub skips where a #!/usr/bin/env bash stub cannot be executed.
|
||||
// Every test that writes its own stub must call it: exec fails silently enough
|
||||
// on Windows that the assertion which follows blames the code instead.
|
||||
func requireShellStub(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the stub agent is a shell script")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAgent stands in for the reasonix binary: it records every invocation's
|
||||
// argv and writes the metrics file it was told to, making segment accounting
|
||||
// verifiable without a provider. The failure it guards — a later leg replacing
|
||||
// an earlier leg's numbers — is invisible in any single run.
|
||||
func fakeAgent(t *testing.T, promptTokens, completionTokens int) (bin, argvLog string) {
|
||||
t.Helper()
|
||||
requireShellStub(t)
|
||||
dir := t.TempDir()
|
||||
argvLog = filepath.Join(dir, "argv.log")
|
||||
bin = filepath.Join(dir, "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> ` + argvLog + `
|
||||
metrics=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--metrics) metrics="$2"; shift 2;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
[ -n "$metrics" ] && cat > "$metrics" <<JSON
|
||||
{"prompt_tokens":` + strconv.Itoa(promptTokens) + `,"completion_tokens":` + strconv.Itoa(completionTokens) + `,"steps":2,"cost":0.5,"currency":"USD","complete":true,"tool_calls":3}
|
||||
JSON
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin, argvLog
|
||||
}
|
||||
|
||||
func TestSegmentedRunAddsEveryLegsSpend(t *testing.T) {
|
||||
bin, argvLog := fakeAgent(t, 100, 20)
|
||||
cfg := suiteConfig{bin: bin, segments: 3}
|
||||
task := task{ID: "seg", Prompt: "fix it", MaxSteps: 9, TimeoutSec: 60}
|
||||
var r result
|
||||
if err := runSegments(t.Context(), cfg, task, t.TempDir(), "", nil, &r); err != nil {
|
||||
t.Fatalf("runSegments: %v", err)
|
||||
}
|
||||
|
||||
if r.Segments != 3 {
|
||||
t.Fatalf("segments = %d, want 3", r.Segments)
|
||||
}
|
||||
if r.PromptTokens != 300 || r.CompletionTokens != 60 {
|
||||
t.Fatalf("tokens = %d/%d, want 300/60 — a later leg replaced an earlier one instead of adding",
|
||||
r.PromptTokens, r.CompletionTokens)
|
||||
}
|
||||
if r.Steps != 6 || r.ToolCalls != 9 {
|
||||
t.Fatalf("steps=%d tools=%d, want 6/9 summed across legs", r.Steps, r.ToolCalls)
|
||||
}
|
||||
if r.Cost != 1.5 {
|
||||
t.Fatalf("cost = %v, want 1.5", r.Cost)
|
||||
}
|
||||
if r.Currency != "USD" || !r.Complete {
|
||||
t.Fatalf("non-additive fields lost: %+v", r.runMetrics)
|
||||
}
|
||||
|
||||
argv, err := os.ReadFile(argvLog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(argv)), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("invocations = %d, want 3", len(lines))
|
||||
}
|
||||
if strings.Contains(lines[0], "--continue") {
|
||||
t.Fatalf("leg 1 must start a fresh session: %s", lines[0])
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
if !strings.Contains(line, "--continue") {
|
||||
t.Fatalf("leg %d must resume: %s", i+2, line)
|
||||
}
|
||||
if strings.Contains(line, "fix it") {
|
||||
t.Fatalf("leg %d restated the task: %s", i+2, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentedRunKeepsPerLegMetricsApart(t *testing.T) {
|
||||
bin, _ := fakeAgent(t, 10, 1)
|
||||
work := t.TempDir()
|
||||
var r result
|
||||
if err := runSegments(t.Context(), suiteConfig{bin: bin, segments: 2}, task{ID: "x", Prompt: "p", MaxSteps: 4, TimeoutSec: 60}, work, "", nil, &r); err != nil {
|
||||
t.Fatalf("runSegments: %v", err)
|
||||
}
|
||||
for _, name := range []string{".run-metrics-1.json", ".run-metrics-2.json"} {
|
||||
if _, err := os.Stat(filepath.Join(work, name)); err != nil {
|
||||
t.Fatalf("%s missing: one shared path would have let the last leg overwrite the rest", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentedRunStopsAtTheFirstFailedLeg(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "failing-agent")
|
||||
log := filepath.Join(dir, "calls.log")
|
||||
script := "#!/usr/bin/env bash\necho x >> " + log + "\nexit 3\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var r result
|
||||
err := runSegments(t.Context(), suiteConfig{bin: bin, segments: 3}, task{ID: "x", Prompt: "p", MaxSteps: 6, TimeoutSec: 60}, t.TempDir(), "", nil, &r)
|
||||
if err == nil {
|
||||
t.Fatal("a failed leg must surface as the run's error")
|
||||
}
|
||||
calls, _ := os.ReadFile(log)
|
||||
if got := len(strings.Fields(string(calls))); got != 1 {
|
||||
t.Fatalf("invocations = %d, want 1 — resuming a session the child never finished is a different experiment", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsegmentedRunKeepsTheOriginalTrajectoryPath(t *testing.T) {
|
||||
if got := lastSegmentTrajectory("t", "task-a", 1); got != filepath.Join("t", "task-a.trajectory.jsonl") {
|
||||
t.Fatalf("path = %q, want the unsegmented name", got)
|
||||
}
|
||||
if got := lastSegmentTrajectory("t", "task-a", 3); got != filepath.Join("t", "task-a.seg3.trajectory.jsonl") {
|
||||
t.Fatalf("path = %q, want the last leg's file", got)
|
||||
}
|
||||
if got := lastSegmentTrajectory("", "task-a", 3); got != "" {
|
||||
t.Fatalf("path = %q, want empty when trajectories are off", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// segment is one leg of a task: leg 1 starts the session, later legs resume it.
|
||||
// Segmenting reaches the states that only appear in a long session — reload,
|
||||
// prefix reconstruction, compaction across a boundary — without waiting hours
|
||||
// for them to arrive on their own.
|
||||
type segment struct {
|
||||
index int
|
||||
maxSteps int
|
||||
prompt string
|
||||
resume bool
|
||||
}
|
||||
|
||||
// continuationPrompt is deliberately empty of new instruction: a resumed leg
|
||||
// must exercise the session's own memory of the task, not be handed the task
|
||||
// again. A prompt that restates the work would hide exactly the degradation
|
||||
// this measures.
|
||||
const continuationPrompt = "Continue from where you left off."
|
||||
|
||||
// planSegments splits a task into count legs, dividing its step budget and
|
||||
// giving the remainder to the last leg so the total never exceeds max_steps.
|
||||
// A steer entry replaces a leg's continuation prompt.
|
||||
func planSegments(t task, count int, steers map[int]string) []segment {
|
||||
if count < 2 {
|
||||
return []segment{{index: 1, maxSteps: t.MaxSteps, prompt: t.Prompt}}
|
||||
}
|
||||
per := t.MaxSteps / count
|
||||
out := make([]segment, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
steps := per
|
||||
if i == count {
|
||||
steps = t.MaxSteps - per*(count-1)
|
||||
}
|
||||
seg := segment{index: i, maxSteps: steps, prompt: continuationPrompt, resume: true}
|
||||
if i == 1 {
|
||||
seg.prompt, seg.resume = t.Prompt, false
|
||||
}
|
||||
if steer, ok := steers[i]; ok {
|
||||
seg.prompt = steer
|
||||
}
|
||||
out = append(out, seg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// segmentSettings validates the two LongRun pressure flags together: a steer
|
||||
// aimed past the last leg would never be delivered, and silently dropping a
|
||||
// user turn is exactly the kind of quiet no-op a benchmark must not have.
|
||||
func segmentSettings(segments int, spec string) map[int]string {
|
||||
steers, err := parseSteers(spec)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "steer:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
for leg := range steers {
|
||||
if leg > segments {
|
||||
fmt.Fprintf(os.Stderr, "steer: leg %d needs -segments %d or more; the run has %d\n", leg, leg, segments)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
return steers
|
||||
}
|
||||
|
||||
// parseSteers reads "message@2,another@3": deliver that message as leg N's
|
||||
// prompt. Steering is a user turn arriving mid-task, so it belongs to a leg
|
||||
// boundary rather than to a wall-clock instant nothing can reproduce.
|
||||
func parseSteers(spec string) (map[int]string, error) {
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[int]string{}
|
||||
for field := range strings.SplitSeq(spec, ",") {
|
||||
message, at, ok := strings.Cut(strings.TrimSpace(field), "@")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("steer %q: want <message>@<segment>", field)
|
||||
}
|
||||
leg, err := strconv.Atoi(strings.TrimSpace(at))
|
||||
if err != nil || leg < 2 {
|
||||
return nil, fmt.Errorf("steer %q: the segment must be 2 or later — leg 1 carries the task itself", field)
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return nil, fmt.Errorf("steer %q: the message is empty", field)
|
||||
}
|
||||
out[leg] = strings.TrimSpace(message)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pressureFlags groups the LongRun knobs: neutral metering, injected faults,
|
||||
// and segmented resume with steering. They are registered and validated
|
||||
// together because they only make sense together.
|
||||
type pressureFlags struct {
|
||||
meter, faults, steer *string
|
||||
segments *int
|
||||
}
|
||||
|
||||
func registerPressureFlags() pressureFlags {
|
||||
return pressureFlags{
|
||||
meter: flag.String("meter", "", "suite mode: route the benchmarked provider through the neutral measuring proxy, using this config.toml as the source (e.g. ~/.reasonix/config.toml). Spend is then counted at the request boundary instead of trusted from the harness"),
|
||||
faults: flag.String("faults", "", "suite mode: inject provider failures through the meter — absolute indices (3:429) and/or a cadence that scales with the run (every:5:500). Requires -meter; the report gains a fault-recovery readout"),
|
||||
steer: flag.String("steer", "", "suite mode: deliver a user turn at a leg boundary, e.g. \"also handle empty input@2\" (requires -segments >= that leg)"),
|
||||
segments: flag.Int("segments", 1, "suite mode: split each task into N resumed legs (--continue between them), reaching reload and compaction pressure without waiting hours for it. The step budget is divided, never multiplied"),
|
||||
}
|
||||
}
|
||||
|
||||
func (p pressureFlags) settings() (config string, faults faultScript, segments int, steers map[int]string) {
|
||||
config, faults = meterSettings(*p.meter, *p.faults)
|
||||
return config, faults, *p.segments, segmentSettings(*p.segments, *p.steer)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlanSegmentsIsOneLegByDefault(t *testing.T) {
|
||||
task := task{ID: "x", Prompt: "fix it", MaxSteps: 12}
|
||||
for _, count := range []int{0, 1} {
|
||||
got := planSegments(task, count, nil)
|
||||
if len(got) != 1 || got[0].resume || got[0].prompt != "fix it" || got[0].maxSteps != 12 {
|
||||
t.Fatalf("count %d = %+v, want the unsegmented run", count, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The step budget is the task's contract; splitting must not quietly hand a
|
||||
// segmented arm more work than the control arm gets.
|
||||
func TestPlanSegmentsPreservesTheStepBudget(t *testing.T) {
|
||||
for _, steps := range []int{12, 13, 20, 7} {
|
||||
got := planSegments(task{Prompt: "p", MaxSteps: steps}, 3, nil)
|
||||
total := 0
|
||||
for _, seg := range got {
|
||||
if seg.maxSteps < 1 {
|
||||
t.Fatalf("steps %d produced an empty leg: %+v", steps, got)
|
||||
}
|
||||
total += seg.maxSteps
|
||||
}
|
||||
if total != steps {
|
||||
t.Fatalf("steps %d split into %d, want the budget preserved", steps, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSegmentsResumesEveryLegAfterTheFirst(t *testing.T) {
|
||||
got := planSegments(task{Prompt: "fix it", MaxSteps: 9}, 3, nil)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("legs = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].resume || got[0].prompt != "fix it" {
|
||||
t.Fatalf("leg 1 = %+v, want the task itself on a fresh session", got[0])
|
||||
}
|
||||
for _, seg := range got[1:] {
|
||||
if !seg.resume {
|
||||
t.Fatalf("leg %d must resume: %+v", seg.index, seg)
|
||||
}
|
||||
if strings.Contains(seg.prompt, "fix it") {
|
||||
t.Fatalf("leg %d restates the task, which hides the degradation being measured: %q", seg.index, seg.prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSegmentsDeliversSteering(t *testing.T) {
|
||||
steers, err := parseSteers("also handle empty input@2")
|
||||
if err != nil {
|
||||
t.Fatalf("parseSteers: %v", err)
|
||||
}
|
||||
got := planSegments(task{Prompt: "fix it", MaxSteps: 9}, 3, steers)
|
||||
if got[1].prompt != "also handle empty input" {
|
||||
t.Fatalf("leg 2 prompt = %q, want the steering message", got[1].prompt)
|
||||
}
|
||||
if !got[1].resume {
|
||||
t.Fatalf("a steered leg still resumes: %+v", got[1])
|
||||
}
|
||||
if got[2].prompt != continuationPrompt {
|
||||
t.Fatalf("leg 3 = %q, want the plain continuation", got[2].prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSteersRejectsLegOneAndMalformedSpecs(t *testing.T) {
|
||||
for _, bad := range []string{"msg@1", "msg@0", "msg", "@2", "msg@x"} {
|
||||
if _, err := parseSteers(bad); err == nil {
|
||||
t.Fatalf("%q must be rejected", bad)
|
||||
}
|
||||
}
|
||||
if got, err := parseSteers(""); err != nil || got != nil {
|
||||
t.Fatalf("empty spec = %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -35,7 +35,8 @@ vet / test ./...` skip this directory, while the import path stays under
|
||||
|
||||
- Go (matches the parent module).
|
||||
- Node + **pnpm** (`npm i -g pnpm`).
|
||||
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
- Wails CLI matching the library: run `make wails-install` from the repository
|
||||
root. The target reads the shared `.wails-version` pin.
|
||||
- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2**
|
||||
runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links
|
||||
against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu
|
||||
|
||||
+5
-111
@@ -45,7 +45,6 @@ import (
|
||||
"reasonix/internal/fileref"
|
||||
fileenc "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/mcpdiag"
|
||||
"reasonix/internal/mcpregistry"
|
||||
"reasonix/internal/memory"
|
||||
@@ -795,6 +794,9 @@ func (a *App) restoreOrBuildTabs() {
|
||||
// freshly written config (including the user's default_model) is
|
||||
// picked up by Load instead of falling back to built-in defaults.
|
||||
_, _ = config.MigrateLegacyIfNeeded()
|
||||
if err := reconcileTopicArchiveMetadataPending(a.deleteTopic); err != nil {
|
||||
slog.Warn("desktop: topic archive metadata reconciliation remains pending")
|
||||
}
|
||||
f := loadTabsFile()
|
||||
_, _ = recoverLegacyProjectSidebarRoots(f)
|
||||
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
|
||||
@@ -2480,32 +2482,7 @@ func removeDesktopSessionArtifacts(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer guard.Release()
|
||||
if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
defer invalidateTopicSessionIndexForPath(path)
|
||||
for _, p := range sessionOwnedArtifactPaths(path) {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeSessionPlannerDisplay(filepath.Dir(path), path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
return agent.ClearCleanupPending(path)
|
||||
return removeDesktopSessionArtifactsWithGuard(path, guard)
|
||||
}
|
||||
|
||||
// CheckpointMeta summarises one rewind point (a user turn) for the desktop.
|
||||
@@ -3510,53 +3487,6 @@ func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallback
|
||||
return target
|
||||
}
|
||||
|
||||
func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
|
||||
var removed []removedSessionRuntime
|
||||
var fallback fallbackRuntimeTarget
|
||||
|
||||
a.mu.Lock()
|
||||
for id, tab := range a.tabs {
|
||||
if tab == nil || tab.TopicID != topicID {
|
||||
continue
|
||||
}
|
||||
sessionDir := tabRuntimeSessionDir(tab)
|
||||
sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
|
||||
if len(removed) == 0 {
|
||||
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
|
||||
}
|
||||
removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
|
||||
a.markTabRemovedLocked(tab)
|
||||
delete(a.tabs, id)
|
||||
a.removeTabOrderLocked(id)
|
||||
if a.activeTabID == id {
|
||||
a.activeTabID = ""
|
||||
}
|
||||
}
|
||||
for key, tab := range a.detachedSessions {
|
||||
if tab == nil || tab.TopicID != topicID {
|
||||
continue
|
||||
}
|
||||
sessionDir := tabRuntimeSessionDir(tab)
|
||||
sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
|
||||
if len(removed) == 0 {
|
||||
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
|
||||
}
|
||||
removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
|
||||
a.markTabRemovedLocked(tab)
|
||||
delete(a.detachedSessions, key)
|
||||
}
|
||||
if a.activeTabID == "" && len(a.tabOrder) > 0 {
|
||||
a.activeTabID = a.tabOrder[0]
|
||||
}
|
||||
fallback.needs = len(removed) > 0 && len(a.tabs) == 0
|
||||
dir, entries, activeID, version := a.saveTabsCollectLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.saveTabsWrite(dir, entries, activeID, version)
|
||||
|
||||
return removed, fallback
|
||||
}
|
||||
|
||||
func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {
|
||||
return removedSessionRuntime{
|
||||
tab: tab,
|
||||
@@ -3638,28 +3568,6 @@ func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []remove
|
||||
return destroys
|
||||
}
|
||||
|
||||
func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
|
||||
results := make(chan jobs.TeardownResult, len(destroys))
|
||||
waits := 0
|
||||
for _, destroy := range destroys {
|
||||
if destroy.Wait == nil {
|
||||
continue
|
||||
}
|
||||
waits++
|
||||
go func(wait func() jobs.TeardownResult) {
|
||||
results <- wait()
|
||||
}(destroy.Wait)
|
||||
}
|
||||
|
||||
timedOut := false
|
||||
for range waits {
|
||||
if (<-results).HasTimedOut() {
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
return timedOut
|
||||
}
|
||||
|
||||
func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {
|
||||
for _, destroy := range destroys {
|
||||
if destroy.WaitAll != nil {
|
||||
@@ -6731,21 +6639,6 @@ func firstNonEmpty(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
|
||||
// and Window both zero means no context-window data yet.
|
||||
type ContextInfo struct {
|
||||
Used int `json:"used"`
|
||||
Window int `json:"window"`
|
||||
SessionTokens int `json:"sessionTokens"`
|
||||
CompactRatio float64 `json:"compactRatio,omitempty"`
|
||||
SessionCost float64 `json:"sessionCost,omitempty"`
|
||||
SessionCurrency string `json:"sessionCurrency,omitempty"`
|
||||
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
|
||||
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
|
||||
Estimated bool `json:"estimated,omitempty"`
|
||||
Sources map[string]usageSourceStats `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
// ContextUsage returns the latest context-window gauge numbers.
|
||||
func (a *App) ContextUsage() ContextInfo {
|
||||
return a.ContextUsageForTab("")
|
||||
@@ -6795,6 +6688,7 @@ func (a *App) ContextUsageForTab(tabID string) ContextInfo {
|
||||
info.Used = snap.Usage.LastUsedTokens
|
||||
}
|
||||
info.CompactRatio = ctrl.CompactRatio()
|
||||
info.Maintenance = contextMaintenanceInfo(ctrl.ContextMaintenanceSnapshot())
|
||||
return info
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
|
||||
// and Window both zero means no context-window data yet.
|
||||
type ContextInfo struct {
|
||||
Used int `json:"used"`
|
||||
Window int `json:"window"`
|
||||
SessionTokens int `json:"sessionTokens"`
|
||||
CompactRatio float64 `json:"compactRatio,omitempty"`
|
||||
SessionCost float64 `json:"sessionCost,omitempty"`
|
||||
SessionCurrency string `json:"sessionCurrency,omitempty"`
|
||||
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
|
||||
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
|
||||
Estimated bool `json:"estimated,omitempty"`
|
||||
Sources map[string]usageSourceStats `json:"sources,omitempty"`
|
||||
Maintenance *ContextMaintenanceInfo `json:"maintenance,omitempty"`
|
||||
}
|
||||
|
||||
// ContextMaintenanceInfo is the Wails-safe current-view snapshot. Optional
|
||||
// fields preserve compatibility with older desktop/front-end combinations.
|
||||
type ContextMaintenanceInfo struct {
|
||||
CanonicalTokens int `json:"canonicalTokens,omitempty"`
|
||||
ProjectedTokens int `json:"projectedTokens,omitempty"`
|
||||
SummaryTokens int `json:"summaryTokens,omitempty"`
|
||||
LastSavedTokens int `json:"lastSavedTokens,omitempty"`
|
||||
SnipTrigger int `json:"snipTrigger,omitempty"`
|
||||
FoldTrigger int `json:"foldTrigger,omitempty"`
|
||||
ForceTrigger int `json:"forceTrigger,omitempty"`
|
||||
HardInputCeiling int `json:"hardInputCeiling,omitempty"`
|
||||
Headroom int `json:"headroom,omitempty"`
|
||||
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
LastReceipt *ContextMaintenanceReceiptInfo `json:"lastReceipt,omitempty"`
|
||||
}
|
||||
|
||||
type ContextMaintenanceReceiptInfo struct {
|
||||
OperationID string `json:"operationId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Trigger string `json:"trigger,omitempty"`
|
||||
SourceProjection uint64 `json:"sourceProjection,omitempty"`
|
||||
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
|
||||
CoveredCount int `json:"coveredCount,omitempty"`
|
||||
CoveredPrefixHash string `json:"coveredPrefixHash,omitempty"`
|
||||
InputHash string `json:"inputHash,omitempty"`
|
||||
OutputHash string `json:"outputHash,omitempty"`
|
||||
InputTokens int `json:"inputTokens,omitempty"`
|
||||
ResultTokens int `json:"resultTokens,omitempty"`
|
||||
SavedTokens int `json:"savedTokens,omitempty"`
|
||||
AffectedToolResults int `json:"affectedToolResults,omitempty"`
|
||||
SummaryHash string `json:"summaryHash,omitempty"`
|
||||
Archive string `json:"archive,omitempty"`
|
||||
CacheBreak bool `json:"cacheBreak,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BlockedInputHash string `json:"blockedInputHash,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
func contextMaintenanceInfo(snapshot agent.ContextMaintenanceSnapshot) *ContextMaintenanceInfo {
|
||||
if snapshot.CanonicalTokens == 0 && snapshot.ProjectedTokens == 0 && snapshot.LastReceipt == nil {
|
||||
return nil
|
||||
}
|
||||
info := &ContextMaintenanceInfo{
|
||||
CanonicalTokens: snapshot.CanonicalTokens, ProjectedTokens: snapshot.ProjectedTokens,
|
||||
SummaryTokens: snapshot.SummaryTokens, LastSavedTokens: snapshot.LastSavedTokens,
|
||||
SnipTrigger: snapshot.SnipTrigger, FoldTrigger: snapshot.FoldTrigger,
|
||||
ForceTrigger: snapshot.ForceTrigger, HardInputCeiling: snapshot.HardInputCeiling,
|
||||
Headroom: snapshot.Headroom, ProjectionVersion: snapshot.ProjectionVersion,
|
||||
Blocked: snapshot.Blocked,
|
||||
}
|
||||
if source := snapshot.LastReceipt; source != nil {
|
||||
info.LastReceipt = contextMaintenanceReceiptInfo(source)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func contextMaintenanceReceiptInfo(source *agent.ContextMaintenanceReceipt) *ContextMaintenanceReceiptInfo {
|
||||
receipt := &ContextMaintenanceReceiptInfo{
|
||||
OperationID: source.OperationID, Status: source.Status, Action: source.Action, Trigger: source.Trigger,
|
||||
SourceProjection: source.SourceProjection, ProjectionVersion: source.ProjectionVersion,
|
||||
CoveredCount: source.CoveredCount, CoveredPrefixHash: source.CoveredPrefixHash,
|
||||
InputHash: source.InputHash, OutputHash: source.OutputHash,
|
||||
InputTokens: source.InputTokens, ResultTokens: source.ResultTokens, SavedTokens: source.SavedTokens,
|
||||
AffectedToolResults: source.AffectedToolResults, SummaryHash: source.SummaryHash, Archive: source.Archive,
|
||||
CacheBreak: source.CacheBreak, Reason: source.Reason, BlockedInputHash: source.BlockedInputHash,
|
||||
}
|
||||
if !source.CreatedAt.IsZero() {
|
||||
receipt.CreatedAt = source.CreatedAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) {
|
||||
b, err := json.Marshal(ContextInfo{Maintenance: &ContextMaintenanceInfo{
|
||||
ProjectedTokens: 1200,
|
||||
LastReceipt: &ContextMaintenanceReceiptInfo{
|
||||
OperationID: "op", Action: "prune", SavedTokens: 4096,
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{`"maintenance"`, `"projectedTokens":1200`, `"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("ContextInfo JSON missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "operation_id") || strings.Contains(got, "saved_tokens") {
|
||||
t.Fatalf("ContextInfo leaked sidecar snake_case into Wails JSON: %s", got)
|
||||
}
|
||||
}
|
||||
+42
-14
@@ -35,8 +35,9 @@ type ExternalOpenerView struct {
|
||||
|
||||
// ExternalOpenersView is the complete state for the Codex-style Open control.
|
||||
type ExternalOpenersView struct {
|
||||
Openers []ExternalOpenerView `json:"openers"`
|
||||
Preferred string `json:"preferred"`
|
||||
Openers []ExternalOpenerView `json:"openers"`
|
||||
Preferred string `json:"preferred"`
|
||||
WorkspaceOpenable bool `json:"workspaceOpenable,omitempty"`
|
||||
}
|
||||
|
||||
type externalOpenerSpec struct {
|
||||
@@ -185,6 +186,18 @@ func (a *App) ExternalOpeners() ExternalOpenersView {
|
||||
return ExternalOpenersView{Openers: views, Preferred: selected.View.ID}
|
||||
}
|
||||
|
||||
// ExternalOpenersForTab adds the tab-scoped local-workspace capability used by
|
||||
// the chat-header Open control. Scope is intentionally not part of the check:
|
||||
// both project tabs and Global tabs can own a real local workspace directory.
|
||||
func (a *App) ExternalOpenersForTab(tabID string) ExternalOpenersView {
|
||||
if _, err := a.externalOpenerWorkspacePathForTab(tabID); err != nil {
|
||||
return ExternalOpenersView{Openers: []ExternalOpenerView{}}
|
||||
}
|
||||
view := a.ExternalOpeners()
|
||||
view.WorkspaceOpenable = true
|
||||
return view
|
||||
}
|
||||
|
||||
// SetPreferredExternalOpener persists an installed, platform-owned opener id.
|
||||
func (a *App) SetPreferredExternalOpener(id string) error {
|
||||
specs := cachedPlatformExternalOpenerSpecs()
|
||||
@@ -206,24 +219,14 @@ func (a *App) OpenWorkspaceInExternalOpener(id string) error {
|
||||
// OpenWorkspaceInExternalOpenerForTab is tab-scoped so a rapid tab switch cannot
|
||||
// send the wrong project to an external application.
|
||||
func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
|
||||
root, _, ok := a.workspaceTargetForTab(tabID)
|
||||
if !ok {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
path, err := workspaceBaseFromRoot(root)
|
||||
path, err := a.externalOpenerWorkspacePathForTab(tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("workspace is not a directory")
|
||||
}
|
||||
|
||||
specs := cachedPlatformExternalOpenerSpecs()
|
||||
var spec externalOpenerSpec
|
||||
var ok bool
|
||||
if strings.TrimSpace(id) == "" {
|
||||
spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
|
||||
} else {
|
||||
@@ -235,6 +238,31 @@ func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
|
||||
return launchPlatformExternalOpener(spec, path)
|
||||
}
|
||||
|
||||
func (a *App) externalOpenerWorkspacePathForTab(tabID string) (string, error) {
|
||||
root, _, ok := a.workspaceTargetForTab(tabID)
|
||||
if !ok {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
// A bound tab with no root has no stable workspace to expose. Keep the legacy
|
||||
// no-tab current-directory fallback, but do not turn an incomplete tab into
|
||||
// an opener for the Desktop process's launch directory.
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
path, err := workspaceBaseFromRoot(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("workspace is not a directory")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// OpenLocalPathInExternalOpener opens an absolute local path with one of the
|
||||
// detected, platform-owned applications. It shares OpenLocalPath's safety
|
||||
// checks so a markdown link cannot turn an AI-generated executable path into
|
||||
|
||||
@@ -140,6 +140,54 @@ func TestSetPreferredExternalOpenerRejectsRendererCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerWorkspaceCapabilityUsesTheTabDirectoryNotScope(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
globalRoot := t.TempDir()
|
||||
missingRoot := filepath.Join(t.TempDir(), "missing")
|
||||
fileRoot := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(fileRoot, []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot},
|
||||
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot},
|
||||
"missing": {ID: "missing", Scope: "project", WorkspaceRoot: missingRoot},
|
||||
"file": {ID: "file", Scope: "global", WorkspaceRoot: fileRoot},
|
||||
"empty": {ID: "empty", Scope: "global"},
|
||||
}
|
||||
|
||||
for _, tabID := range []string{"project", "global"} {
|
||||
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err != nil {
|
||||
t.Errorf("externalOpenerWorkspacePathForTab(%q) = %v, want available", tabID, err)
|
||||
}
|
||||
}
|
||||
for _, tabID := range []string{"missing", "file", "empty", "unknown"} {
|
||||
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err == nil {
|
||||
t.Errorf("externalOpenerWorkspacePathForTab(%q) succeeded, want unavailable", tabID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenersForGlobalTabReportsWorkspaceCapability(t *testing.T) {
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"global": {ID: "global", Scope: "global", WorkspaceRoot: t.TempDir()},
|
||||
}
|
||||
view := app.ExternalOpenersForTab("global")
|
||||
if !view.WorkspaceOpenable {
|
||||
t.Fatal("ExternalOpenersForTab(global) workspaceOpenable = false, want true")
|
||||
}
|
||||
if view.Openers == nil {
|
||||
t.Fatal("ExternalOpenersForTab(global) openers = nil, want a Wails-safe array")
|
||||
}
|
||||
unavailable := app.ExternalOpenersForTab("unknown")
|
||||
if unavailable.WorkspaceOpenable || unavailable.Openers == nil {
|
||||
t.Fatalf("ExternalOpenersForTab(unknown) = %+v, want unavailable with an empty Wails-safe array", unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSaveDestinationIsSourceDetectsFilesystemAliases(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "Readme.md")
|
||||
@@ -294,4 +342,30 @@ func TestExternalOpenerViewIconIsBackwardCompatible(t *testing.T) {
|
||||
if !strings.Contains(string(withIcon), `"iconDataUrl":"data:image/png;base64,AA=="`) {
|
||||
t.Fatalf("native icon missing from JSON contract: %s", withIcon)
|
||||
}
|
||||
|
||||
withoutCapability, err := json.Marshal(ExternalOpenersView{Openers: []ExternalOpenerView{}, Preferred: "finder"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(withoutCapability), "workspaceOpenable") {
|
||||
t.Fatalf("false workspace capability should be omitted for old readers: %s", withoutCapability)
|
||||
}
|
||||
withCapability, err := json.Marshal(ExternalOpenersView{
|
||||
Openers: []ExternalOpenerView{},
|
||||
Preferred: "finder",
|
||||
WorkspaceOpenable: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(withCapability), `"workspaceOpenable":true`) {
|
||||
t.Fatalf("workspace capability missing from JSON contract: %s", withCapability)
|
||||
}
|
||||
var oldReader struct {
|
||||
Openers []ExternalOpenerView `json:"openers"`
|
||||
Preferred string `json:"preferred"`
|
||||
}
|
||||
if err := json.Unmarshal(withCapability, &oldReader); err != nil || oldReader.Preferred != "finder" || oldReader.Openers == nil {
|
||||
t.Fatalf("old reader rejected additive workspace capability: reader=%+v err=%v", oldReader, err)
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,9 @@
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"gsap": "^3.15.0",
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"highlight.js": "^11.10.0",
|
||||
"html-url-attributes": "3.0.1",
|
||||
|
||||
Generated
-22
@@ -12,9 +12,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@gsap/react':
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2([email protected])([email protected])
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.9
|
||||
version: 3.14.9([email protected]([email protected]))([email protected])
|
||||
@@ -24,9 +21,6 @@ importers:
|
||||
'@xterm/xterm':
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0
|
||||
gsap:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
hast-util-to-jsx-runtime:
|
||||
specifier: 2.3.6
|
||||
version: 2.3.6
|
||||
@@ -474,12 +468,6 @@ packages:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@gsap/[email protected]':
|
||||
resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==}
|
||||
peerDependencies:
|
||||
gsap: ^3.12.5
|
||||
react: '>=17'
|
||||
|
||||
'@humanfs/[email protected]':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -1270,9 +1258,6 @@ packages:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
@@ -2383,11 +2368,6 @@ snapshots:
|
||||
|
||||
'@exodus/[email protected]': {}
|
||||
|
||||
'@gsap/[email protected]([email protected])([email protected])':
|
||||
dependencies:
|
||||
gsap: 3.15.0
|
||||
react: 19.2.8
|
||||
|
||||
'@humanfs/[email protected]':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -3196,8 +3176,6 @@ snapshots:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
|
||||
@@ -78,8 +78,8 @@ for (const path of localeChunks) {
|
||||
|
||||
const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
.reduce((total, path) => total + statSync(path).size, 0);
|
||||
// Native Web Animations and frame-batched scrolling keep GSAP out of the eager
|
||||
// graph. Preserve the resulting startup headroom instead of letting the shell
|
||||
// Native Web Animations and frame-batched scrolling avoid an eager animation
|
||||
// runtime. Preserve the resulting startup headroom instead of letting the shell
|
||||
// drift back to the previous 2.27 MiB / 295 KiB-gzip edge.
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_200 * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -79,7 +79,7 @@ import { WorktreeBadge } from "./components/WorktreeBadge";
|
||||
import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { ExternalOpener } from "./components/ExternalOpener";
|
||||
import { ExternalOpener, shouldMountExternalOpener } from "./components/ExternalOpener";
|
||||
import { startTerminalEventBridge } from "./lib/terminalEvents";
|
||||
import { applyTerminalThemePreference } from "./lib/terminalTheme";
|
||||
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
|
||||
@@ -204,12 +204,15 @@ import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowSta
|
||||
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelWidth, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { createRafResizeUpdater } from "./lib/resizeDrag";
|
||||
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { useMountTransition } from "./lib/useMountTransition";
|
||||
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
|
||||
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
|
||||
import { continueDelivery } from "./lib/deliveryContinue";
|
||||
import { activateGoalAndSubmitOnTab } from "./lib/goalSubmit";
|
||||
import logoWordmark from "./assets/logo-wordmark.svg";
|
||||
|
||||
const TERMINAL_CLOSE_TRANSITION_MS = 250;
|
||||
|
||||
function noticePreviewMockEnabled(): boolean {
|
||||
const value = browserMockScenarioParam();
|
||||
return value === "notice" || value === "notices" || value === "notice-preview";
|
||||
@@ -1266,7 +1269,6 @@ export default function App() {
|
||||
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
|
||||
const [liveWorkspacePanelRenderWidth, setLiveWorkspacePanelRenderWidth] = useState<number | null>(null);
|
||||
const [liveTerminalHeight, setLiveTerminalHeight] = useState<number | null>(null);
|
||||
const [terminalContentVisible, setTerminalContentVisible] = useState(false);
|
||||
const terminalResizing = liveTerminalHeight !== null;
|
||||
const workspacePanelMaximized = useLayoutStore((s) => s.workspacePanelMaximized);
|
||||
const setWorkspacePanelMaximized = useLayoutStore((s) => s.setWorkspacePanelMaximized);
|
||||
@@ -1274,6 +1276,10 @@ export default function App() {
|
||||
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
|
||||
const terminalPanelOpen = useLayoutStore((s) => s.terminalPanelOpen);
|
||||
const setTerminalPanelOpen = useLayoutStore((s) => s.setTerminalPanelOpen);
|
||||
const { mounted: terminalContentVisible } = useMountTransition(
|
||||
terminalPanelOpen,
|
||||
TERMINAL_CLOSE_TRANSITION_MS,
|
||||
);
|
||||
const terminalHeight = useLayoutStore((s) => s.terminalHeight);
|
||||
const setTerminalHeight = useLayoutStore((s) => s.setTerminalHeight);
|
||||
const [dockRefreshKey, setDockRefreshKey] = useState(0);
|
||||
@@ -2997,21 +3003,6 @@ export default function App() {
|
||||
[setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight],
|
||||
);
|
||||
|
||||
// Manage terminal content visibility for open/close animation.
|
||||
// On open: mount content immediately. On close: wait for the grid-template-rows
|
||||
// transition to finish before unmounting.
|
||||
const handleTerminalTransitionEnd = useCallback((event: React.TransitionEvent<HTMLDivElement>) => {
|
||||
if (event.propertyName === "grid-template-rows" && !terminalPanelOpen) {
|
||||
setTerminalContentVisible(false);
|
||||
}
|
||||
}, [terminalPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalPanelOpen) {
|
||||
setTerminalContentVisible(true);
|
||||
}
|
||||
}, [terminalPanelOpen]);
|
||||
|
||||
const openWorkspacePanel = useCallback(
|
||||
(mode: RightDockMode = rightDockMode) => {
|
||||
closeTransientOverlays();
|
||||
@@ -4360,7 +4351,6 @@ export default function App() {
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={layoutStyle}
|
||||
onTransitionEnd={handleTerminalTransitionEnd}
|
||||
>
|
||||
{!appChromeHidden && (
|
||||
<AppChrome
|
||||
@@ -4715,8 +4705,8 @@ export default function App() {
|
||||
</div>
|
||||
<div className="topicbar__spacer" />
|
||||
<div className="topicbar__actions">
|
||||
{sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
|
||||
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
{sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<>
|
||||
@@ -4792,8 +4782,8 @@ export default function App() {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
|
||||
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
{!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
<Tooltip label={t("shortcuts.cheatsheetTitle")}>
|
||||
<button
|
||||
@@ -5426,6 +5416,7 @@ export default function App() {
|
||||
lastTurnOutputTokens={state.lastTurnOutputTokens}
|
||||
lastTurnModelMs={state.lastTurnModelMs}
|
||||
lastTurnOutputEstimated={state.lastTurnOutputEstimated}
|
||||
lastRequestTps={state.lastRequestTps}
|
||||
turnCost={state.turnCost}
|
||||
cost={state.sessionCost}
|
||||
currency={state.sessionCurrency}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Run: tsx src/__tests__/approval-animation.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type SubmittedAnswer = [allow: boolean, session: boolean, persist: boolean];
|
||||
type ControllableAnimation = {
|
||||
onfinish: (() => void) | null;
|
||||
oncancel: (() => void) | null;
|
||||
};
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
return dom;
|
||||
}
|
||||
|
||||
function mockNativeAnimate(
|
||||
dom: JSDOM,
|
||||
implementation: (options: KeyframeAnimationOptions) => ControllableAnimation,
|
||||
) {
|
||||
Object.defineProperty(dom.window.Element.prototype, "animate", {
|
||||
configurable: true,
|
||||
value: (_frames: Keyframe[] | PropertyIndexedKeyframes | null, options: number | KeyframeAnimationOptions) => {
|
||||
if (typeof options === "number") throw new TypeError("expected keyframe animation options");
|
||||
return implementation(options) as unknown as Animation;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function renderToolApproval(onAnswer: (...answer: SubmittedAnswer) => void) {
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ApprovalModal
|
||||
approval={{ id: "approval-animation", tool: "bash", subject: "echo safe" }}
|
||||
onAnswer={onAnswer}
|
||||
onStop={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
async function confirmSelectedAction() {
|
||||
const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement | null;
|
||||
if (!confirm) throw new Error("approval confirm button did not render");
|
||||
await act(async () => {
|
||||
confirm.click();
|
||||
await flushTimers();
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(root: Root, dom: JSDOM) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log("\napproval shelf animation");
|
||||
|
||||
// A real Web Animations implementation validates easing synchronously. Keep
|
||||
// the business action pending until the visual transition finishes.
|
||||
{
|
||||
const dom = installDom();
|
||||
const answers: SubmittedAnswer[] = [];
|
||||
const animations: ControllableAnimation[] = [];
|
||||
let easing: string | undefined;
|
||||
mockNativeAnimate(dom, (options) => {
|
||||
easing = options.easing;
|
||||
if (typeof easing !== "string" || easing.includes("power")) {
|
||||
throw new TypeError(`${String(easing)} is not a valid CSS easing`);
|
||||
}
|
||||
const animation: ControllableAnimation = { onfinish: null, oncancel: null };
|
||||
animations.push(animation);
|
||||
return animation;
|
||||
});
|
||||
|
||||
const root = await renderToolApproval((...answer) => answers.push(answer));
|
||||
await confirmSelectedAction();
|
||||
|
||||
eq(easing, "cubic-bezier(0.8, 0, 0.8, 0.28)", "shelf exit passes a valid CSS easing to Element.animate");
|
||||
eq(answers.length, 0, "approval waits for the shelf exit animation");
|
||||
eq(animations.length, 1, "approval starts one shelf exit animation");
|
||||
|
||||
await act(async () => {
|
||||
animations[0].onfinish?.();
|
||||
animations[0].oncancel?.();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(answers.length, 1, "finish and late cancel submit the approval only once");
|
||||
eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "finished animation preserves the selected approval");
|
||||
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// Animation cancellation must not discard a decision that the user already
|
||||
// confirmed.
|
||||
{
|
||||
const dom = installDom();
|
||||
const answers: SubmittedAnswer[] = [];
|
||||
const animation: ControllableAnimation = { onfinish: null, oncancel: null };
|
||||
mockNativeAnimate(dom, () => animation);
|
||||
|
||||
const root = await renderToolApproval((...answer) => answers.push(answer));
|
||||
await confirmSelectedAction();
|
||||
await act(async () => {
|
||||
animation.oncancel?.();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(answers.length, 1, "cancelled shelf animation still submits the approval once");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// The transition is cosmetic. Even a synchronously rejecting WebView must not
|
||||
// block the underlying approval RPC.
|
||||
{
|
||||
const dom = installDom();
|
||||
const answers: SubmittedAnswer[] = [];
|
||||
let attempts = 0;
|
||||
mockNativeAnimate(dom, () => {
|
||||
attempts += 1;
|
||||
throw new TypeError("WebView rejected the animation options");
|
||||
});
|
||||
|
||||
const root = await renderToolApproval((...answer) => answers.push(answer));
|
||||
await confirmSelectedAction();
|
||||
await confirmSelectedAction();
|
||||
|
||||
eq(attempts, 1, "a rejected animation is not retried by a second confirm");
|
||||
eq(answers.length, 1, "a rejected animation falls back to one approval submission");
|
||||
eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "animation fallback preserves the selected approval");
|
||||
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -4,7 +4,6 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { activeFileReferenceToken, pickInlineFileReference } from "../components/FileReferenceMenu";
|
||||
import { LocaleProvider, preloadDetectedLocale } from "../lib/i18n";
|
||||
@@ -14,17 +13,6 @@ import type { WireApproval } from "../lib/types";
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
|
||||
to?: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
};
|
||||
if (typeof gsapForTests.to === "function") {
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
|
||||
@@ -268,10 +268,9 @@ console.log("\ncomposer goal toggle");
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer();
|
||||
|
||||
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
eq(["spellcheck", "autocorrect", "autocapitalize"].map((name) => textarea.getAttribute(name)).join("/"), "false/off/off", "plain composer disables browser text assistance");
|
||||
await rerender({ insertRequest: { id: 1, text: "ship the release notes", mode: "replace" } });
|
||||
eq(textarea.value, "ship the release notes", "insert request populates the composer draft");
|
||||
// The insert queues a rAF that refocuses the textarea; drain that frame
|
||||
@@ -604,6 +603,7 @@ console.log("\ncomposer goal toggle");
|
||||
let token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
|
||||
const invocationId = token?.dataset.invocationId;
|
||||
if (!richInput || !token || !invocationId) throw new Error("rich invocation did not render for paste undo selection");
|
||||
eq(["spellcheck", "autocorrect", "autocapitalize"].map((name) => richInput.getAttribute(name)).join("/"), "false/off/off", "rich composer disables browser text assistance");
|
||||
const afterToken = document.createRange();
|
||||
afterToken.setStartAfter(token);
|
||||
afterToken.collapse(true);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { formatContextMaintenanceNotice } from "../lib/contextMaintenanceTypes";
|
||||
import type { DictKey, Translator } from "../lib/i18n";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
|
||||
const messages: Partial<Record<DictKey, string>> = {
|
||||
"context.maintenanceTitle": "上下文维护",
|
||||
"context.maintenanceActionPrune": "工具结果裁剪",
|
||||
"context.tokensValue": "{value} tokens",
|
||||
"projectTree.status.paused": "已暂停",
|
||||
"settings.typography.applied": "已应用",
|
||||
"task.state.failed": "失败",
|
||||
};
|
||||
|
||||
const translate: Translator = (key, vars) => {
|
||||
const value = messages[key] ?? key;
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? `{${name}}`));
|
||||
};
|
||||
|
||||
const applied = formatContextMaintenanceNotice({
|
||||
status: "applied",
|
||||
action: "prune",
|
||||
inputTokens: 120,
|
||||
resultTokens: 80,
|
||||
savedTokens: 40,
|
||||
}, translate);
|
||||
ok(applied === "上下文维护 · 工具结果裁剪 · 已应用 · 120 → 80 tokens · −40 tokens", `unexpected applied notice: ${applied}`);
|
||||
ok(!applied.includes("Context") && !applied.includes("applied"), "maintenance notice leaked hardcoded English");
|
||||
|
||||
const blocked = formatContextMaintenanceNotice({ status: "blocked" }, translate);
|
||||
ok(blocked === "上下文维护 · 已暂停", `unexpected blocked notice: ${blocked}`);
|
||||
|
||||
console.log("context-maintenance-notice: ok");
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
|
||||
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens, liveTurnUsageBreakdown } from "../components/ContextPanel";
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens, formatSharePercent, liveTurnUsageBreakdown } from "../components/ContextPanel";
|
||||
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
|
||||
import type { WireUsage } from "../lib/types";
|
||||
|
||||
@@ -29,6 +29,8 @@ function ok(condition: boolean, label: string) {
|
||||
|
||||
console.log("\ncontext panel breakdown");
|
||||
|
||||
eq(formatSharePercent(1, 1_000), "<1%", "small non-zero source shares are not shown as zero");
|
||||
|
||||
const mock = contextBreakdown(42_124, 128_000, 22_134, 12_345, 7_521);
|
||||
eq(
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { ClearContextCard } from "../components/ClearContextCard";
|
||||
import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
|
||||
@@ -26,17 +25,6 @@ const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
|
||||
to?: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
};
|
||||
if (typeof gsapForTests.to === "function") {
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Run: tsx src/__tests__/external-opener-preference-sync.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { ExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { ExternalOpenersView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
|
||||
const openers = [
|
||||
{ id: "finder", name: "Finder", kind: "file-manager" as const },
|
||||
{ id: "xcode", name: "Xcode", kind: "editor" as const },
|
||||
];
|
||||
|
||||
function createHarness(bridge: ExternalOpenerBridge) {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const renderTab = async (tabId: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<ExternalOpener key={tabId} tabId={tabId} dismissSignal={0} bridge={bridge} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
};
|
||||
const choose = async (name: string) => {
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]'))
|
||||
.find((button) => button.textContent?.includes(name))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
};
|
||||
const primaryLabel = () => container.querySelector<HTMLButtonElement>(".external-opener__primary")?.ariaLabel;
|
||||
const unmount = async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
};
|
||||
return { container, renderTab, choose, primaryLabel, unmount };
|
||||
}
|
||||
|
||||
console.log("\nexternal opener preference synchronization");
|
||||
|
||||
let preferred = "finder";
|
||||
let resolveOlderLaunch: (() => void) | undefined;
|
||||
const launches: string[] = [];
|
||||
const completionBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpenersForTab() { return { openers, preferred, workspaceOpenable: true }; },
|
||||
async SetPreferredExternalOpener(id) { preferred = id; },
|
||||
async OpenWorkspaceInExternalOpenerForTab(tabId, id) {
|
||||
launches.push(`${tabId}:${id}`);
|
||||
if (tabId === "older") await new Promise<void>((resolve) => { resolveOlderLaunch = resolve; });
|
||||
},
|
||||
};
|
||||
const completion = createHarness(completionBridge);
|
||||
await completion.renderTab("older");
|
||||
await completion.choose("Xcode");
|
||||
await completion.renderTab("visible");
|
||||
ok(completion.primaryLabel()?.includes("Finder") === true, "the visible tab initially discovers the old preference");
|
||||
await act(async () => { resolveOlderLaunch?.(); await flush(); });
|
||||
ok(preferred === "xcode" && completion.primaryLabel()?.includes("Xcode") === true, "an older tab completion synchronizes the visible control");
|
||||
await act(async () => {
|
||||
completion.container.querySelector<HTMLButtonElement>(".external-opener__primary")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
ok(launches.at(-1) === "visible:xcode", "the visible primary action uses the synchronized preference");
|
||||
await completion.unmount();
|
||||
|
||||
preferred = "finder";
|
||||
let resolveSecondOlderLaunch: (() => void) | undefined;
|
||||
let resolveStaleDiscovery: ((view: ExternalOpenersView) => void) | undefined;
|
||||
const staleDiscoveryBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpenersForTab(tabId) {
|
||||
if (tabId !== "visible") return { openers, preferred, workspaceOpenable: true };
|
||||
return new Promise<ExternalOpenersView>((resolve) => { resolveStaleDiscovery = resolve; });
|
||||
},
|
||||
async SetPreferredExternalOpener(id) { preferred = id; },
|
||||
async OpenWorkspaceInExternalOpenerForTab(tabId) {
|
||||
if (tabId === "older") await new Promise<void>((resolve) => { resolveSecondOlderLaunch = resolve; });
|
||||
},
|
||||
};
|
||||
const staleDiscovery = createHarness(staleDiscoveryBridge);
|
||||
await staleDiscovery.renderTab("older");
|
||||
await staleDiscovery.choose("Xcode");
|
||||
await staleDiscovery.renderTab("visible");
|
||||
await act(async () => { resolveSecondOlderLaunch?.(); await flush(); });
|
||||
await act(async () => {
|
||||
resolveStaleDiscovery?.({ openers, preferred: "finder", workspaceOpenable: true });
|
||||
await flush();
|
||||
});
|
||||
ok(staleDiscovery.primaryLabel()?.includes("Xcode") === true, "a discovery started before the write cannot restore the stale preference");
|
||||
await staleDiscovery.unmount();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,10 +1,11 @@
|
||||
// Run: tsx src/__tests__/external-opener.test.tsx
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { ExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener";
|
||||
import { ExternalOpener, shouldMountExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener";
|
||||
import { LocaleProvider, t } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { ExternalOpenersView } from "../lib/types";
|
||||
@@ -45,7 +46,7 @@ const opened: Array<[string, string]> = [];
|
||||
const nativeIcon = "data:image/png;base64,iVBORw0KGgo=";
|
||||
let discoveryCalls = 0;
|
||||
const bridge: ExternalOpenerBridge = {
|
||||
async ExternalOpeners() {
|
||||
async ExternalOpenersForTab() {
|
||||
discoveryCalls += 1;
|
||||
return {
|
||||
openers: [
|
||||
@@ -54,6 +55,7 @@ const bridge: ExternalOpenerBridge = {
|
||||
...(discoveryCalls > 1 ? [{ id: "xcode", name: "Xcode", kind: "editor" as const, iconDataUrl: nativeIcon }] : []),
|
||||
],
|
||||
preferred: "finder",
|
||||
workspaceOpenable: true,
|
||||
};
|
||||
},
|
||||
async SetPreferredExternalOpener(id) {
|
||||
@@ -66,13 +68,49 @@ const bridge: ExternalOpenerBridge = {
|
||||
|
||||
console.log("\nexternal opener");
|
||||
|
||||
const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||
const sharedControlRule = stylesSource.match(/(?:^|\n)\.external-opener\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
const sharedSegmentRule = stylesSource.match(
|
||||
/\.external-opener__primary,\s*\.external-opener__menu-trigger\s*\{([^}]*)\}/,
|
||||
)?.[1] ?? "";
|
||||
const creationControlRule = stylesSource.match(
|
||||
/\.app--creation \.topicbar__actions \.external-opener,\s*:root\[data-theme-style\] \.app--creation \.topicbar__actions \.external-opener\s*\{([^}]*)\}/,
|
||||
)?.[1] ?? "";
|
||||
const sharedIconRule = stylesSource.match(/(?:^|\n)\.external-opener__app-icon\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
const creationIconRule = stylesSource.match(
|
||||
/\.app--creation \.topicbar__actions \.external-opener__app-icon,\s*:root\[data-theme-style\] \.app--creation \.topicbar__actions \.external-opener__app-icon\s*\{([^}]*)\}/,
|
||||
)?.[1] ?? "";
|
||||
ok(
|
||||
/height:\s*28px;/.test(sharedControlRule)
|
||||
&& /border-radius:\s*7px;/.test(sharedControlRule)
|
||||
&& /box-shadow:\s*none;/.test(sharedControlRule),
|
||||
"uses the shared 28px / 7px topic-bar control geometry without an extra shadow",
|
||||
);
|
||||
ok(/height:\s*100%;/.test(sharedSegmentRule), "keeps both split-button segments within the shared control height");
|
||||
ok(
|
||||
/height:\s*28px;/.test(creationControlRule) && /border-radius:\s*7px;/.test(creationControlRule),
|
||||
"keeps Creation topic bars on the same 28px / 7px geometry",
|
||||
);
|
||||
ok(
|
||||
/width:\s*18px;/.test(sharedIconRule) && /height:\s*18px;/.test(sharedIconRule),
|
||||
"fits Workbench application artwork to the compact topic-bar control",
|
||||
);
|
||||
ok(
|
||||
/width:\s*15px;/.test(creationIconRule) && /height:\s*15px;/.test(creationIconRule),
|
||||
"preserves the slimmer Creation application artwork size",
|
||||
);
|
||||
|
||||
ok(shouldMountExternalOpener({ id: "tab-project", scope: "project" }, false), "mounts for a Project tab");
|
||||
ok(shouldMountExternalOpener({ id: "tab-global", scope: "global" }, false), "mounts for a Global tab without guessing from scope");
|
||||
ok(!shouldMountExternalOpener({ id: "tab-global", scope: "global" }, true), "stays hidden while an IM detail surface owns the header");
|
||||
|
||||
const container = document.getElementById("root")!;
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<ExternalOpener tabId="tab-project" dismissSignal={0} bridge={bridge} />
|
||||
<ExternalOpener tabId="tab-global" dismissSignal={0} bridge={bridge} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
@@ -99,7 +137,7 @@ await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
ok(selected.join(",") === "ghostty", "persists the selected application id");
|
||||
ok(JSON.stringify(opened) === JSON.stringify([["tab-project", "ghostty"]]), "opens the exact tab workspace with the selection");
|
||||
ok(JSON.stringify(opened) === JSON.stringify([["tab-global", "ghostty"]]), "opens the exact Global workspace with the selection");
|
||||
|
||||
const primary = container.querySelector<HTMLButtonElement>('button.external-opener__primary');
|
||||
await act(async () => {
|
||||
@@ -122,17 +160,17 @@ await act(async () => root.unmount());
|
||||
let staleResolve: ((value: ExternalOpenersView) => void) | undefined;
|
||||
let raceCalls = 0;
|
||||
const raceBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpeners() {
|
||||
async ExternalOpenersForTab() {
|
||||
raceCalls += 1;
|
||||
if (raceCalls === 1) {
|
||||
return { openers: [{ id: "finder", name: "Finder", kind: "file-manager" }], preferred: "finder" };
|
||||
return { openers: [{ id: "finder", name: "Finder", kind: "file-manager" }], preferred: "finder", workspaceOpenable: true };
|
||||
}
|
||||
if (raceCalls === 2) {
|
||||
return new Promise<ExternalOpenersView>((resolve) => {
|
||||
staleResolve = resolve;
|
||||
});
|
||||
}
|
||||
return { openers: [{ id: "xcode", name: "Xcode", kind: "editor" }], preferred: "xcode" };
|
||||
return { openers: [{ id: "xcode", name: "Xcode", kind: "editor" }], preferred: "xcode", workspaceOpenable: true };
|
||||
},
|
||||
async SetPreferredExternalOpener() {},
|
||||
async OpenWorkspaceInExternalOpenerForTab() {},
|
||||
@@ -169,24 +207,190 @@ await act(async () => {
|
||||
});
|
||||
ok(raceContainer.textContent?.includes("Xcode") === true, "the latest overlapping discovery result wins");
|
||||
await act(async () => {
|
||||
staleResolve?.({ openers: [{ id: "stale", name: "Stale Editor", kind: "editor" }], preferred: "stale" });
|
||||
staleResolve?.({ openers: [{ id: "stale", name: "Stale Editor", kind: "editor" }], preferred: "stale", workspaceOpenable: true });
|
||||
await flush();
|
||||
});
|
||||
ok(raceContainer.textContent?.includes("Xcode") === true && !raceContainer.textContent?.includes("Stale Editor"), "a stale discovery cannot replace the current menu");
|
||||
await act(async () => raceRoot.unmount());
|
||||
raceContainer.remove();
|
||||
|
||||
const failureLog: string[] = [];
|
||||
let failOpen = true;
|
||||
let failPersist = false;
|
||||
const failureBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpeners() {
|
||||
const preferenceWrites: string[] = [];
|
||||
const preferenceLaunches: string[] = [];
|
||||
let resolveOlderLaunch: (() => void) | undefined;
|
||||
const preferenceRaceBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpenersForTab() {
|
||||
return {
|
||||
openers: [
|
||||
{ id: "finder", name: "Finder", kind: "file-manager" },
|
||||
{ id: "xcode", name: "Xcode", kind: "editor" },
|
||||
],
|
||||
preferred: "finder",
|
||||
workspaceOpenable: true,
|
||||
};
|
||||
},
|
||||
async SetPreferredExternalOpener(id) {
|
||||
preferenceWrites.push(id);
|
||||
},
|
||||
async OpenWorkspaceInExternalOpenerForTab(tabId, id) {
|
||||
preferenceLaunches.push(`${tabId}:${id}`);
|
||||
if (tabId === "older-tab") {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveOlderLaunch = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
const preferenceContainer = document.createElement("div");
|
||||
document.body.append(preferenceContainer);
|
||||
const preferenceRoot = createRoot(preferenceContainer);
|
||||
const renderPreferenceTab = async (
|
||||
root: ReturnType<typeof createRoot>,
|
||||
targetBridge: ExternalOpenerBridge,
|
||||
tabId: string,
|
||||
) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<ExternalOpener key={tabId} tabId={tabId} dismissSignal={0} bridge={targetBridge} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
};
|
||||
const choosePreference = async (targetContainer: HTMLElement, name: string) => {
|
||||
await act(async () => {
|
||||
targetContainer.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
Array.from(targetContainer.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]'))
|
||||
.find((button) => button.textContent?.includes(name))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
};
|
||||
await renderPreferenceTab(preferenceRoot, preferenceRaceBridge, "older-tab");
|
||||
await choosePreference(preferenceContainer, "Xcode");
|
||||
await renderPreferenceTab(preferenceRoot, preferenceRaceBridge, "newer-tab");
|
||||
await choosePreference(preferenceContainer, "Finder");
|
||||
ok(
|
||||
preferenceLaunches.join(",") === "older-tab:xcode,newer-tab:finder" && preferenceWrites.join(",") === "finder",
|
||||
"a newer tab selection persists even when it matches the discovered preference",
|
||||
);
|
||||
await act(async () => {
|
||||
resolveOlderLaunch?.();
|
||||
await flush();
|
||||
});
|
||||
ok(preferenceWrites.join(",") === "finder", "an older tab completion cannot overwrite the latest opener preference");
|
||||
await act(async () => preferenceRoot.unmount());
|
||||
preferenceContainer.remove();
|
||||
|
||||
const failedNewerWrites: string[] = [];
|
||||
let resolveOlderSuccessfulLaunch: (() => void) | undefined;
|
||||
const failedNewerBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpenersForTab() {
|
||||
return {
|
||||
openers: [
|
||||
{ id: "finder", name: "Finder", kind: "file-manager" },
|
||||
{ id: "xcode", name: "Xcode", kind: "editor" },
|
||||
],
|
||||
preferred: "finder",
|
||||
workspaceOpenable: true,
|
||||
};
|
||||
},
|
||||
async SetPreferredExternalOpener(id) {
|
||||
failedNewerWrites.push(id);
|
||||
},
|
||||
async OpenWorkspaceInExternalOpenerForTab(tabId) {
|
||||
if (tabId === "older-success") {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveOlderSuccessfulLaunch = resolve;
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error("newer launch failed");
|
||||
},
|
||||
};
|
||||
const failedNewerContainer = document.createElement("div");
|
||||
document.body.append(failedNewerContainer);
|
||||
const failedNewerRoot = createRoot(failedNewerContainer);
|
||||
await renderPreferenceTab(failedNewerRoot, failedNewerBridge, "older-success");
|
||||
await choosePreference(failedNewerContainer, "Xcode");
|
||||
await renderPreferenceTab(failedNewerRoot, failedNewerBridge, "newer-failure");
|
||||
await choosePreference(failedNewerContainer, "Finder");
|
||||
await act(async () => {
|
||||
resolveOlderSuccessfulLaunch?.();
|
||||
await flush();
|
||||
});
|
||||
ok(
|
||||
failedNewerWrites.join(",") === "xcode",
|
||||
"a newer failed launch does not cancel an older successful selection",
|
||||
);
|
||||
await act(async () => failedNewerRoot.unmount());
|
||||
failedNewerContainer.remove();
|
||||
|
||||
const inFlightWrites: string[] = [];
|
||||
let markOlderWriteStarted: (() => void) | undefined;
|
||||
const olderWriteStarted = new Promise<void>((resolve) => {
|
||||
markOlderWriteStarted = resolve;
|
||||
});
|
||||
let resolveOlderWrite: (() => void) | undefined;
|
||||
const inFlightWriteBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpenersForTab() {
|
||||
return {
|
||||
openers: [
|
||||
{ id: "finder", name: "Finder", kind: "file-manager" },
|
||||
{ id: "xcode", name: "Xcode", kind: "editor" },
|
||||
],
|
||||
preferred: "finder",
|
||||
workspaceOpenable: true,
|
||||
};
|
||||
},
|
||||
async SetPreferredExternalOpener(id) {
|
||||
inFlightWrites.push(id);
|
||||
markOlderWriteStarted?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveOlderWrite = resolve;
|
||||
});
|
||||
},
|
||||
async OpenWorkspaceInExternalOpenerForTab(tabId) {
|
||||
if (tabId === "newer-failure") throw new Error("newer launch failed");
|
||||
},
|
||||
};
|
||||
const inFlightContainer = document.createElement("div");
|
||||
document.body.append(inFlightContainer);
|
||||
const inFlightRoot = createRoot(inFlightContainer);
|
||||
await renderPreferenceTab(inFlightRoot, inFlightWriteBridge, "older-write");
|
||||
await choosePreference(inFlightContainer, "Xcode");
|
||||
await olderWriteStarted;
|
||||
await renderPreferenceTab(inFlightRoot, inFlightWriteBridge, "newer-failure");
|
||||
await choosePreference(inFlightContainer, "Finder");
|
||||
await act(async () => {
|
||||
resolveOlderWrite?.();
|
||||
await flush();
|
||||
});
|
||||
ok(
|
||||
inFlightWrites.join(",") === "xcode",
|
||||
"an in-flight older preference write has the same result when a newer launch fails",
|
||||
);
|
||||
await act(async () => inFlightRoot.unmount());
|
||||
inFlightContainer.remove();
|
||||
|
||||
const failureLog: string[] = [];
|
||||
let failOpen = true;
|
||||
let failPersist = false;
|
||||
const failureBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpenersForTab() {
|
||||
return {
|
||||
openers: [
|
||||
{ id: "finder", name: "Finder", kind: "file-manager" },
|
||||
{ id: "xcode", name: "Xcode", kind: "editor" },
|
||||
],
|
||||
preferred: "finder",
|
||||
workspaceOpenable: true,
|
||||
};
|
||||
},
|
||||
async SetPreferredExternalOpener(id) {
|
||||
@@ -247,5 +451,33 @@ ok(
|
||||
await act(async () => failureRoot.unmount());
|
||||
failureContainer.remove();
|
||||
|
||||
const unavailableContainer = document.createElement("div");
|
||||
document.body.append(unavailableContainer);
|
||||
const unavailableRoot = createRoot(unavailableContainer);
|
||||
const unavailableBridge: ExternalOpenerBridge = {
|
||||
async ExternalOpenersForTab() {
|
||||
return {
|
||||
openers: [{ id: "finder", name: "Finder", kind: "file-manager" }],
|
||||
preferred: "finder",
|
||||
workspaceOpenable: false,
|
||||
};
|
||||
},
|
||||
async SetPreferredExternalOpener() {},
|
||||
async OpenWorkspaceInExternalOpenerForTab() {},
|
||||
};
|
||||
await act(async () => {
|
||||
unavailableRoot.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<ExternalOpener tabId="tab-missing-workspace" dismissSignal={0} bridge={unavailableBridge} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
ok(unavailableContainer.querySelector(".external-opener") == null, "hides when the backend reports no local workspace capability");
|
||||
await act(async () => unavailableRoot.unmount());
|
||||
unavailableContainer.remove();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,304 @@
|
||||
// Run: tsx src/__tests__/native-motion.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { useRef } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { CSS_EASE_OUT } from "../lib/motion";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useMountTransition } from "../lib/useMountTransition";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type ControllableAnimation = {
|
||||
onfinish: (() => void) | null;
|
||||
oncancel: (() => void) | null;
|
||||
cancelCalls: number;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function controllableAnimation(): ControllableAnimation {
|
||||
const animation: ControllableAnimation = {
|
||||
onfinish: null,
|
||||
oncancel: null,
|
||||
cancelCalls: 0,
|
||||
cancel() {
|
||||
animation.cancelCalls += 1;
|
||||
animation.oncancel?.();
|
||||
},
|
||||
};
|
||||
return animation;
|
||||
}
|
||||
|
||||
function installDom(reducedMotion = false) {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: reducedMotion,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
function mockNativeAnimate(
|
||||
dom: JSDOM,
|
||||
implementation: (
|
||||
this: Element,
|
||||
frames: Keyframe[] | PropertyIndexedKeyframes | null,
|
||||
options: KeyframeAnimationOptions,
|
||||
) => ControllableAnimation,
|
||||
) {
|
||||
Object.defineProperty(dom.window.Element.prototype, "animate", {
|
||||
configurable: true,
|
||||
value: function (frames: Keyframe[] | PropertyIndexedKeyframes | null, options: number | KeyframeAnimationOptions) {
|
||||
if (typeof options === "number") throw new TypeError("expected keyframe animation options");
|
||||
return implementation.call(this, frames, options) as unknown as Animation;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(root: Root, dom: JSDOM) {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
function CollapseHarness({
|
||||
open,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useCollapseAnimation(ref, open, { onOpenComplete: onOpen, onCloseComplete: onClose });
|
||||
return <div id="collapse" ref={ref}>content</div>;
|
||||
}
|
||||
|
||||
async function renderCollapse(root: Root, open: boolean, onOpen?: () => void, onClose?: () => void) {
|
||||
await act(async () => {
|
||||
root.render(<CollapseHarness open={open} onOpen={onOpen} onClose={onClose} />);
|
||||
});
|
||||
}
|
||||
|
||||
function EntranceHarness({ resetKey, ids }: { resetKey: string; ids: string[] }) {
|
||||
const ref = useEntranceAnimation<HTMLDivElement>(resetKey, ids.length, "[data-entrance]", ids);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{ids.map((id) => <div key={id} id={`entry-${id}`} data-entrance={id} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeferredEntranceHarness({
|
||||
resetKey,
|
||||
seedIds,
|
||||
visibleIds,
|
||||
}: {
|
||||
resetKey: string;
|
||||
seedIds: string[];
|
||||
visibleIds: string[];
|
||||
}) {
|
||||
const ref = useEntranceAnimation<HTMLDivElement>(resetKey, visibleIds.length, "[data-entrance]", seedIds);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{visibleIds.map((id) => <div key={id} id={`entry-${id}`} data-entrance={id} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MountHarness({ open, duration }: { open: boolean; duration: number }) {
|
||||
const { mounted } = useMountTransition(open, duration);
|
||||
return mounted ? <div id="mounted" /> : null;
|
||||
}
|
||||
|
||||
console.log("\nnative motion fallbacks");
|
||||
|
||||
// Valid native motion waits for completion and settles exactly once.
|
||||
{
|
||||
const dom = installDom();
|
||||
const animations: ControllableAnimation[] = [];
|
||||
let easing: string | undefined;
|
||||
mockNativeAnimate(dom, (_frames, options) => {
|
||||
easing = options.easing;
|
||||
const animation = controllableAnimation();
|
||||
animations.push(animation);
|
||||
return animation;
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
let opened = 0;
|
||||
await renderCollapse(root, false, () => { opened += 1; });
|
||||
await renderCollapse(root, true, () => { opened += 1; });
|
||||
|
||||
eq(easing, CSS_EASE_OUT, "collapse passes a CSS easing to Element.animate");
|
||||
eq(opened, 0, "collapse waits for native completion");
|
||||
await act(async () => {
|
||||
animations[0].onfinish?.();
|
||||
animations[0].oncancel?.();
|
||||
});
|
||||
eq(opened, 1, "finish and late cancel settle collapse only once");
|
||||
eq((document.getElementById("collapse") as HTMLElement).style.height, "auto", "finished open state uses auto height");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// A synchronously rejecting WebView must expose the requested final state.
|
||||
{
|
||||
const dom = installDom();
|
||||
mockNativeAnimate(dom, () => {
|
||||
throw new TypeError("WebView rejected height animation");
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
let opened = 0;
|
||||
await renderCollapse(root, false, () => { opened += 1; });
|
||||
await renderCollapse(root, true, () => { opened += 1; });
|
||||
eq(opened, 1, "rejected collapse animation runs its completion fallback");
|
||||
eq((document.getElementById("collapse") as HTMLElement).style.height, "auto", "rejected collapse animation exposes content");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// Direction reversal cancels the old animation without firing its stale callback.
|
||||
{
|
||||
const dom = installDom();
|
||||
const animations: ControllableAnimation[] = [];
|
||||
mockNativeAnimate(dom, () => {
|
||||
const animation = controllableAnimation();
|
||||
animations.push(animation);
|
||||
return animation;
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
let opened = 0;
|
||||
let closed = 0;
|
||||
await renderCollapse(root, false, () => { opened += 1; }, () => { closed += 1; });
|
||||
await renderCollapse(root, true, () => { opened += 1; }, () => { closed += 1; });
|
||||
await renderCollapse(root, false, () => { opened += 1; }, () => { closed += 1; });
|
||||
eq(animations[0].cancelCalls, 1, "direction reversal cancels the previous animation");
|
||||
eq(opened, 0, "superseded open callback is suppressed");
|
||||
await act(async () => animations[1].oncancel?.());
|
||||
eq(closed, 1, "unexpected cancellation settles the active direction");
|
||||
eq((document.getElementById("collapse") as HTMLElement).style.height, "0px", "cancelled close reaches zero height");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// A stable reset key animates appends; a real reset pre-seeds restored rows.
|
||||
{
|
||||
const dom = installDom();
|
||||
const calls: { options: KeyframeAnimationOptions; animation: ControllableAnimation }[] = [];
|
||||
mockNativeAnimate(dom, (_frames, options) => {
|
||||
const animation = controllableAnimation();
|
||||
calls.push({ options, animation });
|
||||
return animation;
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a"]} />));
|
||||
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a", "b"]} />));
|
||||
await act(async () => { await flushTimers(25); });
|
||||
eq(calls.length, 1, "stable entrance reset key animates an appended row");
|
||||
eq(calls[0].options.easing, CSS_EASE_OUT, "entrance animation uses the shared CSS easing");
|
||||
await act(async () => calls[0].animation.onfinish?.());
|
||||
eq((document.getElementById("entry-b") as HTMLElement).style.opacity, "1", "finished entrance exposes the row");
|
||||
|
||||
await act(async () => root.render(<EntranceHarness resetKey="tab-b" ids={["a", "b", "c"]} />));
|
||||
await act(async () => { await flushTimers(25); });
|
||||
eq(calls.length, 1, "changed reset key pre-seeds restored rows without animation");
|
||||
await act(async () => root.render(<EntranceHarness resetKey="tab-b" ids={["a", "b", "c", "d"]} />));
|
||||
await act(async () => { await flushTimers(25); });
|
||||
eq(calls.length, 2, "append after a reset animates normally");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// Virtual rows can mount after the initial scan. Model-backed seed IDs keep
|
||||
// restored rows inert while still allowing a genuinely appended row to enter.
|
||||
{
|
||||
const dom = installDom();
|
||||
const calls: string[] = [];
|
||||
mockNativeAnimate(dom, function () {
|
||||
calls.push((this as unknown as Element).getAttribute?.("data-entrance") ?? "");
|
||||
return controllableAnimation();
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(
|
||||
<DeferredEntranceHarness resetKey="tab-a" seedIds={["history"]} visibleIds={[]} />,
|
||||
));
|
||||
await act(async () => root.render(
|
||||
<DeferredEntranceHarness resetKey="tab-a" seedIds={["history", "new"]} visibleIds={["history", "new"]} />,
|
||||
));
|
||||
await act(async () => { await flushTimers(25); });
|
||||
eq(calls.length, 1, "deferred virtual history does not animate with a new append");
|
||||
eq(calls[0], "new", "only the genuinely appended virtual row animates");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// One rejected entrance must fail open without aborting the timer callback.
|
||||
{
|
||||
const dom = installDom();
|
||||
let attempts = 0;
|
||||
mockNativeAnimate(dom, () => {
|
||||
attempts += 1;
|
||||
throw new TypeError("WebView rejected entrance animation");
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a"]} />));
|
||||
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a", "b", "c"]} />));
|
||||
await act(async () => { await flushTimers(25); });
|
||||
eq(attempts, 2, "a rejected entrance does not prevent later entries from attempting motion");
|
||||
eq((document.getElementById("entry-b") as HTMLElement).style.opacity, "1", "rejected entrance exposes the first row");
|
||||
eq((document.getElementById("entry-c") as HTMLElement).style.opacity, "1", "rejected entrance exposes later rows");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
// Terminal-style conditional content unmounts on a bounded timer even if no
|
||||
// transitionend event is delivered.
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<MountHarness open={true} duration={20} />));
|
||||
ok(Boolean(document.getElementById("mounted")), "transition content mounts while open");
|
||||
await act(async () => root.render(<MountHarness open={false} duration={20} />));
|
||||
ok(Boolean(document.getElementById("mounted")), "transition content remains mounted during close delay");
|
||||
await act(async () => { await flushTimers(30); });
|
||||
ok(!document.getElementById("mounted"), "transition content unmounts without transitionend");
|
||||
await cleanup(root, dom);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
projectTreeTopicMenuOffersPin,
|
||||
projectTreeDedupedExactTime,
|
||||
} from "../components/ProjectTree";
|
||||
import { projectTreeTrashingTopics } from "../lib/projectTreeArchive";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
@@ -35,6 +36,14 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
|
||||
console.log("\nproject tree runtime sessions");
|
||||
|
||||
const noTrashingTopics = new Set<string>();
|
||||
const topicATrashing = projectTreeTrashingTopics(noTrashingTopics, "topic-a", true);
|
||||
const twoTopicsTrashing = projectTreeTrashingTopics(topicATrashing, "topic-b", true);
|
||||
eq([...topicATrashing], ["topic-a"], "archive pending state is keyed by topic");
|
||||
eq([...twoTopicsTrashing], ["topic-a", "topic-b"], "another topic remains independently actionable");
|
||||
eq([...projectTreeTrashingTopics(twoTopicsTrashing, "topic-a", false)], ["topic-b"], "settling one archive leaves the other pending");
|
||||
eq(projectTreeTrashingTopics(topicATrashing, "topic-a", true) === topicATrashing, true, "same pending state preserves Set identity");
|
||||
|
||||
const testT = (key: string, vars?: Record<string, string | number>) => {
|
||||
if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
|
||||
if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { coalescesQuery, invalidateSharedQuery, resetQueryCoalescing, shareQuery } from "../lib/queryCoalesce";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function identicalCallsInFlightShareOneAnswer() {
|
||||
resetQueryCoalescing();
|
||||
let runs = 0;
|
||||
const d = deferred<string>();
|
||||
const run = () => { runs++; return d.promise; };
|
||||
const a = shareQuery("ContextPanel", ["tab-1"], run);
|
||||
const b = shareQuery("ContextPanel", ["tab-1"], run);
|
||||
d.resolve("panel");
|
||||
assert.equal(await a, "panel");
|
||||
assert.equal(await b, "panel");
|
||||
assert.equal(runs, 1, "a duplicate inside the window must not reach the backend");
|
||||
}
|
||||
|
||||
async function differentArgumentsStayIndependent() {
|
||||
resetQueryCoalescing();
|
||||
let runs = 0;
|
||||
const run = async () => { runs++; return runs; };
|
||||
await shareQuery("MetaForTab", ["tab-1"], run);
|
||||
await shareQuery("MetaForTab", ["tab-2"], run);
|
||||
assert.equal(runs, 2, "another tab is another question");
|
||||
}
|
||||
|
||||
// A stale answer is worse than a slow one: a failure must not be inherited.
|
||||
async function rejectionIsNotSharedOnward() {
|
||||
resetQueryCoalescing();
|
||||
let runs = 0;
|
||||
const first = shareQuery("ListTabs", [], async () => { runs++; throw new Error("boom"); });
|
||||
await assert.rejects(first);
|
||||
const second = await shareQuery("ListTabs", [], async () => { runs++; return "ok"; });
|
||||
assert.equal(second, "ok");
|
||||
assert.equal(runs, 2, "the next caller retries instead of inheriting the failure");
|
||||
}
|
||||
|
||||
async function invalidationStartsAFreshRead() {
|
||||
resetQueryCoalescing();
|
||||
let runs = 0;
|
||||
const run = async () => ++runs;
|
||||
assert.equal(await shareQuery("MetaForTab", ["tab-1"], run), 1);
|
||||
invalidateSharedQuery("MetaForTab", ["tab-1"]);
|
||||
assert.equal(await shareQuery("MetaForTab", ["tab-1"], run), 2);
|
||||
}
|
||||
|
||||
async function settledTabListsAreNotShared() {
|
||||
resetQueryCoalescing();
|
||||
let runs = 0;
|
||||
const run = async () => ++runs;
|
||||
assert.equal(await shareQuery("ListTabs", [], run), 1);
|
||||
assert.equal(await shareQuery("ListTabs", [], run), 2, "tab mutations must be visible after the in-flight burst");
|
||||
}
|
||||
|
||||
// Anything that mutates or starts work must never be collapsed.
|
||||
function onlyReadOnlyQueriesAreCoalesced() {
|
||||
for (const readOnly of ["ListProjectTree", "ContextPanel", "MetaForTab", "BalanceForTab"]) {
|
||||
assert.equal(coalescesQuery(readOnly), true, `${readOnly} should coalesce`);
|
||||
}
|
||||
for (const mutating of ["Send", "SetActiveTab", "OpenGlobalTab", "Cancel", "ApproveTool"]) {
|
||||
assert.equal(coalescesQuery(mutating), false, `${mutating} must never be coalesced`);
|
||||
}
|
||||
}
|
||||
|
||||
const tests: Array<[string, () => unknown]> = [
|
||||
["identical in-flight calls share one answer", identicalCallsInFlightShareOneAnswer],
|
||||
["different arguments stay independent", differentArgumentsStayIndependent],
|
||||
["a rejection is not shared onward", rejectionIsNotSharedOnward],
|
||||
["external state boundaries invalidate settled answers", invalidationStartsAFreshRead],
|
||||
["settled tab lists do not cross mutations", settledTabListsAreNotShared],
|
||||
["only read-only queries are coalesced", onlyReadOnlyQueriesAreCoalesced],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`ok ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(`FAIL ${name}\n ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -52,7 +52,9 @@ console.log("\nstatus bar workspace");
|
||||
{ id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", useSSHConfig: false },
|
||||
];
|
||||
const stopped = renderStatusBar({ workspacePath: "/workspace/repo", workspaceName: "repo", remoteHosts });
|
||||
ok(stopped.includes("SSH · Disconnected"), "configured SSH entry remains visible while disconnected");
|
||||
ok(stopped.includes("SSH · Disconnected"), "disconnected SSH entry keeps its full accessible status");
|
||||
ok(stopped.includes('statusbar__remote--idle'), "disconnected SSH entry uses the compact idle treatment");
|
||||
ok(stopped.includes('<span class="statusbar__remote-label">SSH</span>'), "disconnected SSH entry renders only the compact SSH label");
|
||||
ok(stopped.indexOf("SSH · Disconnected") < stopped.indexOf("workspace/repo"), "window-level SSH entry leads the status bar");
|
||||
|
||||
const connected = renderStatusBar({
|
||||
@@ -62,6 +64,8 @@ console.log("\nstatus bar workspace");
|
||||
remoteStatuses: { demo: { hostId: "demo", state: "connected" } },
|
||||
});
|
||||
ok(connected.includes("demo · Connected"), "SSH entry includes host and connected state text");
|
||||
ok(connected.includes('statusbar__remote-state-dot'), "connected SSH entry renders a state dot");
|
||||
ok(connected.includes('<span class="statusbar__remote-label">demo</span>'), "connected SSH entry renders the host without redundant state text");
|
||||
|
||||
const failed = renderStatusBar({
|
||||
workspacePath: "/workspace/repo",
|
||||
@@ -69,6 +73,7 @@ console.log("\nstatus bar workspace");
|
||||
remoteStatuses: { demo: { hostId: "demo", state: "stopped", error: "handshake failed" } },
|
||||
});
|
||||
ok(failed.includes("demo · Connection failed"), "SSH entry keeps a recoverable failure summary visible");
|
||||
ok(failed.includes('<span class="statusbar__remote-label">demo · Connection failed</span>'), "failed SSH entry keeps the failure visible in the status bar");
|
||||
ok(!failed.includes("handshake failed"), "status entry keeps raw connection diagnostics out of primary chrome");
|
||||
|
||||
const degraded = renderStatusBar({
|
||||
@@ -204,6 +209,24 @@ console.log("\nstatus bar workspace");
|
||||
lastTurnOutputEstimated: true,
|
||||
});
|
||||
ok(estimated.includes("≈20 t/s"), "fallback TPS is visibly marked as estimated");
|
||||
|
||||
const perRequest = renderStatusBar({
|
||||
items: ["turn_tps"],
|
||||
lastRequestTps: 35,
|
||||
lastTurnOutputTokens: 100,
|
||||
lastTurnModelMs: 5_000,
|
||||
});
|
||||
ok(perRequest.includes("35 t/s"), "per-request TPS wins over the completed turn value");
|
||||
|
||||
const slowRequest = renderStatusBar({
|
||||
items: ["turn_tps"], lastRequestTps: 1 / 3, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
|
||||
});
|
||||
ok(slowRequest.includes("<1 t/s") && !slowRequest.includes("20 t/s"), "sub-one request TPS replaces the stale turn fallback");
|
||||
|
||||
const unavailable = renderStatusBar({
|
||||
items: ["turn_tps"], lastRequestTps: null, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
|
||||
});
|
||||
ok(unavailable.includes('stat__value--empty">-</b>') && !unavailable.includes("20 t/s"), "unmeasured latest requests clear the stale turn fallback");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ToolCard } from "../components/ToolCard";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { setReasoningSummaryEnabled } from "../lib/reasoningSummaryPreference";
|
||||
@@ -26,28 +25,6 @@ registerHooks({
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
// jsdom has no layout engine: stub the GSAP tween surface the collapse hook
|
||||
// touches so layout effects complete synchronously. Under tsx the imported
|
||||
// binding is a CJS interop object, so the stubs must go onto that object
|
||||
// itself (the hook imports the same binding).
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = gsap as unknown as {
|
||||
to: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
fromTo: (target: unknown, from: unknown, vars: GsapToOptions) => unknown;
|
||||
set: (target: unknown, vars: unknown) => unknown;
|
||||
killTweensOf: (target: unknown) => void;
|
||||
};
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
gsapForTests.fromTo = (_target: unknown, _from: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
gsapForTests.set = () => ({});
|
||||
gsapForTests.killTweensOf = () => {};
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ToolCard } from "../components/ToolCard";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { historyMessagesToItems, initialState, reducer, type Item } from "../lib/useController";
|
||||
@@ -17,25 +16,6 @@ import type { HistoryMessage, WireShellExecution } from "../lib/types";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
// jsdom has no layout engine: stub GSAP the same way as subagent-progress-card.
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = gsap as unknown as {
|
||||
to: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
fromTo: (target: unknown, from: unknown, vars: GsapToOptions) => unknown;
|
||||
set: (target: unknown, vars: unknown) => unknown;
|
||||
killTweensOf: (target: unknown) => void;
|
||||
};
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
gsapForTests.fromTo = (_target: unknown, _from: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
gsapForTests.set = () => ({});
|
||||
gsapForTests.killTweensOf = () => {};
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
Object.defineProperty(dom.window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true, // prefers-reduced-motion: keep GSAP tweens out of the assertions
|
||||
matches: true, // prefers-reduced-motion: keep visual transitions out of the assertions
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
@@ -137,27 +137,10 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
}
|
||||
};
|
||||
|
||||
// GSAP's CSS plugin cannot run against jsdom; the assertions are about
|
||||
// state-driven DOM, so the animation hooks are stubbed out.
|
||||
const server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
plugins: [
|
||||
{
|
||||
name: "stub-animation-hooks",
|
||||
enforce: "pre",
|
||||
load(id) {
|
||||
if (id.endsWith("/src/lib/useGSAPCollapse.ts")) {
|
||||
return "export function useGSAPCollapse() {}";
|
||||
}
|
||||
if (id.endsWith("/src/lib/useEntranceAnimation.ts")) {
|
||||
return "export function useEntranceAnimation() { return { current: null }; }";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { Transcript } = await server.ssrLoadModule("/src/components/Transcript.tsx");
|
||||
const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx");
|
||||
|
||||
@@ -362,5 +362,133 @@ function ev(s: typeof initialState, e: WireEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 7. lastRequestTps pairs the closed interval with the usage tokens ---
|
||||
{
|
||||
const originalNow = Date.now;
|
||||
let now = 50_000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
|
||||
now = 50_100;
|
||||
s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
|
||||
now = 51_100;
|
||||
// The message event closes the interval BEFORE the usage event arrives.
|
||||
s = ev(s, { kind: "message", text: "abcd" } as WireEvent);
|
||||
now = 51_200;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 130, completionTokens: 30, totalTokens: 160,
|
||||
contextPromptTokens: 100, contextCompletionTokens: 20,
|
||||
cacheHitTokens: 0, cacheMissTokens: 100, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, 20, "sampling recovery pairs the interval with latest-attempt tokens");
|
||||
|
||||
now = 52_000;
|
||||
s = ev(s, { kind: "text", text: "more" } as WireEvent);
|
||||
now = 52_050;
|
||||
s = ev(s, { kind: "message", text: "more" } as WireEvent);
|
||||
now = 52_100;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 5, completionTokens: 50, totalTokens: 55,
|
||||
cacheHitTokens: 0, cacheMissTokens: 5, source: "subagent",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, 20, "non-executor usage neither computes nor consumes the pending interval");
|
||||
now = 52_300;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 30, totalTokens: 40,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, null, "intervals under the 500ms gate clear stale request TPS");
|
||||
|
||||
now = 53_000;
|
||||
s = ev(s, { kind: "text", text: "second" } as WireEvent);
|
||||
now = 54_000;
|
||||
s = ev(s, { kind: "message", text: "second" } as WireEvent);
|
||||
now = 54_100;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 30, totalTokens: 40,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, 30, "a later executor usage refreshes the request TPS");
|
||||
|
||||
now = 55_000;
|
||||
s = ev(s, { kind: "text", text: "direct" } as WireEvent);
|
||||
now = 56_000;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 40, totalTokens: 50,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, 40, "usage measures an interval still open at arrival");
|
||||
|
||||
now = 57_000;
|
||||
s = ev(s, { kind: "turn_done" } as WireEvent);
|
||||
eq(s.lastRequestTps, 40, "request TPS persists across turn boundaries");
|
||||
|
||||
now = 58_000;
|
||||
s = ev(s, { kind: "turn_started" } as WireEvent);
|
||||
now = 58_100;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 8, totalTokens: 18,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, null, "usage without a provider interval clears stale request TPS");
|
||||
now = 58_200;
|
||||
s = ev(s, { kind: "tool_dispatch", tool: { id: "final-only", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
|
||||
eq(s.lastRequestTps, null, "a final-only tool dispatch after usage cannot resurrect stale TPS");
|
||||
|
||||
now = 59_000;
|
||||
s = ev(s, { kind: "text", text: "toolcall" } as WireEvent);
|
||||
now = 60_000;
|
||||
s = ev(s, { kind: "tool_dispatch", tool: { id: "t1", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
|
||||
now = 60_100;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 25, totalTokens: 35,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, 25, "tool_dispatch closes the interval the next executor usage pairs with");
|
||||
|
||||
now = 61_000;
|
||||
s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", readOnly: false, partial: true, argChars: 600 } } as WireEvent);
|
||||
now = 62_000;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 30, totalTokens: 40,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, 30, "usage closes the interval started by a partial tool dispatch");
|
||||
now = 62_100;
|
||||
s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
|
||||
eq(s.lastRequestTps, 30, "the later full tool dispatch preserves the measured request TPS");
|
||||
|
||||
now = 63_000;
|
||||
s = ev(s, { kind: "text", text: "closing" } as WireEvent);
|
||||
now = 64_000;
|
||||
s = ev(s, { kind: "message", text: "closing" } as WireEvent);
|
||||
now = 64_100;
|
||||
s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", readOnly: false, partial: true, argChars: 300 } } as WireEvent);
|
||||
now = 64_700;
|
||||
s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
|
||||
now = 64_800;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 30, totalTokens: 40,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
// The partial restart begins a new interval; the full dispatch closes it
|
||||
// and overwrites the message-stashed pending with its own (≥500ms) tail.
|
||||
eq(s.lastRequestTps, 50, "a full dispatch overwrites a message-stashed pending with its own tail close");
|
||||
|
||||
now = 65_000;
|
||||
s = ev(s, { kind: "text", text: "slow" } as WireEvent);
|
||||
now = 68_000;
|
||||
s = ev(s, { kind: "message", text: "slow" } as WireEvent);
|
||||
now = 68_100;
|
||||
s = ev(s, { kind: "usage", usage: {
|
||||
promptTokens: 10, completionTokens: 1, totalTokens: 11,
|
||||
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
|
||||
} } as WireEvent);
|
||||
eq(s.lastRequestTps, 1 / 3, "slow measurable requests retain their raw sub-one TPS");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,64 @@
|
||||
// Run: tsx src/__tests__/workspace-path-copy.test.tsx
|
||||
|
||||
import { workspacePathCopyMenuItems } from "../lib/workspacePathCopyMenuItems";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
|
||||
const clipboardWrites: string[] = [];
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText: async (value: string) => {
|
||||
clipboardWrites.push(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let closeCalls = 0;
|
||||
let resolveCalls = 0;
|
||||
let scopeCurrent = true;
|
||||
const items = workspacePathCopyMenuItems({
|
||||
path: "src/",
|
||||
resolveAbsolutePath: async () => {
|
||||
resolveCalls += 1;
|
||||
return "/repo/src";
|
||||
},
|
||||
isScopeCurrent: () => scopeCurrent,
|
||||
close: () => {
|
||||
closeCalls += 1;
|
||||
},
|
||||
relativeLabel: "Copy relative path",
|
||||
absoluteLabel: "Copy absolute path",
|
||||
});
|
||||
|
||||
console.log("\nworkspace path copy menu");
|
||||
ok(items.length === 2, "builds both path-copy actions");
|
||||
ok(items[0]?.label === "Copy relative path", "exposes the localized relative-path label");
|
||||
ok(items[1]?.label === "Copy absolute path", "exposes the localized absolute-path label");
|
||||
|
||||
items[0]?.onSelect();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
ok(clipboardWrites[0] === "src", "folder relative paths omit the tree-only trailing slash");
|
||||
ok(closeCalls === 1, "relative-path selection closes the menu");
|
||||
|
||||
items[1]?.onSelect();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
ok(resolveCalls === 1, "absolute-path selection uses backend resolution");
|
||||
ok(clipboardWrites[1] === "/repo/src", "copies the backend-resolved absolute path");
|
||||
|
||||
scopeCurrent = false;
|
||||
items[1]?.onSelect();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
ok(resolveCalls === 2 && clipboardWrites.length === 2, "drops stale absolute-path results after the workspace scope changes");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
PromptHeaderAction,
|
||||
PromptShelf,
|
||||
} from "./PromptShelf";
|
||||
import { DUR_FAST } from "../lib/gsapAnimations";
|
||||
import { animateElementExit, DUR_FAST } from "../lib/motion";
|
||||
import {
|
||||
FileReferenceMenu,
|
||||
insertTextAtSelection,
|
||||
@@ -18,27 +18,6 @@ import {
|
||||
useFileReferenceMenu,
|
||||
} from "./FileReferenceMenu";
|
||||
|
||||
function animateShelfExit(
|
||||
el: HTMLDivElement,
|
||||
options: { opacity: number; y: number; duration: number; ease: string; onComplete: () => void },
|
||||
) {
|
||||
if (typeof el.animate === "function") {
|
||||
const animation = el.animate(
|
||||
[
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
{ opacity: options.opacity, transform: `translateY(${options.y}px)` },
|
||||
],
|
||||
{
|
||||
duration: options.duration * 1000,
|
||||
easing: options.ease === "power2.out" ? "cubic-bezier(0.2, 0.72, 0.2, 1)" : options.ease,
|
||||
},
|
||||
);
|
||||
animation.onfinish = options.onComplete;
|
||||
return;
|
||||
}
|
||||
options.onComplete();
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape" || tool === "config_write";
|
||||
}
|
||||
@@ -322,9 +301,8 @@ export function ApprovalModal({
|
||||
const onRevisionActiveChangeRef = useRef(onRevisionActiveChange);
|
||||
const revisionActiveRef = useRef(false);
|
||||
onRevisionActiveChangeRef.current = onRevisionActiveChange;
|
||||
// When consecutive approvals arrive, animate the old card out before
|
||||
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
|
||||
// jarring pop when the API cycles through 4+ pending approvals.
|
||||
// When consecutive approvals arrive, animate the old card out before the
|
||||
// new one slides in so a queue of pending approvals does not visibly pop.
|
||||
const closingRef = useRef(false);
|
||||
const fileMenu = useFileReferenceMenu(revisionText, cwd, tabId, workspaceScopeKey);
|
||||
|
||||
@@ -334,11 +312,10 @@ export function ApprovalModal({
|
||||
setSubmitting(true);
|
||||
const el = shelfRef.current;
|
||||
if (el) {
|
||||
animateShelfExit(el, {
|
||||
animateElementExit(el, {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
duration: DUR_FAST,
|
||||
ease: "power2.in",
|
||||
onComplete: fn,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -4503,7 +4503,7 @@ export function Composer({
|
||||
id="composer-input"
|
||||
ref={taRef}
|
||||
className="composer__input"
|
||||
aria-label={t("composer.placeholder")}
|
||||
aria-label={t("composer.placeholder")} spellCheck={false} autoCorrect="off" autoCapitalize="off"
|
||||
value={text}
|
||||
onInputCapture={(e) => {
|
||||
pendingNativeInputTypeRef.current = (e.nativeEvent as InputEvent).inputType;
|
||||
|
||||
@@ -100,7 +100,7 @@ export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone
|
||||
return "warn";
|
||||
}
|
||||
|
||||
function formatSharePercent(value: number, total: number): string {
|
||||
export function formatSharePercent(value: number, total: number): string {
|
||||
if (total <= 0 || value <= 0) return "-";
|
||||
const pct = (value / total) * 100;
|
||||
if (pct > 0 && pct < 1) return "<1%";
|
||||
@@ -276,6 +276,16 @@ function sourceLabel(source: string, t: Translator): string {
|
||||
}
|
||||
}
|
||||
|
||||
function maintenanceActionLabel(action: string | undefined, t: Translator): string {
|
||||
switch (action) {
|
||||
case "snip": return t("context.maintenanceActionSnip");
|
||||
case "prune": return t("context.maintenanceActionPrune");
|
||||
case "summary": return t("summary.detail");
|
||||
case "native_tool_clear": return t("context.maintenanceActionNative");
|
||||
default: return t("common.none");
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCost(stats: UsageSourceStats): number {
|
||||
return stats.sessionCost && stats.sessionCost > 0 ? stats.sessionCost : stats.sessionCostUsd ?? 0;
|
||||
}
|
||||
@@ -451,6 +461,13 @@ export function ContextPanel({
|
||||
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
|
||||
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
|
||||
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
|
||||
const maintenance = context?.maintenance;
|
||||
const lastMaintenance = maintenance?.lastReceipt;
|
||||
const maintenanceStatus = maintenance?.blocked
|
||||
? t("projectTree.status.paused")
|
||||
: lastMaintenance?.status === "applied"
|
||||
? t("settings.typography.applied")
|
||||
: t("remote.server.state.ready");
|
||||
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
|
||||
const tokenTypeRows = [
|
||||
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
|
||||
@@ -549,6 +566,25 @@ export function ContextPanel({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{maintenance && (
|
||||
<section className="context-panel__section context-panel__session-section">
|
||||
<SectionHeading title={t("context.maintenanceTitle")} meta={maintenanceStatus} />
|
||||
<div className="context-panel__session-metrics">
|
||||
<div className="context-panel__summary-rows">
|
||||
<MiniStat label={t("context.maintenanceProjected")} value={formatOptionalTokens(maintenance.projectedTokens)} tone="accent" />
|
||||
<MiniStat label={t("history.title")} value={formatOptionalTokens(maintenance.canonicalTokens)} />
|
||||
<MiniStat label={t("summary.detail")} value={formatOptionalTokens(maintenance.summaryTokens)} />
|
||||
<MiniStat label={t("context.maintenanceLastSaved")} value={formatOptionalTokens(maintenance.lastSavedTokens)} tone={maintenance.lastSavedTokens ? "good" : undefined} />
|
||||
<MiniStat label={t("context.maintenanceHeadroom")} value={formatOptionalTokens(maintenance.headroom)} wide />
|
||||
<MiniStat label={t("context.maintenanceLastAction")} value={maintenanceActionLabel(lastMaintenance?.action, t)} />
|
||||
<MiniStat label={t("context.maintenanceSnipAt")} value={formatOptionalTokens(maintenance.snipTrigger)} />
|
||||
<MiniStat label={t("context.maintenanceFoldAt")} value={formatOptionalTokens(maintenance.foldTrigger)} />
|
||||
<MiniStat label={t("context.maintenanceForceAt")} value={formatOptionalTokens(maintenance.forceTrigger)} />
|
||||
<MiniStat label={t("context.maintenanceHardAt")} value={formatOptionalTokens(maintenance.hardInputCeiling)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="context-panel__section context-panel__session-section">
|
||||
<SectionHeading title={t("context.sessionMetrics")} />
|
||||
<div className="context-panel__session-metrics">
|
||||
@@ -613,11 +649,10 @@ export function ContextPanel({
|
||||
</div>
|
||||
<div className="context-panel__source-legend">
|
||||
{sourceUsageRows.map((row) => {
|
||||
const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
|
||||
return (
|
||||
<span key={row.source}>
|
||||
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
|
||||
{sourceLabel(row.label, t)} {sharePct > 0 ? `${sharePct.toFixed(0)}%` : "-"}
|
||||
{sourceLabel(row.label, t)} {formatSharePercent(sourceTokenTotal(row), sourceTotalTokens)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ExtensionCard({ item, tabId }: { item: ExtensionItem; tabId?: st
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="extension-card" data-entrance="true" data-extension-surface={item.surfaceKey}>
|
||||
<div className="extension-card" data-entrance={item.id} data-extension-surface={item.surfaceKey}>
|
||||
<div className="extension-card__header">
|
||||
<Puzzle size={14} className="extension-card__icon" aria-hidden="true" />
|
||||
{card.title ? <span className="extension-card__title">{card.title}</span> : null}
|
||||
|
||||
@@ -4,15 +4,59 @@ import { Check, ChevronDown, Code2, Folder, SquareTerminal } from "lucide-react"
|
||||
import { app as desktopApp } from "../lib/bridge";
|
||||
import { t } from "../lib/i18n";
|
||||
import { useToast } from "../lib/toast";
|
||||
import type { ExternalOpenerView, ExternalOpenersView } from "../lib/types";
|
||||
import type { ExternalOpenerView, ExternalOpenersView, TabMeta } from "../lib/types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export interface ExternalOpenerBridge {
|
||||
ExternalOpeners(): Promise<ExternalOpenersView>;
|
||||
ExternalOpenersForTab(tabID: string): Promise<ExternalOpenersView>;
|
||||
SetPreferredExternalOpener(id: string): Promise<void>;
|
||||
OpenWorkspaceInExternalOpenerForTab(tabID: string, id: string): Promise<void>;
|
||||
}
|
||||
|
||||
type ExternalOpenerPreferenceCoordinator = {
|
||||
nextIntent: number;
|
||||
latestSuccessfulIntent: number;
|
||||
writeTail: Promise<void>;
|
||||
preferred: string;
|
||||
revision: number;
|
||||
listeners: Set<(preferred: string) => void>;
|
||||
};
|
||||
|
||||
const externalOpenerPreferenceCoordinators = new WeakMap<ExternalOpenerBridge, ExternalOpenerPreferenceCoordinator>();
|
||||
|
||||
function preferenceCoordinatorFor(bridge: ExternalOpenerBridge): ExternalOpenerPreferenceCoordinator {
|
||||
const current = externalOpenerPreferenceCoordinators.get(bridge);
|
||||
if (current) return current;
|
||||
const created: ExternalOpenerPreferenceCoordinator = {
|
||||
nextIntent: 0,
|
||||
latestSuccessfulIntent: 0,
|
||||
writeTail: Promise.resolve(),
|
||||
preferred: "",
|
||||
revision: 0,
|
||||
listeners: new Set(),
|
||||
};
|
||||
externalOpenerPreferenceCoordinators.set(bridge, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function persistPreferredExternalOpener(
|
||||
bridge: ExternalOpenerBridge,
|
||||
id: string,
|
||||
coordinator: ExternalOpenerPreferenceCoordinator,
|
||||
intent: number,
|
||||
): Promise<boolean> {
|
||||
const write = coordinator.writeTail.then(async () => {
|
||||
if (intent !== coordinator.latestSuccessfulIntent) return false;
|
||||
await bridge.SetPreferredExternalOpener(id);
|
||||
coordinator.preferred = id;
|
||||
coordinator.revision += 1;
|
||||
for (const listener of coordinator.listeners) listener(id);
|
||||
return true;
|
||||
});
|
||||
coordinator.writeTail = write.then(() => undefined, () => undefined);
|
||||
return write;
|
||||
}
|
||||
|
||||
function fallbackOpenerIcon(opener: ExternalOpenerView) {
|
||||
if (opener.kind === "file-manager") return <Folder size={15} strokeWidth={1.9} />;
|
||||
if (opener.kind === "terminal") return <SquareTerminal size={15} strokeWidth={1.9} />;
|
||||
@@ -42,7 +86,20 @@ function errorText(error: unknown): string {
|
||||
}
|
||||
|
||||
function normalizeOpeners(next: ExternalOpenersView): ExternalOpenersView {
|
||||
return { openers: Array.isArray(next.openers) ? next.openers : [], preferred: next.preferred ?? "" };
|
||||
return {
|
||||
openers: Array.isArray(next.openers) ? next.openers : [],
|
||||
preferred: next.preferred ?? "",
|
||||
workspaceOpenable: next.workspaceOpenable === true,
|
||||
};
|
||||
}
|
||||
|
||||
type ResolvedExternalOpeners = ExternalOpenersView & { tabId: string };
|
||||
|
||||
export function shouldMountExternalOpener(
|
||||
tab: Pick<TabMeta, "id" | "scope"> | null | undefined,
|
||||
imDetailVisible: boolean,
|
||||
): boolean {
|
||||
return !imDetailVisible && Boolean(tab?.id);
|
||||
}
|
||||
|
||||
export function ExternalOpener({
|
||||
@@ -59,30 +116,46 @@ export function ExternalOpener({
|
||||
const discoveryRequestRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const busyRef = useRef(false);
|
||||
const [state, setState] = useState<ExternalOpenersView>({ openers: [], preferred: "" });
|
||||
const [state, setState] = useState<ResolvedExternalOpeners>({
|
||||
openers: [],
|
||||
preferred: "",
|
||||
workspaceOpenable: false,
|
||||
tabId: "",
|
||||
});
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refreshOpeners = useCallback(async () => {
|
||||
const request = ++discoveryRequestRef.current;
|
||||
const preferenceCoordinator = preferenceCoordinatorFor(bridge);
|
||||
const preferenceRevision = preferenceCoordinator.revision;
|
||||
try {
|
||||
const next = normalizeOpeners(await bridge.ExternalOpeners());
|
||||
if (mountedRef.current && request === discoveryRequestRef.current) setState(next);
|
||||
const next = normalizeOpeners(await bridge.ExternalOpenersForTab(tabId));
|
||||
if (preferenceCoordinator.revision !== preferenceRevision && preferenceCoordinator.preferred) {
|
||||
next.preferred = preferenceCoordinator.preferred;
|
||||
}
|
||||
if (mountedRef.current && request === discoveryRequestRef.current) setState({ ...next, tabId });
|
||||
} catch (error) {
|
||||
if (mountedRef.current && request === discoveryRequestRef.current) {
|
||||
console.error("Failed to discover external openers", error);
|
||||
}
|
||||
}
|
||||
}, [bridge]);
|
||||
}, [bridge, tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const preferenceCoordinator = preferenceCoordinatorFor(bridge);
|
||||
const syncPreferred = (preferred: string) => {
|
||||
if (mountedRef.current) setState((current) => ({ ...current, preferred }));
|
||||
};
|
||||
preferenceCoordinator.listeners.add(syncPreferred);
|
||||
void refreshOpeners();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
discoveryRequestRef.current += 1;
|
||||
preferenceCoordinator.listeners.delete(syncPreferred);
|
||||
};
|
||||
}, [refreshOpeners]);
|
||||
}, [bridge, refreshOpeners]);
|
||||
|
||||
useEffect(() => setMenuOpen(false), [dismissSignal, tabId]);
|
||||
|
||||
@@ -103,13 +176,17 @@ export function ExternalOpener({
|
||||
}, [menuOpen]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => state.openers.find((opener) => opener.id === state.preferred) ?? state.openers[0],
|
||||
[state],
|
||||
() => state.tabId === tabId
|
||||
? state.openers.find((opener) => opener.id === state.preferred) ?? state.openers[0]
|
||||
: undefined,
|
||||
[state, tabId],
|
||||
);
|
||||
|
||||
const openIn = useCallback(
|
||||
async (opener: ExternalOpenerView, persist: boolean) => {
|
||||
if (busyRef.current) return;
|
||||
const preferenceCoordinator = persist ? preferenceCoordinatorFor(bridge) : undefined;
|
||||
const preferenceIntent = preferenceCoordinator ? ++preferenceCoordinator.nextIntent : 0;
|
||||
busyRef.current = true;
|
||||
discoveryRequestRef.current += 1;
|
||||
setBusy(true);
|
||||
@@ -123,10 +200,19 @@ export function ExternalOpener({
|
||||
showToast(t("externalOpener.failed", { name: opener.name, error: errorText(error) }), "error");
|
||||
return;
|
||||
}
|
||||
if (persist && opener.id !== state.preferred) {
|
||||
if (preferenceCoordinator) {
|
||||
preferenceCoordinator.latestSuccessfulIntent = Math.max(
|
||||
preferenceCoordinator.latestSuccessfulIntent,
|
||||
preferenceIntent,
|
||||
);
|
||||
try {
|
||||
await bridge.SetPreferredExternalOpener(opener.id);
|
||||
setState((current) => ({ ...current, preferred: opener.id }));
|
||||
const persisted = await persistPreferredExternalOpener(
|
||||
bridge,
|
||||
opener.id,
|
||||
preferenceCoordinator,
|
||||
preferenceIntent,
|
||||
);
|
||||
if (persisted && mountedRef.current) setState((current) => ({ ...current, preferred: opener.id }));
|
||||
} catch (error) {
|
||||
showToast(t("externalOpener.persistFailed", { name: opener.name, error: errorText(error) }), "error");
|
||||
}
|
||||
@@ -136,10 +222,10 @@ export function ExternalOpener({
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[bridge, showToast, state.preferred, tabId],
|
||||
[bridge, showToast, tabId],
|
||||
);
|
||||
|
||||
if (!selected) return null;
|
||||
if (state.tabId !== tabId || state.workspaceOpenable !== true || !selected) return null;
|
||||
const openLabel = t("externalOpener.openIn", { name: selected.name });
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,7 @@ export function FloatingMenu({
|
||||
const pos = useMemo(() => clampFloatingMenuPosition(x, y, width, estimatedHeight), [estimatedHeight, width, x, y]);
|
||||
if (typeof document === "undefined") return null;
|
||||
// Portal to <body>: the menu is position:fixed, but a transformed ancestor
|
||||
// (e.g. .workspace-preview carries a residual GSAP transform) would otherwise
|
||||
// (e.g. .workspace-preview carries a residual transform) would otherwise
|
||||
// become its containing block and push the fixed coordinates off-screen.
|
||||
// Rendering at the body root keeps fixed positioning relative to the viewport.
|
||||
return createPortal(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { replaySubmitTextPreservingSelectedContext } from "../lib/editReplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import { displayReasoningText, STREAMING_REASONING_WINDOW_STEP_CHARS, STREAMING_REASONING_WINDOW_STEP_LINES } from "../lib/reasoningDisplay";
|
||||
import { ReasoningSummary } from "./ReasoningSummary";
|
||||
import { historyEntryIdForItemId } from "../lib/transcriptRows";
|
||||
@@ -919,7 +919,7 @@ function ReasoningPanel({
|
||||
: "";
|
||||
const label = isReasoningRunning ? t("msg.thinkingRunning") : t("msg.thinking");
|
||||
const meta = isReasoningRunning ? "" : reasoningDurationLabel(item.reasoningDurationMs, t);
|
||||
useGSAPCollapse(reasoningBodyRef, reasoningOpen);
|
||||
useCollapseAnimation(reasoningBodyRef, reasoningOpen);
|
||||
|
||||
return (
|
||||
<div className="reasoning">
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
|
||||
import { topicActivityTime } from "../lib/session";
|
||||
import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n";
|
||||
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
|
||||
import { useProjectTreeArchiveState } from "../lib/projectTreeArchive";
|
||||
import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
|
||||
import type { ShortcutPlatform } from "../lib/keyboardShortcuts";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
@@ -650,7 +651,7 @@ export function ProjectTree({
|
||||
const [hoverCard, setHoverCard] = useState<{ key: string; card: ProjectTreeTopicHoverCard; left: number; top: number } | null>(null);
|
||||
const hoverCardTimerRef = useRef<number | null>(null);
|
||||
const creatingRef = useRef(false);
|
||||
const trashingRef = useRef(false);
|
||||
const { trashingTopics, beginTrashingTopic, endTrashingTopic } = useProjectTreeArchiveState();
|
||||
const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -694,23 +695,14 @@ export function ProjectTree({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keyed by the data, not the selection: a switch cannot change the tree.
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const nodes = await app.ListProjectTree();
|
||||
const list = asArray(nodes);
|
||||
setTree(list);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
const collapsed = manuallyCollapsedRef.current;
|
||||
for (const key of defaultExpandedProjectTreeKeys(list, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) {
|
||||
if (!collapsed.has(key)) next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTree(asArray(await app.ListProjectTree()));
|
||||
} catch {
|
||||
/* bridge unavailable */
|
||||
}
|
||||
}, [activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
manuallyCollapsedRef.current = manuallyCollapsed;
|
||||
@@ -727,6 +719,15 @@ export function ProjectTree({
|
||||
void refresh();
|
||||
}, [refresh, refreshSignal]);
|
||||
|
||||
// Following the active topic is a view concern over the tree already held.
|
||||
useEffect(() => {
|
||||
const collapsed = manuallyCollapsedRef.current;
|
||||
const keys = defaultExpandedProjectTreeKeys(tree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath).filter((key) => !collapsed.has(key));
|
||||
// Returning prev unchanged keeps a switch that expands nothing new from
|
||||
// re-rendering the tree at all.
|
||||
setExpanded((prev) => (keys.every((key) => prev.has(key)) ? prev : new Set([...prev, ...keys])));
|
||||
}, [tree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath]);
|
||||
|
||||
const markNodeRead = useCallback((node: ProjectNode) => {
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
@@ -1042,8 +1043,7 @@ export function ProjectTree({
|
||||
};
|
||||
|
||||
const trashTopic = async (topicId: string) => {
|
||||
if (trashingRef.current) return;
|
||||
trashingRef.current = true;
|
||||
if (!beginTrashingTopic(topicId)) return;
|
||||
try {
|
||||
await app.TrashTopic(topicId);
|
||||
setMenuTopic(null);
|
||||
@@ -1054,10 +1054,9 @@ export function ProjectTree({
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
trashingRef.current = false;
|
||||
endTrashingTopic(topicId);
|
||||
}
|
||||
};
|
||||
|
||||
const setTopicPinned = async (topicId: string, pinned: boolean) => {
|
||||
try {
|
||||
await app.SetTopicPinned(topicId, pinned);
|
||||
@@ -1323,6 +1322,7 @@ export function ProjectTree({
|
||||
const showSideTime = sideTimeVisible && !showWaitingPill;
|
||||
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const topicId = node.topicId ?? "";
|
||||
const topicTrashing = trashingTopics.has(topicId);
|
||||
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
|
||||
const imSourceLabel = imSource?.label || "";
|
||||
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
|
||||
@@ -1361,9 +1361,9 @@ export function ProjectTree({
|
||||
},
|
||||
{
|
||||
key: "trash",
|
||||
icon: <Archive size={13} />,
|
||||
icon: <Archive className={topicTrashing ? "project-tree__archive-spinner" : undefined} size={13} />,
|
||||
label: confirmAction?.topicId === topicId && confirmAction.action === "trash" ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
|
||||
disabled: archiveBlocked,
|
||||
disabled: archiveBlocked || topicTrashing,
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
if (confirmAction?.topicId === topicId && confirmAction.action === "trash") void trashTopic(topicId);
|
||||
@@ -1529,17 +1529,17 @@ export function ProjectTree({
|
||||
</Tooltip>
|
||||
<Tooltip label={t("projectTree.archiveTopic")} side="top" className="project-tree__topic-action-slot">
|
||||
<button
|
||||
className="project-tree__topic-action project-tree__topic-action--archive"
|
||||
className={`project-tree__topic-action project-tree__topic-action--archive${topicTrashing ? " project-tree__topic-action--busy" : ""}`}
|
||||
type="button"
|
||||
aria-label={t("projectTree.archiveTopic")}
|
||||
disabled={archiveBlocked}
|
||||
aria-busy={topicTrashing} disabled={archiveBlocked || topicTrashing}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void trashTopic(topicId);
|
||||
}}
|
||||
>
|
||||
<Archive size={15} aria-hidden="true" />
|
||||
<Archive className={topicTrashing ? "project-tree__archive-spinner" : undefined} size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
@@ -1750,11 +1750,11 @@ export function ProjectTree({
|
||||
},
|
||||
{
|
||||
key: "archive-active-topic",
|
||||
icon: <Archive size={13} />,
|
||||
icon: <Archive className={activeTopicId && trashingTopics.has(activeTopicId) ? "project-tree__archive-spinner" : undefined} size={13} />,
|
||||
label: activeTopicId && confirmAction?.topicId === activeTopicId && confirmAction.action === "trash"
|
||||
? t("history.confirmMoveToTrash")
|
||||
: t("projectTree.archiveConversation"),
|
||||
disabled: !activeTopicInProject || !activeTopicId || activeTopicArchiveBlocked,
|
||||
disabled: !activeTopicInProject || !activeTopicId || activeTopicArchiveBlocked || Boolean(activeTopicId && trashingTopics.has(activeTopicId)),
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
if (!activeTopicId) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ReadOnlyBatch = memo(function ReadOnlyBatch({ items, subcalls, tabI
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
useGSAPCollapse(bodyRef, open);
|
||||
useCollapseAnimation(bodyRef, open);
|
||||
|
||||
const readCount = items.filter((it) => it.name === "read_file" || it.name === "ls").length;
|
||||
const searchCount = items.filter((it) => it.name === "grep" || it.name === "glob" || it.name === "web_fetch").length;
|
||||
|
||||
@@ -917,7 +917,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, {
|
||||
id="composer-input"
|
||||
ref={rootRef}
|
||||
className="composer__rich-input"
|
||||
contentEditable={!disabled}
|
||||
contentEditable={!disabled} spellCheck={false} autoCorrect="off" autoCapitalize="off"
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Activity, ChevronsUpDown, CircleDollarSign, CircleGauge, Database, FileOutput, Folder, Gauge, GitBranch, HardDrive, Layers, Percent, Puzzle, RefreshCw, Server, Settings, Square, Unplug, Wallet, Zap } from "lucide-react";
|
||||
import { Activity, CircleDollarSign, CircleGauge, Database, FileOutput, Folder, Gauge, GitBranch, HardDrive, Layers, Percent, Puzzle, RefreshCw, Server, Settings, Square, Unplug, Wallet, Zap } from "lucide-react";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
import { RemoteConnectionErrorDialog } from "./RemoteConnectionErrorDialog";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -66,14 +66,8 @@ function formatTurnCount(turns: number | undefined, t: Translator): string {
|
||||
if (typeof turns !== "number" || turns < 0) return "-";
|
||||
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
|
||||
}
|
||||
|
||||
|
||||
function formatTps(outputTokens?: number, modelMs?: number, estimated = false): string | null {
|
||||
if (!outputTokens || outputTokens <= 0) return null;
|
||||
if (!modelMs || modelMs <= 0) return null;
|
||||
const elapsedSec = modelMs / 1000;
|
||||
if (elapsedSec < 0.001) return null;
|
||||
const tps = outputTokens / elapsedSec;
|
||||
function formatTps(tps?: number | null, estimated = false): string | null {
|
||||
if (!tps || tps <= 0) return null;
|
||||
const prefix = estimated ? "≈" : "";
|
||||
if (tps < 1) return `${prefix}<1 t/s`;
|
||||
return `${prefix}${Math.round(tps)} t/s`;
|
||||
@@ -176,6 +170,7 @@ export function StatusBar({
|
||||
lastTurnOutputTokens,
|
||||
lastTurnModelMs,
|
||||
lastTurnOutputEstimated = false,
|
||||
lastRequestTps,
|
||||
turnCost,
|
||||
cost,
|
||||
currency,
|
||||
@@ -209,6 +204,7 @@ export function StatusBar({
|
||||
lastTurnOutputTokens?: number;
|
||||
lastTurnModelMs?: number;
|
||||
lastTurnOutputEstimated?: boolean;
|
||||
lastRequestTps?: number | null; // Null means the latest request was not measurable.
|
||||
turnCost?: number;
|
||||
cost?: number;
|
||||
currency?: string;
|
||||
@@ -255,7 +251,9 @@ export function StatusBar({
|
||||
const tokenLabel = markEstimated(formatTokenCount(sessionTokens), sessionEstimated);
|
||||
const turnTokenLabel = markEstimated(formatTokenCount(turnTokens), turnEstimated);
|
||||
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
|
||||
const tpsLabel = formatTps(lastTurnOutputTokens, lastTurnModelMs, lastTurnOutputEstimated);
|
||||
const tpsLabel = lastRequestTps === undefined
|
||||
? formatTps(lastTurnOutputTokens && lastTurnModelMs ? lastTurnOutputTokens / (lastTurnModelMs / 1_000) : null, lastTurnOutputEstimated)
|
||||
: formatTps(lastRequestTps);
|
||||
const formatUsageToken = (value: number) => `${usage?.estimated ? "≈" : ""}${value.toLocaleString()}`;
|
||||
const outputTokensLabel = usage && typeof usage.completionTokens === "number"
|
||||
? formatUsageToken(usage.completionTokens)
|
||||
@@ -673,25 +671,24 @@ function RemoteStatusBarChip({
|
||||
const worstHost = hosts.find((host) => host.id === worst.hostId) ?? hosts[0];
|
||||
const triggerState = isRemoteTerminalFailure(worst) ? "error" : worst.state;
|
||||
const triggerStatus = isRemoteTerminalFailure(worst) ? t("remote.status.failed") : t(`remote.status.${worst.state}`);
|
||||
const triggerLabel = worst.state === "stopped" && !worst.error
|
||||
? t("remote.statusBar.disconnected")
|
||||
: t("remote.statusBar.summary", { host: worstHost.label, status: triggerStatus });
|
||||
const idleDisconnected = worst.state === "stopped" && !worst.error;
|
||||
const triggerLabel = idleDisconnected ? t("remote.statusBar.disconnected") : t("remote.statusBar.summary", { host: worstHost.label, status: triggerStatus });
|
||||
const triggerText = idleDisconnected ? "SSH" : triggerState === "connected" ? worstHost.label : triggerLabel;
|
||||
|
||||
return (
|
||||
<span className="statusbar__remote-wrap">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`statusbar__remote remote-chip remote-chip--${triggerState}`}
|
||||
className={`statusbar__remote remote-chip remote-chip--${triggerState}${idleDisconnected ? " statusbar__remote--idle" : ""}`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-label={triggerLabel}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={triggerLabel}
|
||||
>
|
||||
<Server size={11} aria-hidden="true" />
|
||||
<span>{triggerLabel}</span>
|
||||
<ChevronsUpDown size={10} aria-hidden="true" />
|
||||
{triggerState === "connected" ? <span className="statusbar__remote-state-dot" aria-hidden="true" /> : <Server size={11} aria-hidden="true" />}
|
||||
<span className="statusbar__remote-label">{triggerText}</span>
|
||||
</button>
|
||||
<AnchoredPopover
|
||||
open={open}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useT } from "../lib/i18n";
|
||||
import { diffsFor, languageForToolArgs, subjectOf, summarize, summarizeFileDiff } from "../lib/tools";
|
||||
import { useShellExpand } from "../lib/shellExpand";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import { isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
|
||||
import type { Translator } from "../lib/i18n";
|
||||
import { ReadOnlyBatch } from "./ReadOnlyBatch";
|
||||
@@ -294,9 +294,9 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
? `${shellName} ${item.status}${shellSummary || summary ? ` ${shellSummary || summary}` : ""}`
|
||||
: undefined;
|
||||
|
||||
// GSAP-driven collapse/expand for tool body
|
||||
// Native collapse/expand for the tool body.
|
||||
const toolBodyRef = useRef<HTMLDivElement>(null);
|
||||
useGSAPCollapse(toolBodyRef, open);
|
||||
useCollapseAnimation(toolBodyRef, open);
|
||||
|
||||
return (
|
||||
<div className={`tool${quiet ? " tool--quiet" : ""}${isSubagent ? " tool--subagent" : ""}${open && hasBody ? " tool--open" : ""}`} data-entrance={item.id} data-shell={isShellCard ? execution?.shell || "bash" : undefined}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
|
||||
@@ -121,7 +121,7 @@ export const ToolGroup = memo(function ToolGroup({
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
useGSAPCollapse(bodyRef, open);
|
||||
useCollapseAnimation(bodyRef, open);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ReadOnlyBatch } from "./ReadOnlyBatch";
|
||||
import { ToolGroup } from "./ToolGroup";
|
||||
import { getProcessFoldPreference, onProcessFoldPreferenceChange, type ProcessFoldPreference } from "../lib/processFoldPreference";
|
||||
import { STEER_NOTICE_PREFIX, isSteerNoticeText } from "../lib/useController";
|
||||
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useTranscriptEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useScrollManager } from "../lib/useScrollManager";
|
||||
import { compactQuestionText, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, type QuestionAnchor } from "../lib/transcriptGrouping";
|
||||
import {
|
||||
@@ -145,7 +145,7 @@ function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
|
||||
// The transcript is a single flat virtual list (block-level rows: user
|
||||
// message, process-fold header, tool batch, answer, notice, turn actions, …)
|
||||
// rendered by @tanstack/react-virtual over the scroll container. Overscan of 8
|
||||
// rows keeps offscreen Markdown/ToolCard/Mermaid/GSAP instances unmounted.
|
||||
// rows keeps offscreen Markdown, ToolCard, Mermaid, and animation instances unmounted.
|
||||
// anchorTo: "end" makes the virtualizer compensate prepends (older history
|
||||
// pages), fold toggles and async height drift against the stable row keys, so
|
||||
// the reading position does not jump; measurement (measureElement) owns all
|
||||
@@ -442,8 +442,7 @@ export function Transcript({
|
||||
setCreationScrollbarHot(true);
|
||||
}, [SCROLLBAR_HOT_ZONE_PX, SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef, setCreationScrollbarHot, syncCreationScrollbarMetrics]);
|
||||
|
||||
const sessionKey = useMemo(() => `${items[0]?.id ?? ""}|${items[items.length - 1]?.id ?? ""}`, [items]);
|
||||
const entranceRef = useEntranceAnimation<HTMLDivElement>(sessionKey, items.length);
|
||||
const entranceRef = useTranscriptEntranceAnimation<HTMLDivElement>(tabId, revealSignal, items);
|
||||
|
||||
// Lease the markdown parse worker for as long as a transcript surface is
|
||||
// mounted; the last release terminates the thread (it re-spawns lazily).
|
||||
@@ -562,7 +561,7 @@ export function Transcript({
|
||||
};
|
||||
}, [items.length, scheduleRepinIfWasPinned]);
|
||||
|
||||
// Footer height changes → smooth scroll repin with GSAP.
|
||||
// Footer height changes → frame-batched scroll repin.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -782,7 +781,7 @@ export function Transcript({
|
||||
case "phase":
|
||||
return (
|
||||
<div className="turn-collapse__body">
|
||||
<PhaseCard text={row.item.text} />
|
||||
<PhaseCard id={row.item.id} text={row.item.text} />
|
||||
</div>
|
||||
);
|
||||
case "process-notice":
|
||||
@@ -810,7 +809,7 @@ export function Transcript({
|
||||
);
|
||||
case "notice":
|
||||
if (isSteerNoticeText(row.item.text)) {
|
||||
return <SteerCard text={row.item.text} />;
|
||||
return <SteerCard id={row.item.id} text={row.item.text} />;
|
||||
}
|
||||
return (
|
||||
<NoticeCard
|
||||
@@ -1135,17 +1134,17 @@ function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; o
|
||||
|
||||
type CompactionItem = Extract<Item, { kind: "compaction" }>;
|
||||
|
||||
function PhaseCard({ text }: { text: string }) {
|
||||
return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
|
||||
function PhaseCard({ id, text }: { id: string; text: string }) {
|
||||
return <div className="phase" data-entrance={id}><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
|
||||
}
|
||||
|
||||
// A mid-turn steer is the user's own message, so it renders on the user side
|
||||
// of the transcript instead of disappearing into the work fold.
|
||||
function SteerCard({ text }: { text: string }) {
|
||||
function SteerCard({ id, text }: { id: string; text: string }) {
|
||||
const t = useT();
|
||||
const body = text.startsWith(STEER_NOTICE_PREFIX) ? text.slice(STEER_NOTICE_PREFIX.length) : text;
|
||||
return (
|
||||
<div className="steer-line" data-entrance="true">
|
||||
<div className="steer-line" data-entrance={id}>
|
||||
<div className="steer-line__bubble" title={t("transcript.steer")}>
|
||||
<span className="steer-line__icon" aria-hidden="true">↪</span>
|
||||
<span className="steer-line__text">{body}</span>
|
||||
@@ -1194,7 +1193,7 @@ export function NoticeCard({ item, onAction, actionDisabled = false }: { item: N
|
||||
const t = useT();
|
||||
const StatusIcon = item.level === "warn" ? TriangleAlert : Info;
|
||||
return (
|
||||
<div className={`notice-line notice-line--${item.level}${item.variant ? ` notice-line--${item.variant}` : ""}`} data-entrance="true">
|
||||
<div className={`notice-line notice-line--${item.level}${item.variant ? ` notice-line--${item.variant}` : ""}`} data-entrance={item.id}>
|
||||
<StatusIcon className="notice-line__icon" size={14} aria-hidden="true" />
|
||||
<div className="notice-line__text">
|
||||
{item.decisionReceipt ? (
|
||||
|
||||
@@ -60,6 +60,7 @@ import { workspaceGitStatusLabel } from "../lib/workspaceChanges";
|
||||
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
|
||||
import { formatSelectionReference, languageFor } from "../lib/selectedTextContext";
|
||||
import { cleanGitDiff } from "../lib/diff";
|
||||
import { WORKSPACE_CONTEXT_MENU_FILE_HEIGHT, WORKSPACE_CONTEXT_MENU_REF_HEIGHT, workspacePathCopyMenuItems } from "../lib/workspacePathCopyMenuItems";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
import { DiffView } from "./DiffView";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
@@ -75,8 +76,6 @@ const WORKSPACE_TREE_RAIL_WIDTH = 44;
|
||||
const WORKSPACE_PREVIEW_MIN_WIDTH = 140;
|
||||
const WORKSPACE_PREVIEW_TARGET_WIDTH = 360;
|
||||
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
|
||||
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 136;
|
||||
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 92;
|
||||
const WORKSPACE_CONTEXT_MENU_SELECTION_HEIGHT = 48;
|
||||
const WORKSPACE_MAX_PREVIEW_TABS = 5;
|
||||
|
||||
@@ -2123,6 +2122,7 @@ export function WorkspacePanel({
|
||||
onSelect: () => void addTreeFileToChat(),
|
||||
},
|
||||
]),
|
||||
...workspacePathCopyMenuItems({ path: treeMenu.path, resolveAbsolutePath: () => app.ResolveWorkspacePathForTab(workspaceTabId, treeMenu.path), isScopeCurrent: () => currentWorkspaceScopeKeyRef.current === workspaceScopeKey, close: () => setTreeMenu(null), relativeLabel: t("workspace.copyRelativePath"), absoluteLabel: t("workspace.copyAbsolutePath") }),
|
||||
{
|
||||
icon: <FolderOpen size={14} />,
|
||||
label: t("workspace.revealInFileManager"),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// bridge is the single seam between the React app and the Go kernel. In the Wails
|
||||
// shell it calls the bound App methods (window.go.main.App.*) and subscribes to
|
||||
// the runtime event stream (window.runtime.EventsOn). In a plain browser (`pnpm
|
||||
// dev` outside the shell) those globals are absent, so it falls back to a mock
|
||||
// that streams a canned turn through the same contract — letting the whole UI be
|
||||
// bridge is the seam between React and the Go kernel. The Wails shell calls bound
|
||||
// App methods and subscribes to runtime events; in a plain browser (`pnpm dev`),
|
||||
// a mock streams a canned turn through the same contract so the whole UI can be
|
||||
// developed and laid out without rebuilding the Go side.
|
||||
|
||||
// @ts-ignore `wails generate module` creates this locally; fresh checkouts keep
|
||||
@@ -11,6 +9,7 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App";
|
||||
import type { InvocationRequest } from "./invocationDisplay";
|
||||
|
||||
import { addBreadcrumb } from "./breadcrumbs";
|
||||
import { maybeShare } from "./queryCoalesce";
|
||||
import { t } from "./i18n";
|
||||
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
|
||||
@@ -387,7 +386,8 @@ export interface AppBindings {
|
||||
WorkspaceGitCommitDetail(tabID: string, hash: string, path: string): Promise<GitCommitDetailView>;
|
||||
OpenWorkspacePath(rel: string): Promise<void>;
|
||||
OpenWorkspacePathForTab(tabID: string, rel: string): Promise<void>;
|
||||
ExternalOpeners(): Promise<ExternalOpenersView>;
|
||||
ResolveWorkspacePathForTab(tabID: string, rel: string): Promise<string>;
|
||||
ExternalOpeners(): Promise<ExternalOpenersView>; ExternalOpenersForTab(tabID: string): Promise<ExternalOpenersView>;
|
||||
SetPreferredExternalOpener(id: string): Promise<void>;
|
||||
OpenWorkspaceInExternalOpener(id: string): Promise<void>;
|
||||
OpenWorkspaceInExternalOpenerForTab(tabID: string, id: string): Promise<void>; OpenLocalPathInExternalOpener(path: string, id: string): Promise<void>; SaveLocalPathAs(path: string): Promise<string>;
|
||||
@@ -1008,12 +1008,11 @@ export const app: AppBindings = new Proxy({} as AppBindings, {
|
||||
const v = (target as unknown as Record<string, unknown>)[String(prop)];
|
||||
if (typeof v !== "function") return v;
|
||||
return (...args: unknown[]) => {
|
||||
const method = String(prop);
|
||||
const crumb = bridgeBreadcrumb(method);
|
||||
const method = String(prop), crumb = bridgeBreadcrumb(method);
|
||||
const startedAt = crumb ? (typeof performance !== "undefined" ? performance.now() : Date.now()) : 0;
|
||||
if (crumb) addBreadcrumb("bridge", crumb);
|
||||
try {
|
||||
const result = (v as (...a: unknown[]) => unknown).apply(target, args);
|
||||
const result = maybeShare(method, args, () => (v as (...a: unknown[]) => unknown).apply(target, args));
|
||||
if (result && typeof (result as Promise<unknown>).then === "function") {
|
||||
return (result as Promise<unknown>).then(
|
||||
(value) => {
|
||||
@@ -3982,6 +3981,7 @@ function makeMockApp(): AppBindings {
|
||||
async OpenWorkspacePathForTab(_tabID: string, rel: string) {
|
||||
await this.OpenWorkspacePath(rel);
|
||||
},
|
||||
async ResolveWorkspacePathForTab(_tabID: string, rel: string) { return `${cwd.replace(/[\\/]+$/, "")}/${rel.replace(/^[/\\]+/, "").replace(/[\\/]+$/, "")}`; },
|
||||
async ExternalOpeners() {
|
||||
return {
|
||||
openers: [
|
||||
@@ -3992,7 +3992,7 @@ function makeMockApp(): AppBindings {
|
||||
],
|
||||
preferred: "vscode",
|
||||
} as ExternalOpenersView;
|
||||
},
|
||||
}, async ExternalOpenersForTab(_tabID: string) { return { ...(await this.ExternalOpeners()), workspaceOpenable: true }; },
|
||||
async SetPreferredExternalOpener(_id: string) {},
|
||||
async OpenWorkspaceInExternalOpener(_id: string) {},
|
||||
async OpenWorkspaceInExternalOpenerForTab(_tabID: string, id: string) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Translator } from "./i18n";
|
||||
|
||||
export type ContextMaintenanceStatus = "planned" | "applied" | "noop" | "blocked" | "failed";
|
||||
export type ContextMaintenanceAction = "snip" | "prune" | "summary" | "native_tool_clear" | "noop";
|
||||
|
||||
export interface WireContextMaintenance {
|
||||
status?: ContextMaintenanceStatus;
|
||||
action?: ContextMaintenanceAction;
|
||||
trigger?: string;
|
||||
operationId?: string;
|
||||
inputTokens?: number;
|
||||
resultTokens?: number;
|
||||
savedTokens?: number;
|
||||
affectedToolResults?: number;
|
||||
projectionVersion?: number;
|
||||
cacheBreak?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ContextMaintenanceReceipt extends WireContextMaintenance {
|
||||
sourceProjection?: number;
|
||||
coveredCount?: number;
|
||||
coveredPrefixHash?: string;
|
||||
inputHash?: string;
|
||||
outputHash?: string;
|
||||
summaryHash?: string;
|
||||
archive?: string;
|
||||
blockedInputHash?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ContextMaintenanceInfo {
|
||||
canonicalTokens?: number;
|
||||
projectedTokens?: number;
|
||||
summaryTokens?: number;
|
||||
lastSavedTokens?: number;
|
||||
snipTrigger?: number;
|
||||
foldTrigger?: number;
|
||||
forceTrigger?: number;
|
||||
hardInputCeiling?: number;
|
||||
headroom?: number;
|
||||
projectionVersion?: number;
|
||||
blocked?: boolean;
|
||||
lastReceipt?: ContextMaintenanceReceipt;
|
||||
}
|
||||
|
||||
function maintenanceActionLabel(action: ContextMaintenanceAction | undefined, t: Translator): string | undefined {
|
||||
switch (action) {
|
||||
case "snip": return t("context.maintenanceActionSnip");
|
||||
case "prune": return t("context.maintenanceActionPrune");
|
||||
case "summary": return t("summary.detail");
|
||||
case "native_tool_clear": return t("context.maintenanceActionNative");
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatContextMaintenanceNotice(m: WireContextMaintenance, t: Translator): string {
|
||||
const parts = [t("context.maintenanceTitle")];
|
||||
const action = maintenanceActionLabel(m.action, t);
|
||||
if (action) parts.push(action);
|
||||
switch (m.status) {
|
||||
case "blocked": parts.push(t("projectTree.status.paused")); break;
|
||||
case "failed": parts.push(t("task.state.failed")); break;
|
||||
case "applied": parts.push(t("settings.typography.applied")); break;
|
||||
}
|
||||
if (typeof m.inputTokens === "number" && typeof m.resultTokens === "number") {
|
||||
parts.push(t("context.tokensValue", {
|
||||
value: `${m.inputTokens.toLocaleString()} → ${m.resultTokens.toLocaleString()}`,
|
||||
}));
|
||||
}
|
||||
if (typeof m.savedTokens === "number" && m.savedTokens > 0) {
|
||||
parts.push(`−${t("context.tokensValue", { value: m.savedTokens.toLocaleString() })}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Shared GSAP animation configuration.
|
||||
// Mirrors the CSS token system (--dur-fast/--dur-base/--dur-slow/--ease-out)
|
||||
// so JS-driven animations stay in sync with the CSS transition layer.
|
||||
|
||||
/** 120ms — color/border hovers, tooltips. */
|
||||
export const DUR_FAST = 0.12;
|
||||
|
||||
/** 180ms — popovers, menus, small enters. Matches CSS --dur-base. */
|
||||
export const DUR_BASE = 0.18;
|
||||
|
||||
/** 340ms — drawers, modals, panel slides. Matches CSS --dur-slow. */
|
||||
export const DUR_SLOW = 0.34;
|
||||
|
||||
/** "power2.out" approximates the app-wide CSS cubic-bezier(0.2, 0.72, 0.2, 1). */
|
||||
export const EASE_OUT = "power2.out";
|
||||
|
||||
/** Returns true when the user has requested reduced motion at the OS level. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Shared timing configuration for CSS and Web Animations API transitions.
|
||||
// The curves mirror the desktop CSS motion tokens.
|
||||
|
||||
/** 120ms — color/border hovers, tooltips. */
|
||||
export const DUR_FAST = 0.12;
|
||||
|
||||
/** 180ms — popovers, menus, small enters. Matches CSS --dur-base. */
|
||||
export const DUR_BASE = 0.18;
|
||||
|
||||
/** 340ms — drawers, modals, panel slides. Matches CSS --dur-slow. */
|
||||
export const DUR_SLOW = 0.34;
|
||||
|
||||
/** Fast-out/decelerate curve used by expanding and entrance animations. */
|
||||
export const CSS_EASE_OUT = "cubic-bezier(0.2, 0.72, 0.2, 1)";
|
||||
|
||||
/** Accelerating curve used by short exit animations. */
|
||||
export const CSS_EASE_IN = "cubic-bezier(0.8, 0, 0.8, 0.28)";
|
||||
|
||||
/** Runs a short exit transition without letting cosmetic failures block work. */
|
||||
export function animateElementExit(
|
||||
el: HTMLElement,
|
||||
options: { opacity: number; y: number; duration: number; onComplete: () => void },
|
||||
) {
|
||||
let completed = false;
|
||||
const complete = () => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
options.onComplete();
|
||||
};
|
||||
if (typeof el.animate !== "function") {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
let animation: Animation;
|
||||
try {
|
||||
animation = el.animate(
|
||||
[
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
{ opacity: options.opacity, transform: `translateY(${options.y}px)` },
|
||||
],
|
||||
{ duration: options.duration * 1000, easing: CSS_EASE_IN },
|
||||
);
|
||||
} catch {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
animation.onfinish = complete;
|
||||
animation.oncancel = complete;
|
||||
}
|
||||
|
||||
/** Returns true when the user has requested reduced motion at the OS level. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export function projectTreeTrashingTopics(previous: Set<string>, topicId: string, trashing: boolean): Set<string> {
|
||||
const id = topicId.trim();
|
||||
if (!id || previous.has(id) === trashing) return previous;
|
||||
const next = new Set(previous);
|
||||
if (trashing) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function useProjectTreeArchiveState() {
|
||||
const topicsRef = useRef<Set<string>>(new Set());
|
||||
const [topics, setTopics] = useState<Set<string>>(new Set());
|
||||
const begin = useCallback((topicId: string) => {
|
||||
if (topicsRef.current.has(topicId)) return false;
|
||||
topicsRef.current = projectTreeTrashingTopics(topicsRef.current, topicId, true);
|
||||
setTopics(topicsRef.current);
|
||||
return true;
|
||||
}, []);
|
||||
const end = useCallback((topicId: string) => {
|
||||
topicsRef.current = projectTreeTrashingTopics(topicsRef.current, topicId, false);
|
||||
setTopics(topicsRef.current);
|
||||
}, []);
|
||||
return { trashingTopics: topics, beginTrashingTopic: begin, endTrashingTopic: end };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Coalesces identical read-only backend queries that a single UI transition
|
||||
* fires more than once.
|
||||
*
|
||||
* Switching a session fans out ~20-28 bridge calls, and measurement showed a
|
||||
* third of them are duplicates within the same transition: independent hooks
|
||||
* each ask for the context panel, the project tree, the tab meta. Deduping at
|
||||
* the call sites would mean threading a cache through a dozen hooks; deduping
|
||||
* at the bridge is one place and cannot be forgotten by the next hook.
|
||||
*
|
||||
* Only queries on the allowlist are coalesced, and only for the few
|
||||
* milliseconds a transition takes: a stale answer is worse than a slow one, so
|
||||
* nothing here outlives the burst it exists to collapse.
|
||||
*/
|
||||
|
||||
/** How long an in-flight or just-settled answer may be shared. */
|
||||
const WINDOW_MS = 200;
|
||||
|
||||
/**
|
||||
* COALESCED lists read-only queries whose answer cannot change within one UI
|
||||
* transition. Anything that mutates, starts work, or reads a value the user is
|
||||
* actively editing stays off this list.
|
||||
*/
|
||||
const COALESCED = new Set([
|
||||
"ListProjectTree",
|
||||
"ListTabs",
|
||||
"ContextPanel",
|
||||
"MetaForTab",
|
||||
"EffortForTab",
|
||||
"JobsForTab",
|
||||
"BackgroundRuntimes",
|
||||
"BalanceForTab",
|
||||
]);
|
||||
|
||||
type Entry = { promise: Promise<unknown>; at: number };
|
||||
|
||||
const inflight = new Map<string, Entry>();
|
||||
|
||||
function now(): number {
|
||||
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
export function coalescesQuery(method: string): boolean {
|
||||
return COALESCED.has(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeShare is the bridge's single entry point: it decides whether a call is
|
||||
* shareable and shares it, so the proxy never has to know the allowlist.
|
||||
*/
|
||||
export function maybeShare(method: string, args: unknown[], run: () => unknown): unknown {
|
||||
if (!coalescesQuery(method)) return run();
|
||||
return shareQuery(method, args, async () => run());
|
||||
}
|
||||
|
||||
/**
|
||||
* shareQuery returns the in-flight answer for an identical call made inside the
|
||||
* window, or runs it. A rejection is never shared beyond its own settlement:
|
||||
* the next caller retries rather than inheriting a stale failure. Callers that
|
||||
* cross an external state boundary must explicitly invalidate that query.
|
||||
*/
|
||||
export function shareQuery<T>(method: string, args: unknown[], run: () => Promise<T>): Promise<T> {
|
||||
const key = `${method}|${safeKey(args)}`;
|
||||
const at = now();
|
||||
const hit = inflight.get(key);
|
||||
if (hit && at - hit.at < WINDOW_MS) return hit.promise as Promise<T>;
|
||||
const promise = run();
|
||||
inflight.set(key, { promise, at });
|
||||
void promise.then(
|
||||
() => {
|
||||
if (method === "ListTabs") {
|
||||
forget(key, promise);
|
||||
return;
|
||||
}
|
||||
// A settled answer stays shareable for the rest of the window: the
|
||||
// duplicates this exists for arrive within a few milliseconds of it.
|
||||
setTimeout(() => forget(key, promise), WINDOW_MS);
|
||||
},
|
||||
() => forget(key, promise),
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function forget(key: string, promise: Promise<unknown>): void {
|
||||
if (inflight.get(key)?.promise === promise) inflight.delete(key);
|
||||
}
|
||||
|
||||
/** Arguments are identifiers and flags; anything unserialisable disables the share. */
|
||||
function safeKey(args: unknown[]): string {
|
||||
try {
|
||||
return JSON.stringify(args);
|
||||
} catch {
|
||||
return `\u0000${Math.random()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a settled burst answer at an external state boundary (for example turn_done). */
|
||||
export function invalidateSharedQuery(method: string, args: unknown[]): void {
|
||||
inflight.delete(`${method}|${safeKey(args)}`);
|
||||
}
|
||||
|
||||
/** Test seam: forget everything remembered so far. */
|
||||
export function resetQueryCoalescing(): void {
|
||||
inflight.clear();
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// One event channel carries every kind; `kind` discriminates the payload.
|
||||
|
||||
import type { Todo } from "./tools";
|
||||
import type { ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes";
|
||||
export type { ContextMaintenanceInfo, ContextMaintenanceReceipt, WireContextMaintenance } from "./contextMaintenanceTypes";
|
||||
|
||||
export type EventKind =
|
||||
| "turn_started"
|
||||
@@ -25,7 +27,8 @@ export type EventKind =
|
||||
| "guardian_assessment"
|
||||
| "extension_surface"
|
||||
| "extension_status"
|
||||
| "stream_attempt";
|
||||
| "stream_attempt"
|
||||
| "context_maintenance";
|
||||
|
||||
export type StreamAttemptAction = "begin" | "discard" | "commit";
|
||||
|
||||
@@ -292,6 +295,7 @@ export interface WireEvent {
|
||||
approval?: WireApproval;
|
||||
ask?: WireAsk;
|
||||
compaction?: WireCompaction;
|
||||
maintenance?: WireContextMaintenance;
|
||||
guardian?: WireGuardian;
|
||||
decisionReceipt?: WireDecisionReceipt;
|
||||
extension?: WireExtensionSurface;
|
||||
@@ -789,6 +793,7 @@ export interface ContextInfo {
|
||||
cacheMissTokens?: number;
|
||||
estimated?: boolean;
|
||||
sources?: Record<string, UsageSourceStats>;
|
||||
maintenance?: ContextMaintenanceInfo;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
@@ -2128,7 +2133,7 @@ export interface ExternalOpenerView {
|
||||
|
||||
export interface ExternalOpenersView {
|
||||
openers: ExternalOpenerView[];
|
||||
preferred: string;
|
||||
preferred: string; workspaceOpenable?: boolean;
|
||||
}
|
||||
|
||||
// Auto-updater payloads (desktop/updater.go). UpdateInfo drives the update banner;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { CSS_EASE_OUT, DUR_BASE, prefersReducedMotion } from "./motion";
|
||||
|
||||
/**
|
||||
* Animates a container's height between 0 and its scrollHeight whenever
|
||||
* `open` flips. The visual transition always fails open: an unavailable or
|
||||
* rejecting Web Animations implementation must still apply the requested
|
||||
* final state and completion callback.
|
||||
*/
|
||||
export function useCollapseAnimation(
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
opts?: {
|
||||
duration?: number;
|
||||
/** Called after the open animation completes or falls back. */
|
||||
onOpenComplete?: () => void;
|
||||
/** Called after the close animation completes or falls back. */
|
||||
onCloseComplete?: () => void;
|
||||
/** When closing, use this height as the starting point instead of
|
||||
* measuring scrollHeight after content has changed. */
|
||||
prevHeight?: number;
|
||||
},
|
||||
) {
|
||||
const prevOpen = useRef<boolean | null>(null);
|
||||
const onOpenRef = useRef(opts?.onOpenComplete);
|
||||
const onCloseRef = useRef(opts?.onCloseComplete);
|
||||
onOpenRef.current = opts?.onOpenComplete;
|
||||
onCloseRef.current = opts?.onCloseComplete;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Initial history can contain hundreds of collapsed rows. Seed the final
|
||||
// state directly before paint instead of constructing animations on mount.
|
||||
if (prevOpen.current === null) {
|
||||
prevOpen.current = open;
|
||||
el.style.height = open ? "auto" : "0px";
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevOpen.current === open) return;
|
||||
prevOpen.current = open;
|
||||
|
||||
const configuredDuration = opts?.duration ?? DUR_BASE;
|
||||
const duration = prefersReducedMotion()
|
||||
? 0
|
||||
: (Number.isFinite(configuredDuration) && configuredDuration >= 0 ? configuredDuration : DUR_BASE) * 1000;
|
||||
let superseded = false;
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (superseded || settled) return;
|
||||
settled = true;
|
||||
el.style.height = open ? "auto" : "0px";
|
||||
if (open) onOpenRef.current?.();
|
||||
else onCloseRef.current?.();
|
||||
};
|
||||
|
||||
if (duration === 0 || typeof el.animate !== "function") {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const keyframes = open
|
||||
? [{ height: "0px" }, { height: `${el.scrollHeight}px` }]
|
||||
: [
|
||||
{
|
||||
height: `${opts?.prevHeight && opts.prevHeight > 0
|
||||
? opts.prevHeight
|
||||
: Math.max(el.getBoundingClientRect().height, el.scrollHeight)}px`,
|
||||
},
|
||||
{ height: "0px" },
|
||||
];
|
||||
|
||||
let animation: Animation;
|
||||
try {
|
||||
animation = el.animate(keyframes, { duration, easing: CSS_EASE_OUT });
|
||||
} catch {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
animation.onfinish = finish;
|
||||
animation.oncancel = finish;
|
||||
return () => {
|
||||
// A dependency change supersedes this direction. Suppress the old final
|
||||
// state/callback before cancellation so the next effect owns settlement.
|
||||
superseded = true;
|
||||
try {
|
||||
animation.cancel();
|
||||
} catch {
|
||||
// Cancellation is cleanup-only; the next render already owns state.
|
||||
}
|
||||
};
|
||||
}, [open, ref]);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { asArray } from "./array";
|
||||
import { addBreadcrumb } from "./breadcrumbs";
|
||||
import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge";
|
||||
import { invalidateCache } from "./composerHistory";
|
||||
import { formatContextMaintenanceNotice } from "./contextMaintenanceTypes";
|
||||
import { formatGuardianAssessmentNotice } from "./guardianEvents";
|
||||
import { invalidateSharedQuery } from "./queryCoalesce";
|
||||
import { createRafBatch } from "./rafBatch";
|
||||
import { aliasActivationRequest, noteActivationRequested, noteActivationSettled, noteActivationStarted } from "./sessionDiagnostics";
|
||||
import { applyLiveSegments, coalesceStreamDeltas, completeLiveReasoning, type StreamDeltaEntry, type StreamSegment } from "./streamDeltaBatch";
|
||||
@@ -414,6 +416,9 @@ interface State {
|
||||
lastTurnWaitAccumMs: number;
|
||||
lastTurnModelMs: number;
|
||||
lastTurnOutputEstimated: boolean;
|
||||
// Per-request rate (null when unmeasurable) and pending interval are tab-local.
|
||||
lastRequestTps?: number | null;
|
||||
pendingRequestModelMs?: number;
|
||||
promptWaitStartedAt?: number;
|
||||
// promptEventClock() reading taken when the CURRENT pending prompt first
|
||||
// arrived. Orders the prompt against reconciliation snapshots so a snapshot
|
||||
@@ -1126,7 +1131,7 @@ function endPromptWaitIfIdle(s: State, now = Date.now()): State {
|
||||
return endPromptWait(s, now);
|
||||
}
|
||||
|
||||
function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnOutputTokens" | "turnOutputChars" | "turnOutputCharsAtUsage" | "turnOutputEstimated" | "turnModelActiveAt" | "turnModelActiveMs" | "turnCost" | "turnArgChars"> {
|
||||
function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnOutputTokens" | "turnOutputChars" | "turnOutputCharsAtUsage" | "turnOutputEstimated" | "turnModelActiveAt" | "turnModelActiveMs" | "turnCost" | "turnArgChars" | "pendingRequestModelMs"> {
|
||||
return {
|
||||
turnStartAt: now,
|
||||
turnDoneAt: 0,
|
||||
@@ -1139,7 +1144,7 @@ function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDon
|
||||
turnOutputCharsAtUsage: 0,
|
||||
turnOutputEstimated: false,
|
||||
turnModelActiveAt: undefined,
|
||||
turnModelActiveMs: 0,
|
||||
turnModelActiveMs: 0, pendingRequestModelMs: undefined,
|
||||
turnCost: 0,
|
||||
turnArgChars: 0,
|
||||
};
|
||||
@@ -1156,13 +1161,11 @@ function beginTurnModelActivity(s: State, now = Date.now()): State {
|
||||
: { ...s, turnModelActiveAt: now };
|
||||
}
|
||||
|
||||
function endTurnModelActivity(s: State, now = Date.now()): State {
|
||||
function endTurnModelActivity(s: State, now = Date.now(), stashForUsage = false): State {
|
||||
if (!s.turnModelActiveAt || s.turnModelActiveAt <= 0) return s;
|
||||
return {
|
||||
...s,
|
||||
turnModelActiveAt: undefined,
|
||||
turnModelActiveMs: Math.max(0, s.turnModelActiveMs) + Math.max(0, now - s.turnModelActiveAt),
|
||||
};
|
||||
const closedMs = Math.max(0, now - s.turnModelActiveAt);
|
||||
return { ...s, turnModelActiveAt: undefined, turnModelActiveMs: Math.max(0, s.turnModelActiveMs) + closedMs,
|
||||
pendingRequestModelMs: stashForUsage ? closedMs : s.pendingRequestModelMs };
|
||||
}
|
||||
|
||||
function snapshotCompletedTurnTelemetry(s: State, now = Date.now()): State {
|
||||
@@ -1485,10 +1488,10 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
existingAssistant && existingAssistant.text.trim() === "" && existingAssistant.reasoning.trim() === "" && !existingAssistant.memoryCitations?.length
|
||||
? s.items.filter((it) => !(it.kind === "assistant" && it.id === existingAssistant.id))
|
||||
: s.items;
|
||||
return { ...endTurnModelActivity(s), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
|
||||
return { ...endTurnModelActivity(s, Date.now(), true), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
|
||||
}
|
||||
const now = Date.now();
|
||||
const settled = endTurnModelActivity(s, now);
|
||||
const settled = endTurnModelActivity(s, now, true);
|
||||
const { items, id, seq } = ensureAssistant(settled);
|
||||
const streamedChars = settled.live?.id === id ? settled.live.text.length + settled.live.reasoning.length : 0;
|
||||
const turnOutputChars = Math.max(0, settled.turnOutputChars - streamedChars + text.length + reasoning.length);
|
||||
@@ -1552,7 +1555,7 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
items: [...activeState.items, { kind: "tool", id, name: t.name, args: "", readOnly: t.readOnly, resolvedName: t.resolvedName, capabilityId: t.capabilityId, status: "running", argChars: t.argChars || undefined, parentId: t.parentId, subagentProgress: SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined }],
|
||||
}, id, false, undefined, { attemptId: t.attemptId, parentId: t.parentId, partial: true });
|
||||
}
|
||||
const settled = t.parentId ? s : endTurnModelActivity(s);
|
||||
const settled = t.parentId ? s : endTurnModelActivity(s, Date.now(), true);
|
||||
const id = t.id || `tool${s.seq}`;
|
||||
const idx = settled.items.findIndex((it) => it.kind === "tool" && it.id === id);
|
||||
if (idx >= 0) {
|
||||
@@ -1661,18 +1664,16 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
// Only executor usage belongs to the foreground model stream. Planner,
|
||||
// subagent, and auxiliary usage still contributes to session totals and
|
||||
// usageSeq, but must not close or inflate the executor TPS interval.
|
||||
const settled = updateContextGauge ? endTurnModelActivity(s) : s;
|
||||
// Prefer Context* (latest attempt) over billable aggregates when multi-
|
||||
// attempt sampling recovery folds several provider calls into one Usage.
|
||||
// Matches Controller.ContextSnapshot: latest prompt + completion.
|
||||
const settled = updateContextGauge ? endTurnModelActivity(s, Date.now(), true) : s;
|
||||
const hasRequestContext = (e.usage?.contextPromptTokens ?? 0) > 0 || (e.usage?.contextCompletionTokens ?? 0) > 0;
|
||||
const requestModelMs = updateContextGauge ? (settled.pendingRequestModelMs ?? 0) : 0;
|
||||
const requestTokens = updateContextGauge ? (hasRequestContext ? (e.usage?.contextCompletionTokens ?? 0) : (e.usage?.completionTokens ?? 0)) : 0;
|
||||
const lastRequestTps = updateContextGauge ? (requestTokens > 0 && requestModelMs >= 500 ? requestTokens / (requestModelMs / 1000) : null) : s.lastRequestTps;
|
||||
// Context* is the latest sampling attempt; other token fields are billable aggregates.
|
||||
let used = settled.context.used;
|
||||
if (e.usage && settled.context.window && updateContextGauge) {
|
||||
const hasContext =
|
||||
(e.usage.contextPromptTokens ?? 0) > 0 || (e.usage.contextCompletionTokens ?? 0) > 0;
|
||||
used = hasContext
|
||||
? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
|
||||
: (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
|
||||
}
|
||||
if (e.usage && settled.context.window && updateContextGauge) used = hasRequestContext
|
||||
? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
|
||||
: (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
|
||||
const turnTokens = settled.turnTokens + (e.usage?.completionTokens ?? 0);
|
||||
const turnOutputTokens = updateContextGauge
|
||||
? settled.turnOutputTokens + (e.usage?.completionTokens ?? 0)
|
||||
@@ -1693,10 +1694,15 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
const usage = updateContextGauge ? e.usage : settled.usage;
|
||||
// The completed round's usage now accounts for the streamed tool-call
|
||||
// arguments, so drop the live estimate rather than double-count it.
|
||||
return { ...settled, usage, context: { ...settled.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnOutputCharsAtUsage, turnOutputEstimated, turnTotalTokens, turnCost, turnArgChars: updateContextGauge ? 0 : settled.turnArgChars, sessionTokens, sessionCost, sessionCurrency, usageSeq: settled.usageSeq + 1 };
|
||||
return { ...settled, usage, context: { ...settled.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnOutputCharsAtUsage, turnOutputEstimated, turnTotalTokens, turnCost, turnArgChars: updateContextGauge ? 0 : settled.turnArgChars, sessionTokens, sessionCost, sessionCurrency, usageSeq: settled.usageSeq + 1, lastRequestTps, pendingRequestModelMs: updateContextGauge ? undefined : settled.pendingRequestModelMs };
|
||||
}
|
||||
case "notice":
|
||||
return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt);
|
||||
case "context_maintenance": {
|
||||
const m = e.maintenance;
|
||||
if (!m || m.status === "noop") return s;
|
||||
return appendNoticeToState(s, m.status === "failed" ? "warn" : "info", formatContextMaintenanceNotice(m, t), m.reason);
|
||||
}
|
||||
case "phase":
|
||||
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
|
||||
case "compaction_started":
|
||||
@@ -2267,6 +2273,7 @@ const noticeCodeKeys: Record<string, DictKey> = {
|
||||
session_recovery_depth_cap: "recovery.noticeKeptCurrent",
|
||||
session_shutdown_recovery_forked: "recovery.noticeSavedCopy",
|
||||
decision_receipt: "notice.decisionReceiptTitle",
|
||||
context_editing_fallback: "notice.contextEditingFallback",
|
||||
};
|
||||
|
||||
// localizedNoticeText localizes a notice's main copy by its stable code first,
|
||||
@@ -2665,8 +2672,9 @@ export function useController() {
|
||||
if (!streamDeltaOnly) bump();
|
||||
}
|
||||
}, [bump, notifyLiveListeners]);
|
||||
|
||||
const clearBalanceForTab = useCallback((tabId: string): void => {
|
||||
invalidateSharedQuery("BalanceForTab", [tabId]);
|
||||
invalidateSharedQuery("MetaForTab", [tabId]);
|
||||
const seq = (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1;
|
||||
balanceRefreshSeqByTab.current.set(tabId, seq);
|
||||
dispatchTo(tabId, { type: "balance", balance: { available: false, display: "" } });
|
||||
@@ -2765,10 +2773,8 @@ export function useController() {
|
||||
return metaRefreshSeq.current.get(tabId) === seq;
|
||||
}, []);
|
||||
const bumpSessionLoadSeq = useCallback((tabId: string): number => {
|
||||
// A session transition changes the meaning of every tab-scoped Meta field.
|
||||
// Invalidate requests that started against the previous session before
|
||||
// resetting or hydrating the visible state.
|
||||
bumpMetaRefreshSeq(tabId);
|
||||
invalidateSharedQuery("MetaForTab", [tabId]);
|
||||
const seq = (sessionLoadSeq.current.get(tabId) ?? 0) + 1;
|
||||
sessionLoadSeq.current.set(tabId, seq);
|
||||
return seq;
|
||||
@@ -2814,8 +2820,9 @@ export function useController() {
|
||||
}, [dispatchTo, loadMetaForTab]);
|
||||
const refreshMetaForTab = useCallback(async (tabId: string): Promise<void> => {
|
||||
const sessionSeq = sessionLoadSeq.current.get(tabId) ?? 0;
|
||||
const meta = await refreshMetaOnlyForTab(tabId);
|
||||
const meta = await loadMetaForTab(tabId);
|
||||
if (meta === undefined || (sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
|
||||
dispatchTo(tabId, { type: "meta", meta });
|
||||
const [context, effort] = await Promise.all([
|
||||
app.ContextUsageForTab(tabId).catch(() => undefined),
|
||||
app.EffortForTab(tabId).catch(() => undefined),
|
||||
@@ -2823,7 +2830,7 @@ export function useController() {
|
||||
if ((sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
|
||||
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
|
||||
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
|
||||
}, [dispatchTo, refreshMetaOnlyForTab]);
|
||||
}, [dispatchTo, loadMetaForTab]);
|
||||
const bumpCheckpointRefreshSeq = useCallback((tabId: string): number => {
|
||||
const seq = (checkpointRefreshSeq.current.get(tabId) ?? 0) + 1;
|
||||
checkpointRefreshSeq.current.set(tabId, seq);
|
||||
@@ -3368,14 +3375,15 @@ export function useController() {
|
||||
textBatch.drain();
|
||||
dispatchTo(targetTabId, { type: "event", e });
|
||||
}
|
||||
if (e.kind === "turn_done" || e.kind === "context_maintenance") {
|
||||
void app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {});
|
||||
}
|
||||
if (e.kind === "turn_done") {
|
||||
app
|
||||
.ContextUsageForTab(targetTabId)
|
||||
.then((context) => dispatchTo(targetTabId, { type: "context", context }))
|
||||
.catch(() => {});
|
||||
invalidateSharedQuery("BalanceForTab", [targetTabId]);
|
||||
void refreshBalanceForTab(targetTabId);
|
||||
app.EffortForTab(targetTabId).then((effort) => dispatchTo(targetTabId, { type: "effort", effort })).catch(() => {});
|
||||
void refreshCheckpoints(targetTabId);
|
||||
invalidateSharedQuery("MetaForTab", [targetTabId]);
|
||||
void refreshMetaForTab(targetTabId);
|
||||
}
|
||||
if (e.kind === "turn_done" || e.kind === "notice") {
|
||||
@@ -3401,18 +3409,20 @@ export function useController() {
|
||||
// rebuild (settings-wide) affects every known tab.
|
||||
const offRebuilt = onRuntimeRebuilt((rebuiltTabId, runtimeEpoch) => {
|
||||
if (rebuiltTabId) {
|
||||
invalidateSharedQuery("MetaForTab", [rebuiltTabId]);
|
||||
if (runtimeEpoch) runtimeEpochByTabRef.current.set(rebuiltTabId, runtimeEpoch);
|
||||
dispatchTo(rebuiltTabId, { type: "controller_rebuilt" });
|
||||
} else {
|
||||
if (runtimeEpoch) {
|
||||
for (const id of Array.from(statesRef.current.keys())) runtimeEpochByTabRef.current.set(id, runtimeEpoch);
|
||||
}
|
||||
for (const id of Array.from(statesRef.current.keys())) dispatchTo(id, { type: "controller_rebuilt" });
|
||||
for (const id of Array.from(statesRef.current.keys())) {
|
||||
invalidateSharedQuery("MetaForTab", [id]);
|
||||
dispatchTo(id, { type: "controller_rebuilt" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const offTopicActivation = onTopicActivation(handleTopicActivationEvent);
|
||||
|
||||
// tab:meta carries a full refreshed Meta after the backend's background
|
||||
// refresh of the expensive fields (git branch, image-input capability) —
|
||||
// those arrive empty in the first MetaForTab response now. Merge it like a
|
||||
@@ -3437,7 +3447,6 @@ export function useController() {
|
||||
// otherwise a session left mid-confirmation shows "waiting" with no modal
|
||||
// and no way to stop (#3844).
|
||||
void app.ReplayPendingPrompts().catch(() => {});
|
||||
|
||||
return () => {
|
||||
textBatch.drain();
|
||||
for (const timer of cancelReconcileTimers.current.values()) {
|
||||
@@ -4087,9 +4096,9 @@ export function useController() {
|
||||
const restoreSession = useCallback((path: string) => app.RestoreSession(path).catch(() => {}).finally(() => invalidateCache()), []);
|
||||
const purgeTrashedSession = useCallback((path: string) => app.PurgeTrashedSession(path).catch(() => {}).finally(() => invalidateCache()), []);
|
||||
const renameSession = useCallback((path: string, title: string) => app.RenameSession(path, title).catch(() => {}).finally(() => invalidateCache()), []);
|
||||
|
||||
const refreshMeta = useCallback(async () => {
|
||||
if (!activeTabId) return;
|
||||
invalidateSharedQuery("MetaForTab", [activeTabId]);
|
||||
await refreshMetaForTab(activeTabId);
|
||||
}, [activeTabId, refreshMetaForTab]);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DUR_SLOW, prefersReducedMotion } from "./gsapAnimations";
|
||||
|
||||
const CSS_EASE_OUT = "cubic-bezier(0.2, 0.72, 0.2, 1)";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { CSS_EASE_OUT, DUR_SLOW, prefersReducedMotion } from "./motion";
|
||||
|
||||
// Animates each data-entrance element in once. First mount (and every
|
||||
// resetKey change) pre-seeds the seen set so restored history never animates;
|
||||
@@ -10,9 +8,18 @@ export function useEntranceAnimation<T extends HTMLElement>(
|
||||
resetKey?: unknown,
|
||||
deps?: unknown,
|
||||
selector = "[data-entrance]",
|
||||
seedIds: readonly string[] = [],
|
||||
) {
|
||||
const ref = useRef<T | null>(null);
|
||||
// Virtualized history rows may mount after the first DOM scan. Seed their
|
||||
// model IDs up front so a later append never mistakes restored rows for new
|
||||
// content and animates the whole viewport.
|
||||
const seen = useRef(new Set<string>());
|
||||
const seeded = useRef(false);
|
||||
if (!seeded.current) {
|
||||
seen.current = new Set(seedIds);
|
||||
seeded.current = true;
|
||||
}
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const timerAnimations = useRef<Animation[]>([]);
|
||||
const firstRun = useRef(true);
|
||||
@@ -21,7 +28,7 @@ export function useEntranceAnimation<T extends HTMLElement>(
|
||||
// Reset on session switch.
|
||||
if (prevResetKey.current !== resetKey) {
|
||||
prevResetKey.current = resetKey;
|
||||
seen.current = new Set();
|
||||
seen.current = new Set(seedIds);
|
||||
firstRun.current = true;
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -68,26 +75,35 @@ export function useEntranceAnimation<T extends HTMLElement>(
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
const animations = entries.map((entry, index) => {
|
||||
if (typeof entry.animate !== "function") {
|
||||
entry.style.opacity = "1";
|
||||
entry.style.transform = "";
|
||||
return null;
|
||||
}
|
||||
const animation = entry.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(12px)" },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
],
|
||||
{
|
||||
duration: DUR_SLOW * 1000,
|
||||
easing: CSS_EASE_OUT,
|
||||
delay: index * itemsStagger(entries.length) * 1000,
|
||||
},
|
||||
);
|
||||
animation.onfinish = () => {
|
||||
const settle = () => {
|
||||
entry.style.opacity = "1";
|
||||
entry.style.transform = "";
|
||||
};
|
||||
if (typeof entry.animate !== "function") {
|
||||
settle();
|
||||
return null;
|
||||
}
|
||||
let animation: Animation;
|
||||
try {
|
||||
animation = entry.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(12px)" },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
],
|
||||
{
|
||||
duration: DUR_SLOW * 1000,
|
||||
easing: CSS_EASE_OUT,
|
||||
delay: index * itemsStagger(entries.length) * 1000,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Entrance motion is cosmetic. Keep later entries running and expose
|
||||
// this entry immediately if a WebView rejects the animation.
|
||||
settle();
|
||||
return null;
|
||||
}
|
||||
animation.onfinish = settle;
|
||||
animation.oncancel = settle;
|
||||
return animation;
|
||||
});
|
||||
timerAnimations.current = animations.filter((animation): animation is Animation => animation !== null);
|
||||
@@ -95,7 +111,13 @@ export function useEntranceAnimation<T extends HTMLElement>(
|
||||
|
||||
return () => {
|
||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||
for (const animation of timerAnimations.current) animation.cancel();
|
||||
for (const animation of timerAnimations.current) {
|
||||
try {
|
||||
animation.cancel();
|
||||
} catch {
|
||||
// Cancellation is cleanup-only; each entry already has a final style.
|
||||
}
|
||||
}
|
||||
timerAnimations.current = [];
|
||||
};
|
||||
// Only re-scan when deps change — NOT on every render.
|
||||
@@ -104,6 +126,18 @@ export function useEntranceAnimation<T extends HTMLElement>(
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function useTranscriptEntranceAnimation<T extends HTMLElement>(
|
||||
tabId: string | undefined,
|
||||
revealSignal: unknown,
|
||||
items: readonly { id: string }[],
|
||||
) {
|
||||
const seedIds = useMemo(() => items.map((item) => item.id), [items]);
|
||||
// A tail append preserves this key; a surface switch, reveal, or history
|
||||
// prepend resets it and pre-seeds every model ID before virtual rows mount.
|
||||
const resetKey = `${tabId ?? ""}|${String(revealSignal)}|${items[0]?.id ?? ""}`;
|
||||
return useEntranceAnimation<T>(resetKey, items.length, "[data-entrance]", seedIds);
|
||||
}
|
||||
|
||||
function itemsStagger(count: number): number {
|
||||
if (count <= 1) return 0;
|
||||
if (count <= 3) return 0.06;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { DUR_BASE, prefersReducedMotion } from "./gsapAnimations";
|
||||
|
||||
const CSS_EASE_OUT = "cubic-bezier(0.2, 0.72, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* useGSAPCollapse — animate a container's height between 0 and its
|
||||
* scrollHeight whenever `open` flips. Replaces the old CSS max-height
|
||||
* hack with a precise pixel-level GSAP tween.
|
||||
*
|
||||
* Usage:
|
||||
* const ref = useRef<HTMLDivElement>(null);
|
||||
* useGSAPCollapse(ref, open);
|
||||
* return <div ref={ref}>{children}</div>;
|
||||
*
|
||||
* The container should have `overflow: hidden` in CSS. No extra wrapper
|
||||
* elements needed.
|
||||
*/
|
||||
export function useGSAPCollapse(
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
opts?: {
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
/** Called after the open animation completes. */
|
||||
onOpenComplete?: () => void;
|
||||
/** Called after the close animation completes. */
|
||||
onCloseComplete?: () => void;
|
||||
/** When closing, use this height as the starting point instead of
|
||||
* measuring scrollHeight (which may have already shrunk due to
|
||||
* content being conditionally removed). */
|
||||
prevHeight?: number;
|
||||
},
|
||||
) {
|
||||
const prevOpen = useRef<boolean | null>(null);
|
||||
const animationRef = useRef<Animation | null>(null);
|
||||
const onOpenRef = useRef(opts?.onOpenComplete);
|
||||
const onCloseRef = useRef(opts?.onCloseComplete);
|
||||
onOpenRef.current = opts?.onOpenComplete;
|
||||
onCloseRef.current = opts?.onCloseComplete;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Skip the very first render — we don't want to animate from 0→auto
|
||||
// on mount. Use a direct style write (no GSAP overhead) for the
|
||||
// initial state so 400+ collapsed items don't each go through
|
||||
// gsap.set property resolution.
|
||||
if (prevOpen.current === null) {
|
||||
prevOpen.current = open;
|
||||
el.style.height = open ? "auto" : "0px";
|
||||
return;
|
||||
}
|
||||
|
||||
// No change — nothing to do.
|
||||
if (prevOpen.current === open) return;
|
||||
prevOpen.current = open;
|
||||
|
||||
const reduced = prefersReducedMotion();
|
||||
const dur = reduced ? 0.001 : (opts?.duration ?? DUR_BASE);
|
||||
const ease = opts?.ease && opts.ease !== "power2.out" ? opts.ease : CSS_EASE_OUT;
|
||||
animationRef.current?.cancel();
|
||||
animationRef.current = null;
|
||||
|
||||
const finish = () => {
|
||||
el.style.height = open ? "auto" : "0px";
|
||||
if (open) onOpenRef.current?.();
|
||||
else onCloseRef.current?.();
|
||||
};
|
||||
if (reduced || typeof el.animate !== "function") {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
const targetHeight = el.scrollHeight;
|
||||
const animation = el.animate(
|
||||
[{ height: "0px" }, { height: `${targetHeight}px` }],
|
||||
{ duration: dur * 1000, easing: ease },
|
||||
);
|
||||
animationRef.current = animation;
|
||||
animation.onfinish = finish;
|
||||
} else {
|
||||
// Close: if caller provided a pre-swap height use it as the start,
|
||||
// otherwise measure the current (already-swapped) scrollHeight.
|
||||
const startHeight = opts?.prevHeight && opts.prevHeight > 0
|
||||
? opts.prevHeight
|
||||
: Math.max(el.getBoundingClientRect().height, el.scrollHeight);
|
||||
const animation = el.animate(
|
||||
[{ height: `${startHeight}px` }, { height: "0px" }],
|
||||
{ duration: dur * 1000, easing: ease },
|
||||
);
|
||||
animationRef.current = animation;
|
||||
animation.onfinish = finish;
|
||||
}
|
||||
return () => animationRef.current?.cancel();
|
||||
}, [open, ref]);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { prefersReducedMotion } from "./motion";
|
||||
|
||||
// useMountTransition keeps a conditionally-rendered overlay mounted long enough
|
||||
// to play an exit animation. Callers flip `open`; the hook returns whether the
|
||||
@@ -16,11 +17,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
// global reduced-motion rule that zeroes every animation.
|
||||
export type MountStatus = "open" | "closing";
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
export function useMountTransition(
|
||||
open: boolean,
|
||||
duration: number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, TouchEvent as ReactTouchEvent, WheelEvent as ReactWheelEvent } from "react";
|
||||
import { DUR_FAST, prefersReducedMotion } from "./gsapAnimations";
|
||||
import { DUR_FAST, prefersReducedMotion } from "./motion";
|
||||
import { isEditableTarget } from "./keyboardShortcuts";
|
||||
|
||||
const BOTTOM_THRESHOLD_PX = 80;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Copy } from "lucide-react";
|
||||
import type { FloatingMenuItem } from "../components/FloatingMenu";
|
||||
import { writeClipboardText } from "./clipboard";
|
||||
|
||||
export const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 204;
|
||||
export const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 160;
|
||||
|
||||
export function workspacePathCopyMenuItems({
|
||||
path,
|
||||
resolveAbsolutePath,
|
||||
isScopeCurrent,
|
||||
close,
|
||||
relativeLabel,
|
||||
absoluteLabel,
|
||||
}: {
|
||||
path: string;
|
||||
resolveAbsolutePath: () => Promise<string>;
|
||||
isScopeCurrent: () => boolean;
|
||||
close: () => void;
|
||||
relativeLabel: string;
|
||||
absoluteLabel: string;
|
||||
}): FloatingMenuItem[] {
|
||||
return [
|
||||
{
|
||||
icon: <Copy size={14} />,
|
||||
label: relativeLabel,
|
||||
onSelect: () => {
|
||||
const relativePath = path.replace(/\/+$/, "");
|
||||
close();
|
||||
if (relativePath) void writeClipboardText(relativePath);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <Copy size={14} />,
|
||||
label: absoluteLabel,
|
||||
onSelect: () => {
|
||||
close();
|
||||
void resolveAbsolutePath()
|
||||
.then((absolutePath) => {
|
||||
if (absolutePath && isScopeCurrent()) return writeClipboardText(absolutePath);
|
||||
})
|
||||
.catch(() => {
|
||||
// The path may disappear between opening the menu and selecting it.
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -255,6 +255,8 @@ export const en = {
|
||||
"workspace.addFileReferenceToChat": "Add file reference",
|
||||
"workspace.addFolderReferenceToChat": "Add folder reference",
|
||||
"workspace.addFileContentToChat": "Add file contents",
|
||||
"workspace.copyRelativePath": "Copy relative path",
|
||||
"workspace.copyAbsolutePath": "Copy absolute path",
|
||||
"workspace.viewMode": "Workspace view",
|
||||
"workspace.filesTab": "Files",
|
||||
"workspace.changedTab": "Changes",
|
||||
@@ -2876,6 +2878,7 @@ export const en = {
|
||||
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
|
||||
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
|
||||
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
|
||||
"notice.contextEditingFallback": "Using local context maintenance.",
|
||||
"questionNav.label": "Question navigation",
|
||||
"questionNav.progress": "Question {current} / {total}",
|
||||
"questionNav.jump": "Jump to question {n}",
|
||||
@@ -3052,6 +3055,18 @@ export const en = {
|
||||
"context.windowUsed": "Used context",
|
||||
"context.windowCompactThreshold": "Compression threshold",
|
||||
"context.windowCompactHeadroom": "Before compression",
|
||||
"context.maintenanceTitle": "Current context composition",
|
||||
"context.maintenanceProjected": "Projected context",
|
||||
"context.maintenanceLastSaved": "Last tool saving",
|
||||
"context.maintenanceHeadroom": "Hard-limit headroom",
|
||||
"context.maintenanceLastAction": "Last maintenance",
|
||||
"context.maintenanceSnipAt": "Tool cleanup at",
|
||||
"context.maintenanceFoldAt": "Summary at",
|
||||
"context.maintenanceForceAt": "Force convergence at",
|
||||
"context.maintenanceHardAt": "Hard input limit",
|
||||
"context.maintenanceActionSnip": "Tool result snip",
|
||||
"context.maintenanceActionPrune": "Tool result prune",
|
||||
"context.maintenanceActionNative": "Native tool clearing",
|
||||
"context.sessionMetrics": "Session metrics",
|
||||
"context.runtimeMetrics": "Runtime",
|
||||
"context.costMetrics": "Cost",
|
||||
@@ -3095,7 +3110,7 @@ export const en = {
|
||||
"context.sourceExecutor": "Main",
|
||||
"context.sourcePlanner": "Planner",
|
||||
"context.sourceSubagent": "Subagent",
|
||||
"context.sourceCompaction": "Compaction",
|
||||
"context.sourceCompaction": "Summary call cost",
|
||||
"context.sourceClassifier": "Classifier",
|
||||
"context.sourceTitle": "Title",
|
||||
"context.sourceRequests": "{count} req",
|
||||
|
||||
@@ -175,6 +175,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"workspace.addFileReferenceToChat": "新增檔案引用",
|
||||
"workspace.addFolderReferenceToChat": "新增資料夾引用",
|
||||
"workspace.addFileContentToChat": "新增檔案內容",
|
||||
"workspace.copyRelativePath": "複製相對路徑",
|
||||
"workspace.copyAbsolutePath": "複製絕對路徑",
|
||||
"workspace.viewMode": "工作區視圖",
|
||||
"workspace.filesTab": "檔案",
|
||||
"workspace.changedTab": "變更",
|
||||
@@ -1989,6 +1991,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
|
||||
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
|
||||
"notice.contextEditingFallback": "改用本機維護。",
|
||||
"questionNav.label": "問題導航",
|
||||
"questionNav.progress": "問題 {current} / {total}",
|
||||
"questionNav.jump": "跳轉到問題 {n}",
|
||||
@@ -3138,6 +3141,18 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"context.windowUsed": "已用上下文",
|
||||
"context.windowCompactThreshold": "壓縮閾值",
|
||||
"context.windowCompactHeadroom": "壓縮前餘量",
|
||||
"context.maintenanceTitle": "上下文構成",
|
||||
"context.maintenanceProjected": "傳送視圖",
|
||||
"context.maintenanceLastSaved": "裁剪節省",
|
||||
"context.maintenanceHeadroom": "硬限餘量",
|
||||
"context.maintenanceLastAction": "維護操作",
|
||||
"context.maintenanceSnipAt": "工具線",
|
||||
"context.maintenanceFoldAt": "摘要線",
|
||||
"context.maintenanceForceAt": "強制線",
|
||||
"context.maintenanceHardAt": "輸入上限",
|
||||
"context.maintenanceActionSnip": "裁短",
|
||||
"context.maintenanceActionPrune": "裁剪",
|
||||
"context.maintenanceActionNative": "原生清理",
|
||||
"context.sessionMetrics": "會話指標",
|
||||
"context.runtimeMetrics": "執行指標",
|
||||
"context.costMetrics": "成本",
|
||||
@@ -3159,7 +3174,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"context.sourceExecutor": "主模型",
|
||||
"context.sourcePlanner": "規劃器",
|
||||
"context.sourceSubagent": "子代理",
|
||||
"context.sourceCompaction": "壓縮摘要",
|
||||
"context.sourceCompaction": "摘要呼叫開銷",
|
||||
"context.sourceClassifier": "計畫分類",
|
||||
"context.sourceTitle": "標題產生",
|
||||
"context.sourceRequests": "{count} 次",
|
||||
|
||||
@@ -256,6 +256,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"workspace.addFileReferenceToChat": "添加文件引用",
|
||||
"workspace.addFolderReferenceToChat": "添加文件夹引用",
|
||||
"workspace.addFileContentToChat": "添加文件内容",
|
||||
"workspace.copyRelativePath": "复制相对路径",
|
||||
"workspace.copyAbsolutePath": "复制绝对路径",
|
||||
"workspace.viewMode": "工作区视图",
|
||||
"workspace.filesTab": "文件",
|
||||
"workspace.changedTab": "改动",
|
||||
@@ -2879,6 +2881,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
|
||||
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
|
||||
"notice.contextEditingFallback": "改用本地维护。",
|
||||
"questionNav.label": "问题导航",
|
||||
"questionNav.progress": "问题 {current} / {total}",
|
||||
"questionNav.jump": "跳转到问题 {n}",
|
||||
@@ -3055,6 +3058,18 @@ export const zh: Record<DictKey, string> = {
|
||||
"context.windowUsed": "已用上下文",
|
||||
"context.windowCompactThreshold": "压缩阈值",
|
||||
"context.windowCompactHeadroom": "压缩前余量",
|
||||
"context.maintenanceTitle": "上下文构成",
|
||||
"context.maintenanceProjected": "发送视图",
|
||||
"context.maintenanceLastSaved": "裁剪节省",
|
||||
"context.maintenanceHeadroom": "硬限余量",
|
||||
"context.maintenanceLastAction": "维护操作",
|
||||
"context.maintenanceSnipAt": "工具线",
|
||||
"context.maintenanceFoldAt": "摘要线",
|
||||
"context.maintenanceForceAt": "强制线",
|
||||
"context.maintenanceHardAt": "输入上限",
|
||||
"context.maintenanceActionSnip": "裁短",
|
||||
"context.maintenanceActionPrune": "裁剪",
|
||||
"context.maintenanceActionNative": "原生清理",
|
||||
"context.sessionMetrics": "会话指标",
|
||||
"context.runtimeMetrics": "运行指标",
|
||||
"context.costMetrics": "成本",
|
||||
@@ -3098,7 +3113,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"context.sourceExecutor": "主模型",
|
||||
"context.sourcePlanner": "规划器",
|
||||
"context.sourceSubagent": "子代理",
|
||||
"context.sourceCompaction": "压缩摘要",
|
||||
"context.sourceCompaction": "摘要调用开销",
|
||||
"context.sourceClassifier": "计划分类",
|
||||
"context.sourceTitle": "标题生成",
|
||||
"context.sourceRequests": "{count} 次",
|
||||
|
||||
@@ -3734,7 +3734,7 @@ a[href] {
|
||||
margin: 2px auto;
|
||||
}
|
||||
|
||||
/* expanded padding — parent --open class drives it while GSAP handles height */
|
||||
/* Expanded padding — the parent --open class drives it while native motion handles height. */
|
||||
.readonly-batch--open .readonly-batch__body,
|
||||
.turn-collapse--open .turn-collapse__body,
|
||||
.tool--open .tool__body {
|
||||
@@ -22948,11 +22948,11 @@ body > .mermaid-diagram--fullscreen {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 32px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
border-radius: 7px;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 1px 1px color-mix(in srgb, #000 9%, transparent);
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
@@ -22964,6 +22964,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
|
||||
.external-opener__primary-wrap {
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.external-opener__primary,
|
||||
@@ -22971,7 +22972,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 30px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -22980,13 +22981,13 @@ body > .mermaid-diagram--fullscreen {
|
||||
|
||||
.external-opener__primary {
|
||||
width: 38px;
|
||||
border-radius: 9px 0 0 9px;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.external-opener__menu-trigger {
|
||||
width: 27px;
|
||||
border-left: 1px solid var(--border-soft);
|
||||
border-radius: 0 9px 9px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.external-opener__primary:hover:not(:disabled),
|
||||
@@ -23010,8 +23011,8 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.external-opener__app-icon img {
|
||||
@@ -30016,9 +30017,16 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
}
|
||||
|
||||
.project-tree__topic-action--busy svg,
|
||||
.project-tree__archive-spinner {
|
||||
animation: project-tree-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar--workbench .project-tree__topic-state--thinking,
|
||||
.sidebar--workbench .project-tree__topic-state--streaming {
|
||||
.sidebar--workbench .project-tree__topic-state--streaming,
|
||||
.project-tree__topic-action--busy svg,
|
||||
.project-tree__archive-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -30776,7 +30784,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
:root[data-theme-style] .app--creation .topicbar__actions .external-opener {
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -37349,6 +37357,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 190px;
|
||||
min-height: 24px;
|
||||
gap: 4px;
|
||||
padding: 1px 7px;
|
||||
cursor: pointer;
|
||||
@@ -37356,15 +37365,33 @@ body > .mermaid-diagram--fullscreen {
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.statusbar__remote > span {
|
||||
.statusbar__remote-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.statusbar__remote-state-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: 0 0 7px;
|
||||
}
|
||||
.statusbar__remote-wrap {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.statusbar__remote--idle {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
justify-content: center;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
.statusbar__remote--idle .statusbar__remote-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.remote-switcher {
|
||||
z-index: var(--z-local-popover);
|
||||
width: min(390px, calc(100vw - 16px));
|
||||
|
||||
@@ -87,6 +87,9 @@ func repairExistingWindowsShortcuts(paths []string, launcher string, repair func
|
||||
}
|
||||
|
||||
func repairWindowsShortcut(shortcutPath, launcher string) (bool, error) {
|
||||
root := filepath.Dir(filepath.Clean(strings.TrimSpace(launcher)))
|
||||
versionedLayout := installlayout.HasCurrent(root)
|
||||
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
|
||||
@@ -130,7 +133,7 @@ func repairWindowsShortcut(shortcutPath, launcher string) (bool, error) {
|
||||
}
|
||||
iconLocation := iconValue.ToString()
|
||||
_ = iconValue.Clear()
|
||||
repointTarget, fixIcon := repairWindowsShortcutPlan(target, iconLocation, launcher)
|
||||
repointTarget, fixIcon := repairWindowsShortcutPlan(target, iconLocation, launcher, versionedLayout)
|
||||
if !repointTarget && !fixIcon {
|
||||
return false, nil
|
||||
}
|
||||
@@ -205,13 +208,32 @@ func reasonixWindowsVersionedTarget(target, launcher string) bool {
|
||||
|
||||
// repairWindowsShortcutPlan decides which owned-shortcut properties need
|
||||
// rewriting. repointTarget is true when TargetPath points into a versioned
|
||||
// directory the updater can delete; fixIcon is true when IconLocation points
|
||||
// at the versioned desktop binary instead of the stable launcher.
|
||||
func repairWindowsShortcutPlan(target, iconLocation, launcher string) (repointTarget, fixIcon bool) {
|
||||
return reasonixWindowsVersionedTarget(target, launcher), reasonixWindowsStaleIcon(iconLocation, launcher)
|
||||
// directory the updater can delete or at a legacy root-level desktop binary
|
||||
// after the versioned layout is active; fixIcon applies the same policy to
|
||||
// IconLocation. Flat installs keep their live root-level binary untouched.
|
||||
func repairWindowsShortcutPlan(target, iconLocation, launcher string, versionedLayout bool) (repointTarget, fixIcon bool) {
|
||||
repointTarget = reasonixWindowsVersionedTarget(target, launcher) ||
|
||||
reasonixWindowsFlatDesktopTarget(target, launcher, versionedLayout)
|
||||
return repointTarget, reasonixWindowsStaleIcon(iconLocation, launcher, versionedLayout)
|
||||
}
|
||||
|
||||
func reasonixWindowsStaleIcon(iconLocation, launcher string) bool {
|
||||
// reasonixWindowsFlatDesktopTarget reports whether target points at the legacy
|
||||
// root-level desktop binary after versioned layout activation or after the file
|
||||
// disappeared. A valid current.json is the commit point, so a leftover flat
|
||||
// binary is not a live flat install when best-effort cleanup could not remove it.
|
||||
func reasonixWindowsFlatDesktopTarget(target, launcher string, versionedLayout bool) bool {
|
||||
flat := filepath.Join(filepath.Dir(filepath.Clean(strings.TrimSpace(launcher))), "reasonix-desktop.exe")
|
||||
if !strings.EqualFold(filepath.Clean(strings.TrimSpace(target)), flat) {
|
||||
return false
|
||||
}
|
||||
if versionedLayout {
|
||||
return true
|
||||
}
|
||||
_, err := os.Lstat(flat)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func reasonixWindowsStaleIcon(iconLocation, launcher string, versionedLayout bool) bool {
|
||||
iconPath := strings.TrimSpace(iconLocation)
|
||||
if comma := strings.LastIndex(iconPath, ","); comma >= 0 {
|
||||
if _, err := strconv.Atoi(strings.TrimSpace(iconPath[comma+1:])); err == nil {
|
||||
@@ -228,8 +250,21 @@ func reasonixWindowsStaleIcon(iconLocation, launcher string) bool {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
return len(parts) == 3 && strings.EqualFold(parts[0], "versions") &&
|
||||
strings.EqualFold(parts[2], "reasonix-desktop.exe")
|
||||
if len(parts) == 3 && strings.EqualFold(parts[0], "versions") &&
|
||||
strings.EqualFold(parts[2], "reasonix-desktop.exe") {
|
||||
return true
|
||||
}
|
||||
// Legacy root-level icon: stale once the versioned layout is committed, even
|
||||
// if best-effort cleanup left the old binary behind. Without current.json,
|
||||
// require the file to be gone so a live flat install keeps its working icon.
|
||||
if len(parts) == 1 && strings.EqualFold(parts[0], "reasonix-desktop.exe") {
|
||||
if versionedLayout {
|
||||
return true
|
||||
}
|
||||
_, err := os.Lstat(filepath.Clean(iconPath))
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func notifyWindowsShortcutChanged(path string) {
|
||||
|
||||
@@ -73,9 +73,13 @@ func TestReasonixWindowsShortcutTargetRequiresCurrentInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
|
||||
root := filepath.Join(`C:\Program Files, Inc`, "Reasonix")
|
||||
func TestReasonixWindowsStaleIcon(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "Program Files, Inc", "Reasonix")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher := filepath.Join(root, "reasonix-launcher.exe")
|
||||
flat := filepath.Join(root, "reasonix-desktop.exe")
|
||||
tests := []struct {
|
||||
name string
|
||||
icon string
|
||||
@@ -83,6 +87,7 @@ func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
|
||||
}{
|
||||
{name: "versioned", icon: filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe") + ",0", want: true},
|
||||
{name: "quoted versioned", icon: `"` + filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe") + `", 0`, want: true},
|
||||
{name: "legacy root-level (file gone)", icon: flat + ",0", want: true},
|
||||
{name: "stable launcher", icon: launcher + ",0", want: false},
|
||||
{name: "custom icon", icon: filepath.Join(root, "custom.ico") + ",0", want: false},
|
||||
{name: "other install", icon: filepath.Join(`D:\Apps`, "Reasonix", "versions", "v1.19.3", "reasonix-desktop.exe") + ",0", want: false},
|
||||
@@ -90,11 +95,55 @@ func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := reasonixWindowsStaleIcon(tt.icon, launcher); got != tt.want {
|
||||
if got := reasonixWindowsStaleIcon(tt.icon, launcher, false); got != tt.want {
|
||||
t.Fatalf("reasonixWindowsStaleIcon(%q, %q) = %v, want %v", tt.icon, launcher, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reasonixWindowsStaleIcon(flat+",0", launcher, false) {
|
||||
t.Fatal("live flat icon = stale, want healthy")
|
||||
}
|
||||
if !reasonixWindowsStaleIcon(flat+",0", launcher, true) {
|
||||
t.Fatal("versioned layout with leftover flat icon = healthy, want stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonixWindowsFlatDesktopTargetNeedsMissingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
launcher := filepath.Join(root, "reasonix-launcher.exe")
|
||||
flat := filepath.Join(root, "reasonix-desktop.exe")
|
||||
|
||||
// No file on disk: the legacy root-level target dangles after the
|
||||
// versioned-layout migration and must be repointed.
|
||||
if !reasonixWindowsFlatDesktopTarget(flat, launcher, false) {
|
||||
t.Fatalf("missing flat desktop target = false, want true")
|
||||
}
|
||||
|
||||
// A live flat install still has the binary; its shortcut must be left alone.
|
||||
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reasonixWindowsFlatDesktopTarget(flat, launcher, false) {
|
||||
t.Fatalf("existing flat desktop target = true, want false")
|
||||
}
|
||||
if !reasonixWindowsFlatDesktopTarget(flat, launcher, true) {
|
||||
t.Fatalf("versioned layout with leftover flat desktop target = false, want true")
|
||||
}
|
||||
|
||||
// Non-flat targets never match.
|
||||
for _, target := range []string{
|
||||
launcher,
|
||||
filepath.Join(root, "Reasonix.exe"),
|
||||
filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe"),
|
||||
} {
|
||||
if reasonixWindowsFlatDesktopTarget(target, launcher, true) {
|
||||
t.Fatalf("reasonixWindowsFlatDesktopTarget(%q) = true, want false", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonixWindowsVersionedTarget(t *testing.T) {
|
||||
@@ -125,7 +174,7 @@ func TestReasonixWindowsVersionedTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRepairWindowsShortcutPlan(t *testing.T) {
|
||||
root := filepath.Join(`C:\Program Files`, "Reasonix")
|
||||
root := t.TempDir()
|
||||
launcher := filepath.Join(root, "reasonix-launcher.exe")
|
||||
versioned := filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe")
|
||||
tests := []struct {
|
||||
@@ -139,14 +188,27 @@ func TestRepairWindowsShortcutPlan(t *testing.T) {
|
||||
{name: "versioned target + clean icon", target: versioned, icon: launcher + ",0", wantRepoint: true, wantFixIcon: false},
|
||||
{name: "stable target + versioned icon", target: launcher, icon: versioned + ",0", wantRepoint: false, wantFixIcon: true},
|
||||
{name: "stable target + clean icon", target: launcher, icon: launcher + ",0", wantRepoint: false, wantFixIcon: false},
|
||||
{name: "flat target + flat icon (both gone)", target: filepath.Join(root, "reasonix-desktop.exe"), icon: filepath.Join(root, "reasonix-desktop.exe") + ",0", wantRepoint: true, wantFixIcon: true},
|
||||
{name: "flat target + clean icon", target: filepath.Join(root, "reasonix-desktop.exe"), icon: launcher + ",0", wantRepoint: true, wantFixIcon: false},
|
||||
{name: "custom target + custom icon", target: filepath.Join(root, "custom.ico"), icon: filepath.Join(root, "custom.ico") + ",0", wantRepoint: false, wantFixIcon: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repoint, fixIcon := repairWindowsShortcutPlan(tt.target, tt.icon, launcher)
|
||||
repoint, fixIcon := repairWindowsShortcutPlan(tt.target, tt.icon, launcher, false)
|
||||
if repoint != tt.wantRepoint || fixIcon != tt.wantFixIcon {
|
||||
t.Fatalf("repairWindowsShortcutPlan(%q, %q) = (%v, %v), want (%v, %v)", tt.target, tt.icon, repoint, fixIcon, tt.wantRepoint, tt.wantFixIcon)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
flat := filepath.Join(root, "reasonix-desktop.exe")
|
||||
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repoint, fixIcon := repairWindowsShortcutPlan(flat, flat+",0", launcher, false); repoint || fixIcon {
|
||||
t.Fatalf("live flat plan = (%v, %v), want (false, false)", repoint, fixIcon)
|
||||
}
|
||||
if repoint, fixIcon := repairWindowsShortcutPlan(flat, flat+",0", launcher, true); !repoint || !fixIcon {
|
||||
t.Fatalf("versioned layout leftover plan = (%v, %v), want (true, true)", repoint, fixIcon)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
func removeDesktopSessionArtifactsWithGuard(path string, guard *agent.SessionRemovalGuard) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
if guard == nil {
|
||||
return fmt.Errorf("session removal guard is required")
|
||||
}
|
||||
defer guard.Release()
|
||||
if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
defer invalidateTopicSessionIndexForPath(path)
|
||||
for _, artifact := range sessionOwnedArtifactPaths(path) {
|
||||
if strings.TrimSpace(artifact) == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(artifact); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeSessionPlannerDisplay(filepath.Dir(path), path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
return agent.ClearCleanupPending(path)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/jobs"
|
||||
)
|
||||
|
||||
// Interactive removal returns promptly; durable cleanup-pending markers let
|
||||
// delayed cleanup safely own non-cooperative jobs after this grace expires.
|
||||
const desktopSessionRemovalGrace = time.Second
|
||||
const desktopSessionRemovalWatchdog = desktopSessionRemovalGrace + 250*time.Millisecond
|
||||
|
||||
func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
|
||||
return waitDestroyHandleBatches([][]control.SessionDestroyHandle{destroys})[0]
|
||||
}
|
||||
|
||||
func waitDestroyHandleBatches(batches [][]control.SessionDestroyHandle) []bool {
|
||||
timedOut := make([]bool, len(batches))
|
||||
if len(batches) == 0 {
|
||||
return timedOut
|
||||
}
|
||||
type batchResult struct {
|
||||
index int
|
||||
timedOut bool
|
||||
}
|
||||
deadline := time.Now().Add(desktopSessionRemovalWatchdog)
|
||||
results := make(chan batchResult, len(batches))
|
||||
for i, destroys := range batches {
|
||||
go func() {
|
||||
results <- batchResult{index: i, timedOut: waitDestroyHandlesUntil(destroys, deadline)}
|
||||
}()
|
||||
}
|
||||
for range batches {
|
||||
result := <-results
|
||||
timedOut[result.index] = result.timedOut
|
||||
}
|
||||
return timedOut
|
||||
}
|
||||
|
||||
func waitDestroyHandlesUntil(destroys []control.SessionDestroyHandle, deadline time.Time) bool {
|
||||
results := make(chan jobs.TeardownResult, len(destroys))
|
||||
waits := 0
|
||||
for _, destroy := range destroys {
|
||||
wait := destroy.Wait
|
||||
if destroy.WaitFor != nil {
|
||||
remaining := min(max(time.Until(deadline), time.Duration(0)), desktopSessionRemovalGrace)
|
||||
wait = func() jobs.TeardownResult { return destroy.WaitFor(remaining) }
|
||||
}
|
||||
if wait == nil {
|
||||
continue
|
||||
}
|
||||
waits++
|
||||
go func(wait func() jobs.TeardownResult) { results <- wait() }(wait)
|
||||
}
|
||||
if waits == 0 {
|
||||
return false
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
defer timer.Stop()
|
||||
timedOut := false
|
||||
for range waits {
|
||||
select {
|
||||
case result := <-results:
|
||||
timedOut = timedOut || result.HasTimedOut()
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return timedOut
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/jobs"
|
||||
)
|
||||
|
||||
func TestWaitDestroyHandlesPrefersBoundedWait(t *testing.T) {
|
||||
requested := make(chan time.Duration, 1)
|
||||
unboundedCalled := make(chan struct{}, 1)
|
||||
timedOut := waitDestroyHandles([]control.SessionDestroyHandle{{
|
||||
Wait: func() jobs.TeardownResult {
|
||||
unboundedCalled <- struct{}{}
|
||||
return jobs.TeardownResult{}
|
||||
},
|
||||
WaitFor: func(grace time.Duration) jobs.TeardownResult {
|
||||
requested <- grace
|
||||
return jobs.TeardownResult{}
|
||||
},
|
||||
}})
|
||||
if timedOut {
|
||||
t.Fatal("bounded wait reported an unexpected timeout")
|
||||
}
|
||||
select {
|
||||
case grace := <-requested:
|
||||
if grace != desktopSessionRemovalGrace {
|
||||
t.Fatalf("bounded wait grace = %s, want %s", grace, desktopSessionRemovalGrace)
|
||||
}
|
||||
default:
|
||||
t.Fatal("bounded wait was not called")
|
||||
}
|
||||
select {
|
||||
case <-unboundedCalled:
|
||||
t.Fatal("waitDestroyHandles used the unbounded wait when WaitFor was available")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitDestroyHandlesBoundsLegacyWait(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
returned := make(chan struct{})
|
||||
defer func() {
|
||||
close(release)
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("legacy wait goroutine did not finish after release")
|
||||
}
|
||||
}()
|
||||
started := time.Now()
|
||||
timedOut := waitDestroyHandles([]control.SessionDestroyHandle{{
|
||||
Wait: func() jobs.TeardownResult {
|
||||
<-release
|
||||
close(returned)
|
||||
return jobs.TeardownResult{}
|
||||
},
|
||||
}})
|
||||
elapsed := time.Since(started)
|
||||
if !timedOut {
|
||||
t.Fatal("non-cooperative legacy wait did not report a timeout")
|
||||
}
|
||||
if elapsed < desktopSessionRemovalGrace || elapsed > 2*time.Second {
|
||||
t.Fatalf("legacy wait returned after %s, want a bounded wait near %s", elapsed, desktopSessionRemovalGrace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitDestroyHandleBatchesWaitsAcrossSessionsConcurrently(t *testing.T) {
|
||||
started := make(chan int, 2)
|
||||
release := make(chan struct{})
|
||||
batches := make([][]control.SessionDestroyHandle, 2)
|
||||
for i := range batches {
|
||||
index := i
|
||||
batches[i] = []control.SessionDestroyHandle{{
|
||||
WaitFor: func(time.Duration) jobs.TeardownResult {
|
||||
started <- index
|
||||
<-release
|
||||
return jobs.TeardownResult{}
|
||||
},
|
||||
}}
|
||||
}
|
||||
done := make(chan []bool, 1)
|
||||
go func() { done <- waitDestroyHandleBatches(batches) }()
|
||||
seen := map[int]bool{}
|
||||
for range batches {
|
||||
select {
|
||||
case index := <-started:
|
||||
seen[index] = true
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("session destroy batches did not start concurrently")
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
if !seen[0] || !seen[1] {
|
||||
t.Fatalf("started batches = %v, want both sessions", seen)
|
||||
}
|
||||
select {
|
||||
case timedOut := <-done:
|
||||
if timedOut[0] || timedOut[1] {
|
||||
t.Fatalf("completed destroy batches timed out: %v", timedOut)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("concurrent destroy batches did not finish after release")
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ func friendlySessionFileError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionFileLockHeld) {
|
||||
return errSessionBusyElsewhere
|
||||
}
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return err
|
||||
|
||||
@@ -72,6 +72,9 @@ func TestFriendlySessionFileErrorPassesThroughOtherErrors(t *testing.T) {
|
||||
if got := friendlySessionFileError(errSessionBusyElsewhere); !errors.Is(got, errSessionBusyElsewhere) {
|
||||
t.Fatalf("sanitized busy error rewritten to %v", got)
|
||||
}
|
||||
if got := friendlySessionFileError(agent.ErrSessionFileLockHeld); !errors.Is(got, errSessionBusyElsewhere) {
|
||||
t.Fatalf("bounded session file lock error = %v, want %v", got, errSessionBusyElsewhere)
|
||||
}
|
||||
notExist := &os.PathError{Op: "lstat", Path: "gone.jsonl", Err: syscall.ENOENT}
|
||||
if got := friendlySessionFileError(notExist); !errors.Is(got, error(notExist)) {
|
||||
t.Fatalf("not-exist error rewritten to %v", got)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
func TestSessionSidecarSavesUseDurableAtomicWrite(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path func(string) string
|
||||
save func(string) error
|
||||
}{
|
||||
{
|
||||
name: "titles",
|
||||
path: sessionTitlesPath,
|
||||
save: func(dir string) error {
|
||||
return saveSessionTitles(dir, map[string]string{"session.jsonl": "Durable title"})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "displays",
|
||||
path: sessionDisplayPath,
|
||||
save: func(dir string) error {
|
||||
return saveSessionDisplays(dir, sessionDisplayMap{
|
||||
"session.jsonl": {messageDisplayKey("expanded prompt"): "visible prompt"},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "planner displays",
|
||||
path: sessionPlannerDisplayPath,
|
||||
save: func(dir string) error {
|
||||
return saveSessionPlannerDisplays(dir, sessionPlannerDisplayMap{
|
||||
"session.jsonl": {{UserHash: "prompt-digest", Messages: []HistoryMessage{}}},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := tt.path(dir)
|
||||
previousCrashPoint := fileutil.CrashPoint
|
||||
t.Cleanup(func() { fileutil.CrashPoint = previousCrashPoint })
|
||||
usedDurableWrite := false
|
||||
fileutil.CrashPoint = func(op, gotPath string) {
|
||||
if op == "atomic-write" && gotPath == path {
|
||||
usedDurableWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := tt.save(dir); err != nil {
|
||||
t.Fatalf("save %s sidecar: %v", tt.name, err)
|
||||
}
|
||||
if !usedDurableWrite {
|
||||
t.Fatalf("%s sidecar bypassed fileutil.AtomicWriteFile", tt.name)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s sidecar: %v", tt.name, err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("%s sidecar mode = %o, want 600", tt.name, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionTitleHonorsSidecarLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
release, err := filelock.Acquire(context.Background(), sessionTitlesPath(dir)+".lock")
|
||||
if err != nil {
|
||||
t.Fatalf("acquire title sidecar lock: %v", err)
|
||||
}
|
||||
previousTimeout := sessionTitlesQueueTimeout
|
||||
sessionTitlesQueueTimeout = 40 * time.Millisecond
|
||||
t.Cleanup(func() { sessionTitlesQueueTimeout = previousTimeout })
|
||||
|
||||
err = setSessionTitle(dir, filepath.Join(dir, "locked.jsonl"), "Locked")
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatal("setSessionTitle succeeded while the title sidecar lock was held")
|
||||
}
|
||||
release()
|
||||
if err := setSessionTitle(dir, filepath.Join(dir, "locked.jsonl"), "Saved"); err != nil {
|
||||
t.Fatalf("setSessionTitle after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSessionDisplayExternalLockUsesShortBudget(t *testing.T) {
|
||||
if os.Getenv("REASONIX_DISPLAY_LOCK_HELPER") != "" {
|
||||
dir := os.Getenv("REASONIX_DISPLAY_LOCK_DIR")
|
||||
release, err := filelock.Acquire(context.Background(), sessionDisplayPath(dir)+".lock")
|
||||
if err != nil {
|
||||
t.Fatalf("acquire external display lock: %v", err)
|
||||
}
|
||||
defer release()
|
||||
if err := os.WriteFile(filepath.Join(dir, "display-lock.ready"), []byte("ready"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !waitForPlannerDisplayTestFile(filepath.Join(dir, "display-lock.release"), 10*time.Second) {
|
||||
t.Fatal("timed out waiting to release external display lock")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
var output strings.Builder
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestRecordSessionDisplayExternalLockUsesShortBudget$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"REASONIX_DISPLAY_LOCK_HELPER=1",
|
||||
"REASONIX_DISPLAY_LOCK_DIR="+dir,
|
||||
)
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start external display-lock helper: %v", err)
|
||||
}
|
||||
releasePath := filepath.Join(dir, "display-lock.release")
|
||||
releaseHelper := func() {
|
||||
_ = os.WriteFile(releasePath, []byte("release"), 0o600)
|
||||
}
|
||||
if !waitForPlannerDisplayTestFile(filepath.Join(dir, "display-lock.ready"), 5*time.Second) {
|
||||
releaseHelper()
|
||||
_ = cmd.Wait()
|
||||
t.Fatalf("external display-lock helper did not start: %s", output.String())
|
||||
}
|
||||
|
||||
previousTimeout := sessionDisplayExternalLockTimeout
|
||||
sessionDisplayExternalLockTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { sessionDisplayExternalLockTimeout = previousTimeout })
|
||||
started := time.Now()
|
||||
err := recordSessionDisplay(dir, filepath.Join(dir, "session.jsonl"), "expanded prompt", "visible prompt")
|
||||
elapsed := time.Since(started)
|
||||
releaseHelper()
|
||||
if waitErr := cmd.Wait(); waitErr != nil {
|
||||
t.Fatalf("external display-lock helper failed: %v\n%s", waitErr, output.String())
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("record display error = %v, want external lock deadline exceeded", err)
|
||||
}
|
||||
if elapsed >= 2*time.Second {
|
||||
t.Fatalf("record display waited %v, want the short external lock budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionTitleSerializesConcurrentUpdates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const sessions = 32
|
||||
errs := make(chan error, sessions)
|
||||
var wg sync.WaitGroup
|
||||
for i := range sessions {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
path := filepath.Join(dir, fmt.Sprintf("session-%02d.jsonl", i))
|
||||
errs <- setSessionTitle(dir, path, fmt.Sprintf("Title %02d", i))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("setSessionTitle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
titles := loadSessionTitles(dir)
|
||||
if len(titles) != sessions {
|
||||
t.Fatalf("title count = %d, want %d: %#v", len(titles), sessions, titles)
|
||||
}
|
||||
for i := range sessions {
|
||||
key := fmt.Sprintf("session-%02d.jsonl", i)
|
||||
if got := titles[key]; got != fmt.Sprintf("Title %02d", i) {
|
||||
t.Fatalf("%s title = %q, want retained concurrent value", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
// Hold the removal guard across the whole move so no runtime can acquire
|
||||
// the session (or save into it) while its artifacts are relocated; the
|
||||
// lock sidecars are deleted atomically with the guard release.
|
||||
guard, err := acquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return trashSessionArtifactsWithGuard(dir, sessionPath, key, guard)
|
||||
}
|
||||
|
||||
func trashSessionArtifactsWithGuard(dir, sessionPath, key string, guard *agent.SessionRemovalGuard) error {
|
||||
if guard == nil {
|
||||
return errSessionBusyElsewhere
|
||||
}
|
||||
defer guard.Release()
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if info, err := os.Stat(itemDir); err == nil {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("session trash target is not a directory: %s", key)
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, key)
|
||||
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
||||
_, liveErr := os.Stat(sessionPath)
|
||||
if liveErr != nil && !os.IsNotExist(liveErr) {
|
||||
return liveErr
|
||||
}
|
||||
if liveErr == nil {
|
||||
discardable, err := liveSessionContentDiscardable(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discardable {
|
||||
return removeDesktopSessionArtifactsWithGuard(sessionPath, guard)
|
||||
}
|
||||
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matches {
|
||||
return removeDesktopSessionArtifactsWithGuard(sessionPath, guard)
|
||||
}
|
||||
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
defer invalidateTopicSessionIndexForPath(sessionPath)
|
||||
if err := invalidateTopicDirMarkers(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
|
||||
if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
||||
b, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return agent.ClearCleanupPending(sessionPath)
|
||||
}
|
||||
+77
-131
@@ -33,6 +33,21 @@ const sessionPlannerDisplayFile = ".planner-display.json"
|
||||
const sessionTrashDir = ".trash"
|
||||
const sessionTrashMetaFile = ".trash-meta.json"
|
||||
|
||||
const (
|
||||
// Durable sidecar publication includes an fsync while holding the update
|
||||
// lock. Keep enough queue budget for a burst of in-process writers on
|
||||
// slower Windows disks, but fail external contention quickly so turn
|
||||
// completion and retry-queue handoff do not stall behind another process.
|
||||
sessionSidecarQueueTimeout = 5 * time.Second
|
||||
sessionSidecarExternalLockTimeout = 750 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
sessionTitlesQueueTimeout = sessionSidecarQueueTimeout
|
||||
sessionPlannerDisplayExternalLockTimeout = sessionSidecarExternalLockTimeout
|
||||
sessionDisplayExternalLockTimeout = sessionSidecarExternalLockTimeout
|
||||
)
|
||||
|
||||
func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) }
|
||||
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
|
||||
func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }
|
||||
@@ -73,7 +88,33 @@ func loadSessionTitlesForUpdate(dir string) (map[string]string, error) {
|
||||
return loadStringMapForUpdate(sessionTitlesPath(dir))
|
||||
}
|
||||
|
||||
// saveSessionTitles writes the map atomically (temp file + rename).
|
||||
func updateSessionTitles(dir string, mutate func(map[string]string) bool) error {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return errors.New("title directory is empty")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionTitlesQueueTimeout)
|
||||
defer cancel()
|
||||
release, err := filelock.AcquireWithExternalTimeout(ctx, sessionTitlesPath(dir)+".lock", sessionSidecarExternalLockTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock title sidecar: %w", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
m, err := loadSessionTitlesForUpdate(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !mutate(m) {
|
||||
return nil
|
||||
}
|
||||
return saveSessionTitles(dir, m)
|
||||
}
|
||||
|
||||
// saveSessionTitles writes the map durably and atomically. Keep this on the
|
||||
// shared helper so the temporary file is fsynced before it is published.
|
||||
func saveSessionTitles(dir string, m map[string]string) error {
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
@@ -82,21 +123,7 @@ func saveSessionTitles(dir string, m map[string]string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".titles.*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return fileutil.ReplaceFile(tmpPath, sessionTitlesPath(dir))
|
||||
return fileutil.AtomicWriteFile(sessionTitlesPath(dir), b, 0o600)
|
||||
}
|
||||
|
||||
// setSessionTitle sets (or, with an empty title, clears) a session's custom name.
|
||||
@@ -105,17 +132,22 @@ func setSessionTitle(dir, sessionPath, title string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := loadSessionTitlesForUpdate(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := filepath.Base(sessionPath)
|
||||
if strings.TrimSpace(title) == "" {
|
||||
delete(m, key)
|
||||
} else {
|
||||
m[key] = strings.TrimSpace(title)
|
||||
}
|
||||
return saveSessionTitles(dir, m)
|
||||
return updateSessionTitles(dir, func(m map[string]string) bool {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
if _, ok := m[key]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(m, key)
|
||||
return true
|
||||
}
|
||||
if m[key] == title {
|
||||
return false
|
||||
}
|
||||
m[key] = title
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// deleteSessionFile moves a session's .jsonl and file sidecars into the local
|
||||
@@ -215,64 +247,6 @@ func reconcileDesktopCleanupPending(dir string) error {
|
||||
})
|
||||
}
|
||||
|
||||
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
// Hold the removal guard across the whole move so no runtime can acquire
|
||||
// the session (or save into it) while its artifacts are relocated; the
|
||||
// lock sidecars are deleted atomically with the guard release.
|
||||
guard, err := acquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer guard.Release()
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if info, err := os.Stat(itemDir); err == nil {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("session trash target is not a directory: %s", key)
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, key)
|
||||
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
||||
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matches {
|
||||
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
|
||||
if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
||||
b, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return agent.ClearCleanupPending(sessionPath)
|
||||
}
|
||||
|
||||
func validateSessionTrashTarget(dir, sessionPath, key string) error {
|
||||
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
@@ -394,6 +368,10 @@ func liveSessionDiscardable(sessionPath string) (bool, error) {
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
return true, nil
|
||||
}
|
||||
return liveSessionContentDiscardable(sessionPath)
|
||||
}
|
||||
|
||||
func liveSessionContentDiscardable(sessionPath string) (bool, error) {
|
||||
info, err := os.Stat(sessionPath)
|
||||
if os.IsNotExist(err) {
|
||||
return true, nil
|
||||
@@ -452,7 +430,7 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
|
||||
return err
|
||||
}
|
||||
if !target.shouldMove {
|
||||
return nil
|
||||
return agent.ClearCleanupPending(sessionPath)
|
||||
}
|
||||
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
|
||||
// there takes its own removal guard, and the guard is not reentrant.
|
||||
@@ -598,15 +576,14 @@ func purgeTrashedSessionFile(dir, path string) error {
|
||||
if err := os.RemoveAll(itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := loadSessionTitlesForUpdate(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := m[key]; ok {
|
||||
delete(m, key)
|
||||
if err := saveSessionTitles(dir, m); err != nil {
|
||||
return err
|
||||
if err := updateSessionTitles(dir, func(m map[string]string) bool {
|
||||
if _, ok := m[key]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(m, key)
|
||||
return true
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeSessionDisplayKey(dir, key); err != nil {
|
||||
return err
|
||||
@@ -950,10 +927,7 @@ type plannerDisplayTurn struct {
|
||||
Messages []HistoryMessage `json:"messages"`
|
||||
}
|
||||
|
||||
var (
|
||||
sessionPlannerDisplayLockTimeout = 750 * time.Millisecond
|
||||
errCorruptSessionPlannerDisplay = errors.New("corrupt planner display sidecar")
|
||||
)
|
||||
var errCorruptSessionPlannerDisplay = errors.New("corrupt planner display sidecar")
|
||||
|
||||
// sessionPlannerDisplayUpdateAfterLoad is a subprocess-test seam. Production
|
||||
// leaves it nil; tests use it to force two independent processes into the old
|
||||
@@ -1018,21 +992,7 @@ func saveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".planner-display.*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return fileutil.ReplaceFile(tmpPath, sessionPlannerDisplayPath(dir))
|
||||
return fileutil.AtomicWriteFile(sessionPlannerDisplayPath(dir), b, 0o600)
|
||||
}
|
||||
|
||||
func saveOrRemoveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
|
||||
@@ -1053,9 +1013,9 @@ func updateSessionPlannerDisplays(dir string, recoverCorrupt bool, mutate func(s
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionPlannerDisplayLockTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
|
||||
defer cancel()
|
||||
release, err := filelock.Acquire(ctx, sessionPlannerDisplayPath(dir)+".lock")
|
||||
release, err := filelock.AcquireWithExternalTimeout(ctx, sessionPlannerDisplayPath(dir)+".lock", sessionPlannerDisplayExternalLockTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock planner display sidecar: %w", err)
|
||||
}
|
||||
@@ -1155,21 +1115,7 @@ func saveSessionDisplays(dir string, m sessionDisplayMap) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".display.*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return fileutil.ReplaceFile(tmpPath, sessionDisplayPath(dir))
|
||||
return fileutil.AtomicWriteFile(sessionDisplayPath(dir), b, 0o600)
|
||||
}
|
||||
|
||||
func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
|
||||
@@ -1194,9 +1140,9 @@ func updateSessionDisplays(dir string, mutate func(sessionDisplayMap) bool) erro
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
|
||||
defer cancel()
|
||||
release, err := filelock.Acquire(ctx, sessionDisplayPath(dir)+".lock")
|
||||
release, err := filelock.AcquireWithExternalTimeout(ctx, sessionDisplayPath(dir)+".lock", sessionDisplayExternalLockTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock display sidecar: %w", err)
|
||||
}
|
||||
|
||||
@@ -1243,7 +1243,7 @@ func TestRecordSessionPlannerDisplayConcurrentPreservesEverySession(t *testing.T
|
||||
func TestRecordSessionPlannerDisplayCrossProcessPreservesEverySession(t *testing.T) {
|
||||
if role := os.Getenv("REASONIX_PLANNER_DISPLAY_HELPER"); role != "" {
|
||||
dir := os.Getenv("REASONIX_PLANNER_DISPLAY_DIR")
|
||||
sessionPlannerDisplayLockTimeout = 5 * time.Second
|
||||
sessionPlannerDisplayExternalLockTimeout = 5 * time.Second
|
||||
attempted := filepath.Join(dir, role+".attempted")
|
||||
loaded := filepath.Join(dir, role+".loaded")
|
||||
release := filepath.Join(dir, role+".release")
|
||||
|
||||
-167
@@ -7660,173 +7660,6 @@ func (a *App) SetTopicPinned(topicID string, pinned bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errTopicHasActiveWork = errors.New("wait for the session to finish, answer pending prompts, and stop background jobs before archiving this topic")
|
||||
|
||||
// TrashTopic removes an idle topic from the project tree and moves its saved
|
||||
// session records into the session trash. Idle in-process runtimes are detached
|
||||
// first, so their autosave cannot recreate state after the topic is gone.
|
||||
func (a *App) TrashTopic(topicID string) error {
|
||||
return friendlySessionFileError(a.trashTopic(topicID))
|
||||
}
|
||||
|
||||
func (a *App) topicHasActiveRuntimeWork(topicID string) bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
|
||||
for _, tab := range tabs {
|
||||
if tab != nil && tab.TopicID == topicID && tab.hasActiveRuntimeWork() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) trashTopic(topicID string) error {
|
||||
if strings.TrimSpace(topicID) == "" {
|
||||
return fmt.Errorf("topicID is required")
|
||||
}
|
||||
|
||||
var fallback fallbackRuntimeTarget
|
||||
var changedDirs []string
|
||||
if err := func() error {
|
||||
defer a.lockRuntimeMutation("trash-topic")()
|
||||
a.sessionRemovalMu.Lock()
|
||||
defer a.sessionRemovalMu.Unlock()
|
||||
if a.topicHasActiveRuntimeWork(topicID) {
|
||||
return errTopicHasActiveWork
|
||||
}
|
||||
|
||||
targets, err := a.topicTrashTargets(topicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range targets {
|
||||
changedDirs = append(changedDirs, target.dir)
|
||||
}
|
||||
removed, nextFallback := a.removeTopicRuntimeBindings(topicID)
|
||||
fallback = nextFallback
|
||||
if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
|
||||
a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, map[control.SessionAPI]bool{})
|
||||
return err
|
||||
}
|
||||
destroyBegun := false
|
||||
closedRemoved := map[control.SessionAPI]bool{}
|
||||
defer func() {
|
||||
if destroyBegun {
|
||||
a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
|
||||
return
|
||||
}
|
||||
a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, closedRemoved)
|
||||
}()
|
||||
|
||||
for _, target := range targets {
|
||||
destroys := a.destroyHandlesForSession(target.dir, target.sessionPath, removed)
|
||||
if len(destroys) > 0 {
|
||||
destroyBegun = true
|
||||
}
|
||||
teardownTimedOut := waitDestroyHandles(destroys)
|
||||
a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, target.dir, target.sessionPath, closedRemoved)
|
||||
if teardownTimedOut {
|
||||
if err := agent.MarkCleanupPending(target.sessionPath, "delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
go delayedDesktopSessionTrash(target.dir, target.sessionPath, target.key, destroys)
|
||||
} else {
|
||||
err := trashSessionArtifacts(target.dir, target.sessionPath, target.key)
|
||||
finishDestroyHandles(destroys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.deleteTopic(topicID)
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
if fallback.needs {
|
||||
fallback.topicID = ""
|
||||
if err := a.openFallbackRuntime(fallback); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(changedDirs) > 0 {
|
||||
a.emitProjectTreeChangedForSessionDirs(changedDirs...)
|
||||
} else {
|
||||
a.emitProjectTreeMetadataChanged()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type topicTrashTarget struct {
|
||||
dir string
|
||||
sessionPath string
|
||||
key string
|
||||
}
|
||||
|
||||
func (a *App) topicTrashTargets(topicID string) ([]topicTrashTarget, error) {
|
||||
topicID = strings.TrimSpace(topicID)
|
||||
var targets []topicTrashTarget
|
||||
seen := map[string]bool{}
|
||||
addTarget := func(dir, path string) error {
|
||||
sessionPath, key, err := validateSessionPath(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := dir + "\x00" + sessionPath
|
||||
if seen[id] {
|
||||
return nil
|
||||
}
|
||||
seen[id] = true
|
||||
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
|
||||
return err
|
||||
}
|
||||
targets = append(targets, topicTrashTarget{dir: dir, sessionPath: sessionPath, key: key})
|
||||
return nil
|
||||
}
|
||||
for _, dir := range a.knownSessionDirs() {
|
||||
index, err := topicSessionIndexForDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, match := range index.byTopic[topicID] {
|
||||
if agent.IsCleanupPending(match.path) {
|
||||
continue
|
||||
}
|
||||
if err := addTarget(dir, match.path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
a.mu.RLock()
|
||||
var runtimeTargets []struct {
|
||||
dir string
|
||||
path string
|
||||
}
|
||||
for _, tab := range a.runtimeTabsLocked() {
|
||||
if tab == nil || tab.TopicID != topicID {
|
||||
continue
|
||||
}
|
||||
if path := canonicalTabSessionPath(tab.currentSessionPath()); path != "" {
|
||||
dir := tabSessionDir(tab)
|
||||
if filepath.IsAbs(path) {
|
||||
dir = filepath.Dir(path)
|
||||
}
|
||||
runtimeTargets = append(runtimeTargets, struct {
|
||||
dir string
|
||||
path string
|
||||
}{dir: dir, path: path})
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
for _, target := range runtimeTargets {
|
||||
if err := addTarget(target.dir, target.path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// ListProjectTree builds the sidebar tree: project folders each containing
|
||||
// their topics, plus a Global section.
|
||||
// topicSummary is used by ListProjectTree and mergeSessionInfos to track
|
||||
|
||||
+16
-28
@@ -3140,6 +3140,9 @@ func TestTrashTopicMovesOpenSessionToTrash(t *testing.T) {
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("open topic session should be moved to trash: %v", err)
|
||||
}
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("completed topic archive left a cleanup-pending marker")
|
||||
}
|
||||
trashed := app.ListTrashedSessions()
|
||||
if len(trashed) != 1 || trashed[0].Path != trashPath {
|
||||
t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
|
||||
@@ -3288,7 +3291,7 @@ func TestTrashTopicRejectsRunningDetachedRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicWaitsForConcurrentTurnAdmission(t *testing.T) {
|
||||
func TestTrashTopicRejectsConcurrentTurnAdmissionWithoutWaiting(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
topicID := "topic_concurrent_turn_trash"
|
||||
@@ -3344,31 +3347,12 @@ func TestTrashTopicWaitsForConcurrentTurnAdmission(t *testing.T) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
trashEntered := make(chan struct{})
|
||||
var trashEnteredOnce sync.Once
|
||||
app.runtimeMutationBeforeLockHook = func(operation string) {
|
||||
if operation == "trash-topic" {
|
||||
trashEnteredOnce.Do(func() { close(trashEntered) })
|
||||
}
|
||||
started := time.Now()
|
||||
if err := app.TrashTopic(topicID); !errors.Is(err, errTopicArchiveBusy) {
|
||||
t.Fatalf("concurrent TrashTopic error = %v, want %v", err, errTopicArchiveBusy)
|
||||
}
|
||||
trashDone := make(chan error, 1)
|
||||
go func() { trashDone <- app.TrashTopic(topicID) }()
|
||||
select {
|
||||
case <-trashEntered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("TrashTopic did not reach the runtime mutation barrier")
|
||||
}
|
||||
|
||||
// Wait until TrashTopic owns runtimeRebuildMu and is queued on the admission
|
||||
// writer. Releasing the turn gate now must let SubmitToTab publish Running
|
||||
// before TrashTopic can re-check active work.
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for app.runtimeRebuildMu.TryLock() {
|
||||
app.runtimeRebuildMu.Unlock()
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("TrashTopic never acquired the runtime rebuild lock")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("concurrent TrashTopic waited %s instead of returning busy", elapsed)
|
||||
}
|
||||
tab.turnStartMu.Unlock()
|
||||
turnGateHeld = false
|
||||
@@ -3377,9 +3361,6 @@ func TestTrashTopicWaitsForConcurrentTurnAdmission(t *testing.T) {
|
||||
t.Fatalf("SubmitToTab: %v", err)
|
||||
}
|
||||
<-runner.started
|
||||
if err := <-trashDone; !errors.Is(err, errTopicHasActiveWork) {
|
||||
t.Fatalf("concurrent TrashTopic error = %v, want %v", err, errTopicHasActiveWork)
|
||||
}
|
||||
if !ctrl.Running() {
|
||||
t.Fatal("rejected archive should leave the concurrently admitted turn running")
|
||||
}
|
||||
@@ -3843,6 +3824,13 @@ func TestTrashTopicValidTrashRemovesEmptyLiveStub(t *testing.T) {
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("existing trash should remain authoritative: %v", err)
|
||||
}
|
||||
trashed, err := listTrashedSessionFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("listTrashedSessionFiles: %v", err)
|
||||
}
|
||||
if len(trashed) != 1 || !sameDesktopPath(trashed[0], trashPath) {
|
||||
t.Fatalf("trashed sessions = %v, want only authoritative copy %q", trashed, trashPath)
|
||||
}
|
||||
}
|
||||
|
||||
func hasHistoryContent(messages []HistoryMessage, content string) bool {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
var (
|
||||
errTopicHasActiveWork = errors.New("wait for the session to finish, answer pending prompts, and stop background jobs before archiving this topic")
|
||||
errTopicArchiveBusy = errors.New("Reasonix is finishing another session change — wait a moment and retry archiving")
|
||||
)
|
||||
|
||||
var topicArchiveCleanupHookForTest func() error
|
||||
|
||||
type topicArchiveTrace struct {
|
||||
phase string
|
||||
targetCount int
|
||||
runtimeCount int
|
||||
}
|
||||
|
||||
func (a *App) TrashTopic(topicID string) error {
|
||||
return friendlySessionFileError(a.trashTopic(topicID))
|
||||
}
|
||||
|
||||
func (a *App) topicHasActiveRuntimeWork(topicID string) bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
|
||||
for _, tab := range tabs {
|
||||
if tab != nil && tab.TopicID == topicID && tab.hasActiveRuntimeWork() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) trashTopic(topicID string) (retErr error) {
|
||||
topicID = strings.TrimSpace(topicID)
|
||||
if topicID == "" {
|
||||
return fmt.Errorf("topicID is required")
|
||||
}
|
||||
started := time.Now()
|
||||
trace := topicArchiveTrace{phase: "start"}
|
||||
defer func() {
|
||||
outcome := "ok"
|
||||
if retErr != nil {
|
||||
outcome = "failed"
|
||||
}
|
||||
slog.Debug("desktop: topic archive timing", "outcome", outcome, "phase", trace.phase,
|
||||
"total_ms", time.Since(started).Milliseconds(), "target_count", trace.targetCount, "runtime_count", trace.runtimeCount)
|
||||
}()
|
||||
fallback, changedDirs, err := a.commitTopicArchive(topicID, &trace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fallback.needs {
|
||||
trace.phase = "open_fallback"
|
||||
fallback.topicID = ""
|
||||
if err := a.openFallbackRuntime(fallback); err != nil {
|
||||
// Runtime construction errors can include provider configuration
|
||||
// details, so keep this recovery diagnostic value-free.
|
||||
slog.Warn("desktop: open fallback after topic archive failed")
|
||||
}
|
||||
}
|
||||
trace.phase = "notify"
|
||||
if len(changedDirs) > 0 {
|
||||
a.emitProjectTreeChangedForSessionDirs(changedDirs...)
|
||||
} else {
|
||||
a.emitProjectTreeMetadataChanged()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) commitTopicArchive(topicID string, trace *topicArchiveTrace) (fallbackRuntimeTarget, []string, error) {
|
||||
trace.phase = "runtime_lock"
|
||||
releaseRuntime, ok := a.tryLockRuntimeMutation("trash-topic")
|
||||
if !ok {
|
||||
return fallbackRuntimeTarget{}, nil, errTopicArchiveBusy
|
||||
}
|
||||
defer releaseRuntime()
|
||||
trace.phase = "removal_lock"
|
||||
if !a.sessionRemovalMu.TryLock() {
|
||||
return fallbackRuntimeTarget{}, nil, errTopicArchiveBusy
|
||||
}
|
||||
defer a.sessionRemovalMu.Unlock()
|
||||
trace.phase = "active_work_check"
|
||||
if a.topicHasActiveRuntimeWork(topicID) {
|
||||
return fallbackRuntimeTarget{}, nil, errTopicHasActiveWork
|
||||
}
|
||||
trace.phase = "target_scan"
|
||||
targets, err := a.topicTrashTargets(topicID)
|
||||
if err != nil {
|
||||
return fallbackRuntimeTarget{}, nil, err
|
||||
}
|
||||
trace.targetCount = len(targets)
|
||||
changedDirs := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
changedDirs = append(changedDirs, target.dir)
|
||||
}
|
||||
trace.phase = "snapshot"
|
||||
removed := a.captureTopicRuntimeBindings(topicID)
|
||||
trace.runtimeCount = len(removed)
|
||||
if err := a.snapshotTopicRuntimeBindings(removed); err != nil {
|
||||
return fallbackRuntimeTarget{}, nil, err
|
||||
}
|
||||
trace.phase = "acquire_removal_ownership"
|
||||
ownership, err := acquireTopicArchiveOwnership(targets, removed)
|
||||
if err != nil {
|
||||
return fallbackRuntimeTarget{}, nil, err
|
||||
}
|
||||
defer ownership.release()
|
||||
trace.phase = "mark_cleanup_pending"
|
||||
rollbackMarkers, err := markTopicArchiveCleanupPending(topicID, targets)
|
||||
if err != nil {
|
||||
ownership.rollback()
|
||||
return fallbackRuntimeTarget{}, nil, err
|
||||
}
|
||||
trace.phase = "detach_runtimes"
|
||||
fallback, unchanged := a.removeTopicRuntimeBindingsIfUnchanged(topicID, removed)
|
||||
if !unchanged {
|
||||
rollbackMarkers()
|
||||
ownership.rollback()
|
||||
return fallbackRuntimeTarget{}, nil, errTopicArchiveBusy
|
||||
}
|
||||
a.finalizeRemovedTopicRuntimes(removed)
|
||||
destroyBegun := false
|
||||
closedRemoved := map[control.SessionAPI]bool{}
|
||||
defer func() {
|
||||
if destroyBegun {
|
||||
a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
|
||||
} else {
|
||||
a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, closedRemoved)
|
||||
}
|
||||
}()
|
||||
trace.phase = "teardown"
|
||||
destroyBatches := make([][]control.SessionDestroyHandle, len(targets))
|
||||
for i, target := range targets {
|
||||
destroys := a.destroyHandlesForSession(target.dir, target.sessionPath, removed)
|
||||
destroyBatches[i] = destroys
|
||||
destroyBegun = destroyBegun || len(destroys) > 0
|
||||
}
|
||||
timedOutTargets := waitDestroyHandleBatches(destroyBatches)
|
||||
for i, target := range targets {
|
||||
destroys := destroyBatches[i]
|
||||
a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, target.dir, target.sessionPath, closedRemoved)
|
||||
if timedOutTargets[i] {
|
||||
guard := ownership.take(target.sessionPath)
|
||||
go delayedDesktopTopicTrash(target.dir, target.sessionPath, target.key, guard, destroys)
|
||||
continue
|
||||
}
|
||||
trace.phase = "move_artifacts"
|
||||
var err error
|
||||
if hook := topicArchiveCleanupHookForTest; hook != nil {
|
||||
err = hook()
|
||||
}
|
||||
if err == nil {
|
||||
err = trashSessionArtifactsWithGuard(target.dir, target.sessionPath, target.key, ownership.take(target.sessionPath))
|
||||
}
|
||||
finishDestroyHandles(destroys)
|
||||
if err != nil {
|
||||
// Cleanup-pending is the durable commit point. Once bindings have
|
||||
// detached, report the archive as accepted and let startup
|
||||
// reconciliation finish any filesystem operation that could not.
|
||||
slog.Warn("desktop: topic archive cleanup remains pending")
|
||||
}
|
||||
}
|
||||
trace.phase = "delete_topic_metadata"
|
||||
if err := a.deleteTopic(topicID); err != nil {
|
||||
slog.Warn("desktop: topic archive metadata cleanup remains pending")
|
||||
} else if err := clearTopicArchiveMetadataPending(topicID); err != nil {
|
||||
slog.Warn("desktop: topic archive metadata marker cleanup remains pending")
|
||||
}
|
||||
return fallback, changedDirs, nil
|
||||
}
|
||||
|
||||
func markTopicArchiveCleanupPending(topicID string, targets []topicTrashTarget) (func(), error) {
|
||||
if err := markTopicArchiveMetadataPending(topicID, targets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
marked := make([]string, 0, len(targets))
|
||||
rollback := func() {
|
||||
for _, path := range marked {
|
||||
if err := agent.ClearCleanupPending(path); err != nil {
|
||||
slog.Warn("desktop: rollback topic archive marker failed")
|
||||
}
|
||||
}
|
||||
if err := clearTopicArchiveMetadataPending(topicID); err != nil {
|
||||
slog.Warn("desktop: rollback topic archive metadata marker failed")
|
||||
}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := agent.MarkCleanupPending(target.sessionPath, "delete"); err != nil {
|
||||
rollback()
|
||||
return rollback, err
|
||||
}
|
||||
marked = append(marked, target.sessionPath)
|
||||
}
|
||||
return rollback, nil
|
||||
}
|
||||
|
||||
type topicTrashTarget struct {
|
||||
dir string
|
||||
sessionPath string
|
||||
key string
|
||||
}
|
||||
|
||||
func (a *App) topicTrashTargets(topicID string) ([]topicTrashTarget, error) {
|
||||
topicID = strings.TrimSpace(topicID)
|
||||
var targets []topicTrashTarget
|
||||
seen := map[string]bool{}
|
||||
addTarget := func(dir, path string) error {
|
||||
sessionPath, key, err := validateSessionPath(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := dir + "\x00" + sessionPath
|
||||
if seen[id] {
|
||||
return nil
|
||||
}
|
||||
seen[id] = true
|
||||
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
|
||||
return err
|
||||
}
|
||||
targets = append(targets, topicTrashTarget{dir: dir, sessionPath: sessionPath, key: key})
|
||||
return nil
|
||||
}
|
||||
for _, dir := range a.knownSessionDirs() {
|
||||
index, err := topicSessionIndexForDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, match := range index.byTopic[topicID] {
|
||||
if !agent.IsCleanupPending(match.path) {
|
||||
if err := addTarget(dir, match.path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
a.mu.RLock()
|
||||
var runtimeTargets []struct{ dir, path string }
|
||||
for _, tab := range a.runtimeTabsLocked() {
|
||||
if tab == nil || tab.TopicID != topicID {
|
||||
continue
|
||||
}
|
||||
if path := canonicalTabSessionPath(tab.currentSessionPath()); path != "" {
|
||||
dir := tabSessionDir(tab)
|
||||
if filepath.IsAbs(path) {
|
||||
dir = filepath.Dir(path)
|
||||
}
|
||||
runtimeTargets = append(runtimeTargets, struct{ dir, path string }{dir: dir, path: path})
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
for _, target := range runtimeTargets {
|
||||
if err := addTarget(target.dir, target.path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
const topicArchiveMetadataPendingDir = "desktop-topic-archive-pending"
|
||||
|
||||
type topicArchiveMetadataPending struct {
|
||||
TopicID string `json:"topicId"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
Sessions []topicArchiveMetadataPendingSession `json:"sessions,omitempty"`
|
||||
}
|
||||
|
||||
type topicArchiveMetadataPendingSession struct {
|
||||
Dir string `json:"dir"`
|
||||
SessionPath string `json:"sessionPath"`
|
||||
}
|
||||
|
||||
func topicArchiveMetadataPendingPath(topicID string) string {
|
||||
digest := sha256.Sum256([]byte(strings.TrimSpace(topicID)))
|
||||
return filepath.Join(desktopConfigDir(), topicArchiveMetadataPendingDir, hex.EncodeToString(digest[:])+".json")
|
||||
}
|
||||
|
||||
func markTopicArchiveMetadataPending(topicID string, targets []topicTrashTarget) error {
|
||||
topicID = strings.TrimSpace(topicID)
|
||||
if topicID == "" {
|
||||
return fmt.Errorf("topicID is required")
|
||||
}
|
||||
path := topicArchiveMetadataPendingPath(topicID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
sessions := make([]topicArchiveMetadataPendingSession, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
sessions = append(sessions, topicArchiveMetadataPendingSession{Dir: target.dir, SessionPath: target.sessionPath})
|
||||
}
|
||||
body, err := json.MarshalIndent(topicArchiveMetadataPending{
|
||||
TopicID: topicID, CreatedAt: time.Now().UnixMilli(), Sessions: sessions,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(path, body, 0o644)
|
||||
}
|
||||
|
||||
func clearTopicArchiveMetadataPending(topicID string) error {
|
||||
if err := os.Remove(topicArchiveMetadataPendingPath(topicID)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listTopicArchiveMetadataPending() ([]topicArchiveMetadataPending, error) {
|
||||
dir := filepath.Join(desktopConfigDir(), topicArchiveMetadataPendingDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
pending := make([]topicArchiveMetadataPending, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var item topicArchiveMetadataPending
|
||||
if err := json.Unmarshal(body, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.TopicID = strings.TrimSpace(item.TopicID)
|
||||
if item.TopicID == "" || filepath.Base(topicArchiveMetadataPendingPath(item.TopicID)) != entry.Name() {
|
||||
return nil, fmt.Errorf("invalid topic archive metadata marker")
|
||||
}
|
||||
pending = append(pending, item)
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
func reconcileTopicArchiveMetadataPending(deleteTopic func(string) error) error {
|
||||
if deleteTopic == nil {
|
||||
return fmt.Errorf("topic archive metadata reconciler is unavailable")
|
||||
}
|
||||
pending, err := listTopicArchiveMetadataPending()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
for _, item := range pending {
|
||||
itemFailed := false
|
||||
for _, target := range item.Sessions {
|
||||
sessionPath, key, err := validateSessionPath(target.Dir, target.SessionPath)
|
||||
if err == nil {
|
||||
err = agent.MarkCleanupPending(sessionPath, "delete")
|
||||
}
|
||||
if err == nil {
|
||||
err = reconcileDesktopTrashSessionArtifacts(target.Dir, sessionPath, key)
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
itemFailed = true
|
||||
}
|
||||
}
|
||||
if err := deleteTopic(item.TopicID); err != nil {
|
||||
errs = append(errs, err)
|
||||
itemFailed = true
|
||||
}
|
||||
if itemFailed {
|
||||
continue
|
||||
}
|
||||
if err := clearTopicArchiveMetadataPending(item.TopicID); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
type topicArchiveRemovalOwnership struct {
|
||||
guard *agent.SessionRemovalGuard
|
||||
restoreTab *WorkspaceTab
|
||||
}
|
||||
|
||||
type topicArchiveOwnershipBatch struct {
|
||||
entries []*topicArchiveRemovalOwnership
|
||||
byPath map[string]*topicArchiveRemovalOwnership
|
||||
}
|
||||
|
||||
func takeTopicArchiveSessionLease(tab *WorkspaceTab, sessionPath string) *agent.SessionLease {
|
||||
if tab == nil {
|
||||
return nil
|
||||
}
|
||||
key := sessionRuntimeKey(sessionPath)
|
||||
tab.sessionLeaseMu.Lock()
|
||||
defer tab.sessionLeaseMu.Unlock()
|
||||
lease := tab.sessionLease
|
||||
if lease == nil || sessionRuntimeKey(lease.Path()) != key {
|
||||
return nil
|
||||
}
|
||||
tab.sessionLease = nil
|
||||
tab.storeSessionLeaseRuntimeKey("")
|
||||
return lease
|
||||
}
|
||||
|
||||
func topicArchiveLeaseOwners(removed []removedSessionRuntime) map[string]*WorkspaceTab {
|
||||
owners := make(map[string]*WorkspaceTab, len(removed))
|
||||
for _, item := range removed {
|
||||
if item.tab == nil || item.readOnly || item.sessionPath == "" {
|
||||
continue
|
||||
}
|
||||
key := sessionRuntimeKey(item.sessionPath)
|
||||
if key != "" && item.tab.sessionLeaseRuntimeKey() == key {
|
||||
owners[key] = item.tab
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
func acquireTopicArchiveOwnership(targets []topicTrashTarget, removed []removedSessionRuntime) (*topicArchiveOwnershipBatch, error) {
|
||||
batch := &topicArchiveOwnershipBatch{byPath: make(map[string]*topicArchiveRemovalOwnership, len(targets))}
|
||||
localOwners := topicArchiveLeaseOwners(removed)
|
||||
// Acquire historical/non-runtime sessions first. If another process owns
|
||||
// any target, the archive aborts before a local runtime lease is touched.
|
||||
for _, target := range targets {
|
||||
key := sessionRuntimeKey(target.sessionPath)
|
||||
if localOwners[key] != nil {
|
||||
continue
|
||||
}
|
||||
guard, err := acquireSessionRemovalGuard(target.sessionPath)
|
||||
if err != nil {
|
||||
batch.release()
|
||||
return nil, err
|
||||
}
|
||||
batch.add(target, guard, nil)
|
||||
}
|
||||
// Convert local runtime leases without releasing the lease lock. Converted
|
||||
// guards can be restored if marker publication or generation validation
|
||||
// fails before detach commits.
|
||||
for _, target := range targets {
|
||||
key := sessionRuntimeKey(target.sessionPath)
|
||||
tab := localOwners[key]
|
||||
if tab == nil {
|
||||
continue
|
||||
}
|
||||
lease := takeTopicArchiveSessionLease(tab, target.sessionPath)
|
||||
if lease == nil {
|
||||
batch.rollback()
|
||||
return nil, errTopicArchiveBusy
|
||||
}
|
||||
guard, err := lease.TryConvertToRemovalGuard()
|
||||
if err != nil {
|
||||
tab.adoptSessionLease(lease)
|
||||
batch.rollback()
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return nil, errSessionBusyElsewhere
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
batch.add(target, guard, tab)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (b *topicArchiveOwnershipBatch) add(target topicTrashTarget, guard *agent.SessionRemovalGuard, tab *WorkspaceTab) {
|
||||
entry := &topicArchiveRemovalOwnership{guard: guard, restoreTab: tab}
|
||||
b.entries = append(b.entries, entry)
|
||||
b.byPath[sessionRuntimeKey(target.sessionPath)] = entry
|
||||
}
|
||||
|
||||
func (b *topicArchiveOwnershipBatch) take(sessionPath string) *agent.SessionRemovalGuard {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
entry := b.byPath[sessionRuntimeKey(sessionPath)]
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
guard := entry.guard
|
||||
entry.guard = nil
|
||||
return guard
|
||||
}
|
||||
|
||||
func (b *topicArchiveOwnershipBatch) rollback() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range slices.Backward(b.entries) {
|
||||
if entry.guard == nil {
|
||||
continue
|
||||
}
|
||||
if entry.restoreTab != nil {
|
||||
lease, err := entry.guard.RestoreSessionLease()
|
||||
if err != nil {
|
||||
slog.Warn("desktop: restoring topic archive session ownership failed")
|
||||
entry.guard.Release()
|
||||
} else {
|
||||
entry.restoreTab.adoptSessionLease(lease)
|
||||
}
|
||||
} else {
|
||||
entry.guard.Release()
|
||||
}
|
||||
entry.guard = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *topicArchiveOwnershipBatch) release() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range b.entries {
|
||||
if entry.guard != nil {
|
||||
entry.guard.Release()
|
||||
entry.guard = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func delayedDesktopTopicTrash(dir, sessionPath, key string, guard *agent.SessionRemovalGuard, destroys []control.SessionDestroyHandle) {
|
||||
waitAllDestroyHandles(destroys)
|
||||
if err := trashSessionArtifactsWithGuard(dir, sessionPath, key, guard); err != nil {
|
||||
slog.Warn("desktop: delayed topic archive cleanup remains pending")
|
||||
}
|
||||
finishDestroyHandles(destroys)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
func (a *App) captureTopicRuntimeBindings(topicID string) []removedSessionRuntime {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
var captured []removedSessionRuntime
|
||||
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
|
||||
for _, tab := range tabs {
|
||||
if tab == nil || tab.TopicID != topicID {
|
||||
continue
|
||||
}
|
||||
captured = append(captured, removedRuntimeFromTab(tab, tabRuntimeSessionDir(tab), canonicalTabSessionPath(tab.currentSessionPath())))
|
||||
}
|
||||
}
|
||||
return captured
|
||||
}
|
||||
|
||||
// snapshotTopicRuntimeBindings keeps bindings usable until every writable
|
||||
// controller snapshots. The caller owns runtime admission, blocking new turns.
|
||||
func (a *App) snapshotTopicRuntimeBindings(captured []removedSessionRuntime) error {
|
||||
for _, item := range captured {
|
||||
if item.ctrl == nil || item.readOnly {
|
||||
continue
|
||||
}
|
||||
if item.ctrl.Running() {
|
||||
return errTopicHasActiveWork
|
||||
}
|
||||
if err := item.ctrl.Snapshot(); err != nil {
|
||||
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||||
return err
|
||||
}
|
||||
kind, _ := agent.SnapshotConflictKind(err)
|
||||
slog.Warn("desktop: skipping stale runtime snapshot before removing topic", "conflict_kind", kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func topicRuntimeBindingsMatchLocked(tabs map[string]*WorkspaceTab, topicID string, expected map[*WorkspaceTab]removedSessionRuntime) (int, bool) {
|
||||
matched := 0
|
||||
for _, tab := range tabs {
|
||||
if tab == nil || tab.TopicID != topicID {
|
||||
continue
|
||||
}
|
||||
item, ok := expected[tab]
|
||||
if !ok || tab.Ctrl != item.ctrl || tabRuntimeSessionDir(tab) != item.sessionDir || canonicalTabSessionPath(tab.currentSessionPath()) != item.sessionPath {
|
||||
return 0, false
|
||||
}
|
||||
matched++
|
||||
}
|
||||
return matched, true
|
||||
}
|
||||
|
||||
// removeTopicRuntimeBindingsIfUnchanged commits only when the captured runtime
|
||||
// generation still matches, so stale completion cannot remove a rebound tab.
|
||||
func (a *App) removeTopicRuntimeBindingsIfUnchanged(topicID string, captured []removedSessionRuntime) (fallbackRuntimeTarget, bool) {
|
||||
expected := make(map[*WorkspaceTab]removedSessionRuntime, len(captured))
|
||||
for _, item := range captured {
|
||||
expected[item.tab] = item
|
||||
}
|
||||
a.mu.Lock()
|
||||
visible, visibleOK := topicRuntimeBindingsMatchLocked(a.tabs, topicID, expected)
|
||||
detached, detachedOK := topicRuntimeBindingsMatchLocked(a.detachedSessions, topicID, expected)
|
||||
if !visibleOK || !detachedOK || visible+detached != len(captured) {
|
||||
a.mu.Unlock()
|
||||
return fallbackRuntimeTarget{}, false
|
||||
}
|
||||
var fallback fallbackRuntimeTarget
|
||||
fallbackSet := false
|
||||
for id, tab := range a.tabs {
|
||||
if _, ok := expected[tab]; !ok {
|
||||
continue
|
||||
}
|
||||
if !fallbackSet {
|
||||
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
|
||||
fallbackSet = true
|
||||
}
|
||||
a.markTabRemovedLocked(tab)
|
||||
delete(a.tabs, id)
|
||||
a.removeTabOrderLocked(id)
|
||||
if a.activeTabID == id {
|
||||
a.activeTabID = ""
|
||||
}
|
||||
}
|
||||
for key, tab := range a.detachedSessions {
|
||||
if _, ok := expected[tab]; !ok {
|
||||
continue
|
||||
}
|
||||
if !fallbackSet {
|
||||
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
|
||||
fallbackSet = true
|
||||
}
|
||||
a.markTabRemovedLocked(tab)
|
||||
delete(a.detachedSessions, key)
|
||||
}
|
||||
if a.activeTabID == "" && len(a.tabOrder) > 0 {
|
||||
a.activeTabID = a.tabOrder[0]
|
||||
}
|
||||
fallback.needs = len(captured) > 0 && len(a.tabs) == 0
|
||||
dir, entries, activeID, version := a.saveTabsCollectLocked()
|
||||
a.mu.Unlock()
|
||||
a.saveTabsWrite(dir, entries, activeID, version)
|
||||
return fallback, true
|
||||
}
|
||||
|
||||
func (a *App) finalizeRemovedTopicRuntimes(removed []removedSessionRuntime) {
|
||||
for _, item := range removed {
|
||||
if item.sink != nil {
|
||||
item.sink.clearContext()
|
||||
}
|
||||
if item.ctrl == nil || item.readOnly {
|
||||
continue
|
||||
}
|
||||
item.ctrl.SetSessionPath("")
|
||||
a.quiesceTabAutosave(item.tab)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) tryLockRuntimeMutation(operation string) (func(), bool) {
|
||||
if hook := a.runtimeMutationBeforeLockHook; hook != nil {
|
||||
hook(operation)
|
||||
}
|
||||
if !a.runtimeRebuildMu.TryLock() {
|
||||
return nil, false
|
||||
}
|
||||
if !a.runtimeAdmissionMu.TryLock() {
|
||||
a.runtimeRebuildMu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
return func() {
|
||||
a.runtimeAdmissionMu.Unlock()
|
||||
a.runtimeRebuildMu.Unlock()
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
type snapshotErrorSessionController struct {
|
||||
control.SessionAPI
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *snapshotErrorSessionController) Snapshot() error { return c.err }
|
||||
|
||||
func TestTrashTopicSnapshotFailureKeepsRuntimeAndFiles(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_snapshot_failure"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Snapshot failure"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSessionWithPrompt(t, dir, "snapshot-failure.jsonl", topicID, "Snapshot failure", projectRoot, "preserve me", time.Now())
|
||||
base := controllerWithContent(t, sessionPath)
|
||||
defer base.Close()
|
||||
snapshotErr := errors.New("snapshot blocked")
|
||||
ctrl := &snapshotErrorSessionController{SessionAPI: base, err: snapshotErr}
|
||||
tab := &WorkspaceTab{ID: "snapshot-failure", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID,
|
||||
TopicTitle: "Snapshot failure", SessionPath: sessionPath, Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
|
||||
if err := app.TrashTopic(topicID); !errors.Is(err, snapshotErr) {
|
||||
t.Fatalf("TrashTopic snapshot error = %v, want %v", err, snapshotErr)
|
||||
}
|
||||
if got := app.tabs[tab.ID]; got != tab || tab.removed {
|
||||
t.Fatalf("snapshot failure changed runtime binding: got=%p removed=%v", got, tab.removed)
|
||||
}
|
||||
if got := ctrl.SessionPath(); !sameDesktopPath(got, sessionPath) {
|
||||
t.Fatalf("snapshot failure session path = %q, want %q", got, sessionPath)
|
||||
}
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("snapshot failure must not publish a cleanup-pending marker")
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
t.Fatalf("snapshot failure removed the session file: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "Snapshot failure" {
|
||||
t.Fatalf("snapshot failure topic title = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicRejectsConcurrentRuntimeMutationWithoutWaiting(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
topicID := "topic_runtime_mutation_busy"
|
||||
if err := setTopicTitle("", topicID, "Runtime mutation busy"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
app := &App{}
|
||||
app.runtimeRebuildMu.Lock()
|
||||
started := time.Now()
|
||||
err := app.TrashTopic(topicID)
|
||||
elapsed := time.Since(started)
|
||||
app.runtimeRebuildMu.Unlock()
|
||||
if !errors.Is(err, errTopicArchiveBusy) {
|
||||
t.Fatalf("TrashTopic error = %v, want %v", err, errTopicArchiveBusy)
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("TrashTopic waited %s behind another runtime mutation", elapsed)
|
||||
}
|
||||
if got := loadTopicTitle("", topicID); got != "Runtime mutation busy" {
|
||||
t.Fatalf("busy archive changed topic title to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicForeignLeaseFailsBeforeCleanupCommit(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_foreign_lease"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Foreign lease"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSessionWithPrompt(t, dir, "foreign-lease.jsonl", topicID, "Foreign lease", projectRoot, "preserve me", time.Now())
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestTrashTopicForeignLeaseHelper$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"REASONIX_TOPIC_ARCHIVE_LEASE_HELPER=1",
|
||||
"REASONIX_TOPIC_ARCHIVE_LEASE_PATH="+sessionPath,
|
||||
)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("lease helper stdin: %v", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("lease helper stdout: %v", err)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start lease helper: %v", err)
|
||||
}
|
||||
released := false
|
||||
release := func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
_ = stdin.Close()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Errorf("lease helper exit: %v (%s)", err, stderr.String())
|
||||
}
|
||||
}
|
||||
defer release()
|
||||
ready, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil || strings.TrimSpace(ready) != "ready" {
|
||||
t.Fatalf("lease helper readiness = %q, err=%v (%s)", ready, err, stderr.String())
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if err := app.TrashTopic(topicID); !errors.Is(err, errSessionBusyElsewhere) {
|
||||
t.Fatalf("TrashTopic foreign lease error = %v, want %v", err, errSessionBusyElsewhere)
|
||||
}
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("rejected archive published a cleanup-pending marker")
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "Foreign lease" {
|
||||
t.Fatalf("rejected archive topic title = %q", got)
|
||||
}
|
||||
if err := reconcileDesktopCleanupPending(dir); err != nil {
|
||||
t.Fatalf("reconcile without marker: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
t.Fatalf("rejected archive removed the live session: %v", err)
|
||||
}
|
||||
|
||||
release()
|
||||
if err := reconcileDesktopCleanupPending(dir); err != nil {
|
||||
t.Fatalf("reconcile after foreign release: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
t.Fatalf("failed archive scheduled a later deletion: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicForeignLeaseHelper(t *testing.T) {
|
||||
if os.Getenv("REASONIX_TOPIC_ARCHIVE_LEASE_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(os.Getenv("REASONIX_TOPIC_ARCHIVE_LEASE_PATH"))
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
if _, err := os.Stdout.WriteString("ready\n"); err != nil {
|
||||
lease.Release()
|
||||
t.Fatalf("write readiness: %v", err)
|
||||
}
|
||||
var release [1]byte
|
||||
_, _ = os.Stdin.Read(release[:])
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
func TestSnapshotTopicRuntimeConflictLogOmitsSessionPath(t *testing.T) {
|
||||
privatePath := filepath.Join(t.TempDir(), "private-session.jsonl")
|
||||
base := control.New(control.Options{})
|
||||
defer base.Close()
|
||||
ctrl := &snapshotErrorSessionController{SessionAPI: base, err: &agent.SessionSnapshotConflictError{
|
||||
Path: privatePath,
|
||||
Kind: agent.SessionSnapshotConflictDiverged,
|
||||
}}
|
||||
var output bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
|
||||
defer slog.SetDefault(previous)
|
||||
|
||||
app := NewApp()
|
||||
if err := app.snapshotTopicRuntimeBindings([]removedSessionRuntime{{ctrl: ctrl}}); err != nil {
|
||||
t.Fatalf("snapshotTopicRuntimeBindings: %v", err)
|
||||
}
|
||||
logged := output.String()
|
||||
if strings.Contains(logged, privatePath) {
|
||||
t.Fatalf("snapshot conflict log exposed the session path: %s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, string(agent.SessionSnapshotConflictDiverged)) {
|
||||
t.Fatalf("snapshot conflict log omitted the safe conflict kind: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicConvertsLocalLeaseWithoutUnlockWindow(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_local_lease"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Local lease"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSessionWithPrompt(t, dir, "local-lease.jsonl", topicID, "Local lease", projectRoot, "preserve me", time.Now())
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
|
||||
defer ctrl.Close()
|
||||
tab := &WorkspaceTab{ID: "local", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID,
|
||||
TopicTitle: "Local lease", SessionPath: sessionPath, Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}}
|
||||
if err := tab.ensureSessionLease(sessionPath); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
keep := &WorkspaceTab{ID: "keep", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "keep", Ready: true}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab, keep.ID: keep}, tabOrder: []string{tab.ID, keep.ID}, activeTabID: tab.ID}
|
||||
|
||||
if err := app.TrashTopic(" " + topicID + " "); err != nil {
|
||||
t.Fatalf("TrashTopic: %v", err)
|
||||
}
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("completed archive left cleanup pending")
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("archived session still exists, err=%v", err)
|
||||
}
|
||||
for _, path := range []string{store.SessionLeaseInfo(sessionPath), store.SessionLeaseLock(sessionPath), store.SessionLockFile(sessionPath)} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("ownership sidecar survived archive: %s (err=%v)", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicCommittedCleanupFailureReconcilesWithoutFailureResponse(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_committed_cleanup"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Committed cleanup"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSessionWithPrompt(t, dir, "committed-cleanup.jsonl", topicID, "Committed cleanup", projectRoot, "preserve me", time.Now())
|
||||
topicArchiveCleanupHookForTest = func() error { return errors.New("injected cleanup failure") }
|
||||
defer func() { topicArchiveCleanupHookForTest = nil }()
|
||||
|
||||
app := NewApp()
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("committed TrashTopic returned a failure: %v", err)
|
||||
}
|
||||
if !agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("committed cleanup failure did not retain its durable marker")
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("committed archive retained topic title %q", got)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
t.Fatalf("deferred session disappeared before reconciliation: %v", err)
|
||||
}
|
||||
|
||||
topicArchiveCleanupHookForTest = nil
|
||||
if err := reconcileDesktopCleanupPending(dir); err != nil {
|
||||
t.Fatalf("reconcile committed cleanup: %v", err)
|
||||
}
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("reconciliation retained the cleanup marker")
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("reconciled session still exists, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicMetadataFailureReconcilesFromDurableIntent(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_metadata_retry"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Metadata retry"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
writeTopicSessionWithPrompt(t, dir, "metadata-retry.jsonl", topicID, "Metadata retry", projectRoot, "preserve me", time.Now())
|
||||
blockedTempPath := topicTitleSourcesPath(projectRoot) + ".tmp"
|
||||
if err := os.MkdirAll(blockedTempPath, 0o755); err != nil {
|
||||
t.Fatalf("block metadata temp write: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("committed TrashTopic returned a failure: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "Metadata retry" {
|
||||
t.Fatalf("failed metadata cleanup title = %q, want retained retry locator", got)
|
||||
}
|
||||
if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); err != nil {
|
||||
t.Fatalf("metadata cleanup intent was not retained: %v", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(blockedTempPath); err != nil {
|
||||
t.Fatalf("unblock metadata temp write: %v", err)
|
||||
}
|
||||
if err := reconcileTopicArchiveMetadataPending(app.deleteTopic); err != nil {
|
||||
t.Fatalf("reconcile topic metadata: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("reconciled archive retained topic title %q", got)
|
||||
}
|
||||
if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("reconciled metadata marker still exists, err=%v", err)
|
||||
}
|
||||
projects := loadProjectsFile()
|
||||
for _, project := range projects.Projects {
|
||||
if containsDesktopString(project.Topics, topicID) || containsDesktopString(project.PinnedTopics, topicID) {
|
||||
t.Fatalf("reconciled archive retained topic in project index: %+v", project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicArchiveIntentCompletesSessionsAndMetadataAfterRestart(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_restart_commit"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Restart commit"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSessionWithPrompt(t, dir, "restart-commit.jsonl", topicID, "Restart commit", projectRoot, "preserve me", time.Now())
|
||||
targets := []topicTrashTarget{{dir: dir, sessionPath: sessionPath, key: filepath.Base(sessionPath)}}
|
||||
if err := markTopicArchiveMetadataPending(topicID, targets); err != nil {
|
||||
t.Fatalf("mark archive intent: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if err := reconcileTopicArchiveMetadataPending(app.deleteTopic); err != nil {
|
||||
t.Fatalf("reconcile archive intent: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("reconciled live session still exists, err=%v", err)
|
||||
}
|
||||
trashPath := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath), filepath.Base(sessionPath))
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("reconciled session was not preserved in trash: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("reconciled archive retained topic title %q", got)
|
||||
}
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("reconciled archive retained session cleanup marker")
|
||||
}
|
||||
if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("reconciled archive retained topic marker, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicPreservesDivergedExistingTrash(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_diverged_trash"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Diverged trash"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSessionWithPrompt(t, dir, "diverged-trash.jsonl", topicID, "Diverged trash", projectRoot, "new live history", time.Now())
|
||||
existingTrashPath := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath), filepath.Base(sessionPath))
|
||||
existing := agent.NewSession("system")
|
||||
existing.Add(provider.Message{Role: provider.RoleUser, Content: "older trash history"})
|
||||
if err := existing.SaveSnapshot(existingTrashPath); err != nil {
|
||||
t.Fatalf("write existing trash: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("TrashTopic: %v", err)
|
||||
}
|
||||
trashed, err := listTrashedSessionFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("listTrashedSessionFiles: %v", err)
|
||||
}
|
||||
if len(trashed) != 2 {
|
||||
t.Fatalf("trashed session count = %d, want both histories: %v", len(trashed), trashed)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, path := range trashed {
|
||||
session, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession(%q): %v", path, err)
|
||||
}
|
||||
for _, message := range session.Snapshot() {
|
||||
seen[message.Content] = true
|
||||
}
|
||||
}
|
||||
for _, content := range []string{"older trash history", "new live history"} {
|
||||
if !seen[content] {
|
||||
t.Fatalf("archived histories = %#v, missing %q", seen, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicArchiveOwnershipRollbackRestoresLocalLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "rollback-lease.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{ID: "rollback", SessionPath: sessionPath, Ready: true}
|
||||
if err := tab.ensureSessionLease(sessionPath); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
defer tab.releaseSessionLease()
|
||||
removed := []removedSessionRuntime{{tab: tab, sessionDir: dir, sessionPath: sessionPath}}
|
||||
targets := []topicTrashTarget{{dir: dir, sessionPath: sessionPath, key: filepath.Base(sessionPath)}}
|
||||
|
||||
batch, err := acquireTopicArchiveOwnership(targets, removed)
|
||||
if err != nil {
|
||||
t.Fatalf("acquireTopicArchiveOwnership: %v", err)
|
||||
}
|
||||
if tab.sessionLeaseRuntimeKey() != "" {
|
||||
batch.rollback()
|
||||
t.Fatal("converted ownership remained attached to the runtime tab")
|
||||
}
|
||||
batch.rollback()
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != sessionRuntimeKey(sessionPath) {
|
||||
t.Fatalf("rolled back lease key = %q, want %q", got, sessionRuntimeKey(sessionPath))
|
||||
}
|
||||
if next, err := agent.TryAcquireSessionLease(sessionPath); !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
if next != nil {
|
||||
next.Release()
|
||||
}
|
||||
t.Fatalf("competing lease after rollback err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ResolveWorkspacePathForTab returns the absolute local path represented by a
|
||||
// workspace-relative path or a session-authorized external folder reference.
|
||||
// Keeping this resolution in the backend ensures the renderer cannot mistake an
|
||||
// external reference token for a child of the workspace root.
|
||||
func (a *App) ResolveWorkspacePathForTab(tabID, rel string) (string, error) {
|
||||
path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", os.ErrInvalid
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
func TestResolveWorkspacePathForTab(t *testing.T) {
|
||||
parentRoot := robustTempDir(t)
|
||||
childRoot := filepath.Join(parentRoot, "child")
|
||||
if err := os.MkdirAll(childRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{
|
||||
"child": {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
|
||||
}}
|
||||
|
||||
absolutePath, err := app.ResolveWorkspacePathForTab("child", "shared.txt")
|
||||
if err != nil || absolutePath != filepath.Join(childRoot, "shared.txt") {
|
||||
t.Fatalf("ResolveWorkspacePathForTab(child) = (%q, %v)", absolutePath, err)
|
||||
}
|
||||
if _, err := app.ResolveWorkspacePathForTab("missing", "shared.txt"); err == nil {
|
||||
t.Fatal("ResolveWorkspacePathForTab accepted a missing tab")
|
||||
}
|
||||
if _, err := app.ResolveWorkspacePathForTab("child", "../parent-only.txt"); err == nil {
|
||||
t.Fatal("ResolveWorkspacePathForTab accepted a path outside the requested tab workspace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkspacePathForTabExternalFolder(t *testing.T) {
|
||||
external := filepath.Join(robustTempDir(t), "Folder With Spaces")
|
||||
if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedExternal := external
|
||||
if resolved, err := filepath.EvalSymlinks(external); err == nil {
|
||||
expectedExternal = resolved
|
||||
}
|
||||
ctrl := &control.Controller{}
|
||||
token, _, err := ctrl.RegisterExternalFolderRef(external)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterExternalFolderRef: %v", err)
|
||||
}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: robustTempDir(t), Ctrl: ctrl},
|
||||
}}
|
||||
|
||||
absolutePath, err := app.ResolveWorkspacePathForTab("project", token+"/src/outside.txt")
|
||||
want := filepath.Join(expectedExternal, "src", "outside.txt")
|
||||
if err != nil || absolutePath != want {
|
||||
t.Fatalf("ResolveWorkspacePathForTab external = (%q, %v), want %q", absolutePath, err, want)
|
||||
}
|
||||
}
|
||||
+32
-13
@@ -14,7 +14,7 @@
|
||||
- [Configuration](#configuration)
|
||||
- [CLI reference](./CLI.md)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Serve web frontend](#serve-web-frontend)
|
||||
- [Web frontend](#web-frontend)
|
||||
- [Configuration paths](./CONFIG_PATHS.md)
|
||||
- [Reasoning language](./REASONING_LANGUAGE.md)
|
||||
- [Task contracts and pause policy](./TASK_CONTRACT.md)
|
||||
@@ -69,6 +69,7 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills)
|
||||
# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths
|
||||
tool_result_snip_ratio = 0.6 # shorten stale tool output before summary compaction
|
||||
# context_editing = "native" # opt in only for the official Anthropic endpoint; default local
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
@@ -205,23 +206,39 @@ these separately reviewed reports. Runtime fatal throws, operating-system kills,
|
||||
and panics in unwrapped background goroutines cannot be recovered by Go and do
|
||||
not produce this local report.
|
||||
|
||||
## Serve web frontend
|
||||
## Web frontend
|
||||
|
||||
`reasonix serve` starts the same local engine behind a browser UI. Use it when
|
||||
you want a desktop-style surface without installing the desktop app, when running
|
||||
Reasonix on a remote development box through a tunnel, or when you want a
|
||||
shareable view of a live session.
|
||||
For local use, `reasonix web` starts the browser UI and opens it in your default
|
||||
browser. Inside an interactive CLI session, `/web` snapshots the current session,
|
||||
restores the terminal, and opens an explicit `/sessions/<id>#token=...` deep link.
|
||||
Even a never-used session keeps its reserved ID without forcing an empty
|
||||
transcript onto disk, so the first Web turn continues the same session identity.
|
||||
|
||||
```bash
|
||||
cd your-project
|
||||
reasonix serve
|
||||
# open http://127.0.0.1:8787
|
||||
reasonix web
|
||||
```
|
||||
|
||||
By default it listens on `127.0.0.1:8787` with `auth_mode = "none"`. Keep that
|
||||
default for local-only use. If you bind outside loopback, expose it through a
|
||||
tunnel, or put it behind a reverse proxy, enable authentication before sharing
|
||||
the URL:
|
||||
Use `reasonix web --no-open` when you want to start the foreground Web server
|
||||
and print its URL without opening a browser tab. The lower-level
|
||||
`reasonix serve` command starts the same engine without opening a browser by
|
||||
default. It remains the right entry point for remote development boxes,
|
||||
supervisors, tunnels, reverse proxies, and shareable authenticated sessions.
|
||||
|
||||
`reasonix web` starts at `127.0.0.1:8787`, automatically tries 8788, 8789, and
|
||||
so on when a port is busy (up to 100 retries), and defaults to a newly generated
|
||||
token even when `[serve].auth_mode` is `none`. Each live process registers a
|
||||
single-writer heartbeat file under `<Reasonix home>/server/instances/`; clean
|
||||
shutdown removes its own file, while later instances lazily remove records whose
|
||||
owner process is confirmed dead. Multiple Web instances can therefore share one
|
||||
Reasonix home without overwriting registry state. The process stays attached to
|
||||
the terminal; stop it with Ctrl-C.
|
||||
|
||||
An explicit `reasonix web --auth none` disables the default token and should be
|
||||
used only when the listener is intentionally trusted. `reasonix serve` keeps its
|
||||
backward-compatible, config-driven `auth_mode = "none"` default on
|
||||
`127.0.0.1:8787`. If you bind Serve outside loopback, expose it through a tunnel,
|
||||
or put it behind a reverse proxy, enable authentication before sharing the URL:
|
||||
|
||||
```bash
|
||||
reasonix serve --auth token
|
||||
@@ -229,7 +246,9 @@ reasonix serve --addr 0.0.0.0:8787 --auth token
|
||||
reasonix serve --auth password --password 'temporary-password'
|
||||
```
|
||||
|
||||
Token mode prints a share URL with `?token=...`; pass `--token` or set
|
||||
Token mode prints a share URL with `#token=...`; the Web page exchanges the
|
||||
fragment for an HttpOnly cookie before starting API or SSE requests, keeping the
|
||||
token out of request URLs, browser history, referrers, and access logs. Pass `--token` or set
|
||||
`[serve].token` to reuse a stable token. Password mode requires either
|
||||
`--password` at startup or a stored bcrypt hash:
|
||||
|
||||
|
||||
+26
-9
@@ -14,7 +14,7 @@
|
||||
- [配置](#配置)
|
||||
- [CLI 命令参考](./CLI.zh-CN.md)
|
||||
- [环境变量](#环境变量)
|
||||
- [Serve Web 前端](#serve-web-前端)
|
||||
- [Web 前端](#web-前端)
|
||||
- [配置路径](./CONFIG_PATHS.zh-CN.md)
|
||||
- [思考语言](./REASONING_LANGUAGE.zh-CN.md)
|
||||
- [任务合约与暂停策略](./TASK_CONTRACT.zh-CN.md)
|
||||
@@ -63,6 +63,7 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
|
||||
# max_subagent_concurrency = 6 # 会话级子代理总并发(task/fleet/skills)
|
||||
# max_parallel_writers = 3 # 互不重叠 write_paths 时的并行写入上限
|
||||
tool_result_snip_ratio = 0.6 # 在摘要 compaction 前先缩短旧工具输出
|
||||
# context_editing = "native" # 仅官方 Anthropic 端点显式启用;默认 local
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
@@ -184,19 +185,33 @@ reasonix report delete [ID] # 不发送,直接删除
|
||||
需要单独审阅的报告。Go 无法恢复 runtime fatal throw、操作系统强制终止,以及未包装
|
||||
后台 goroutine 中的 panic,因此这些情况不会生成本地报告。
|
||||
|
||||
## Serve Web 前端
|
||||
## Web 前端
|
||||
|
||||
`reasonix serve` 会用同一个本地 Reasonix 引擎启动浏览器 UI。适合不安装桌面端但想用可视化界面、
|
||||
在远程开发机上通过 tunnel 使用,或把当前会话临时共享给浏览器查看的场景。
|
||||
本机使用时,`reasonix web` 会启动浏览器 UI,并自动用默认浏览器打开。也可以在 CLI 交互会话中
|
||||
执行 `/web`:Reasonix 会保存当前会话、恢复终端,然后打开明确的
|
||||
`/sessions/<id>#token=...` 深链。即使会话尚未产生第一轮消息,也会延续已预留的 Session ID,
|
||||
同时继续保持“空会话不提前写 transcript”的惰性落盘行为。
|
||||
|
||||
```bash
|
||||
cd your-project
|
||||
reasonix serve
|
||||
# 打开 http://127.0.0.1:8787
|
||||
reasonix web
|
||||
```
|
||||
|
||||
默认监听 `127.0.0.1:8787`,认证模式是 `auth_mode = "none"`。这个默认值只适合本机使用。
|
||||
如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,请先开启认证再分享 URL:
|
||||
如果想启动前台 Web 服务并打印地址、但不自动新开浏览器标签页,可使用
|
||||
`reasonix web --no-open`。底层的 `reasonix serve`
|
||||
默认不会打开浏览器,继续用于远程开发机、进程托管、tunnel、反向代理和需要认证分享的场景。
|
||||
|
||||
`reasonix web` 从 `127.0.0.1:8787` 开始监听;端口占用时会依次尝试 8788、8789……,
|
||||
最多递增重试 100 次。它默认启用自动生成的 Token,即使配置中的 `[serve].auth_mode`
|
||||
是 `none` 也一样。每个运行实例都会在 `<Reasonix home>/server/instances/` 下写入自己的
|
||||
单写者 heartbeat 文件;正常退出时只删除自己的文件,新实例则会惰性清理已确认进程死亡的记录。
|
||||
因此多个 Web 实例可以共用同一个 Reasonix home,而不会相互覆盖登记状态。服务保持在前台运行,
|
||||
按 Ctrl-C 停止。
|
||||
|
||||
显式传入 `reasonix web --auth none` 可以关闭默认 Token,只应在监听地址确定可信时使用。
|
||||
`reasonix serve` 则保持向后兼容:默认监听 `127.0.0.1:8787`,认证模式仍由配置决定,空配置为
|
||||
`auth_mode = "none"`。如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,
|
||||
请先开启认证再分享 URL:
|
||||
|
||||
```bash
|
||||
reasonix serve --auth token
|
||||
@@ -204,7 +219,9 @@ reasonix serve --addr 0.0.0.0:8787 --auth token
|
||||
reasonix serve --auth password --password 'temporary-password'
|
||||
```
|
||||
|
||||
Token 模式会在终端打印带 `?token=...` 的分享链接;可通过 `--token` 或 `[serve].token`
|
||||
Token 模式会在终端打印带 `#token=...` 的分享链接;Web 页面会先将 fragment 换成
|
||||
HttpOnly Cookie,再启动 API 与 SSE 请求,从而避免 Token 进入请求 URL、浏览器历史、
|
||||
Referrer 和访问日志。可通过 `--token` 或 `[serve].token`
|
||||
复用固定 token。Password 模式必须在启动时传 `--password`,或在配置里保存 bcrypt hash:
|
||||
|
||||
```bash
|
||||
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user