mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b09d419d | ||
|
|
e4279b0dee | ||
|
|
a3b98e5620 | ||
|
|
f54e1e70b0 | ||
|
|
314beed8f5 | ||
|
|
352d171b32 | ||
|
|
abf7bf86bc | ||
|
|
d473b5a9b1 | ||
|
|
515026e11c | ||
|
|
b3bdd16098 | ||
|
|
f60ab17b0a | ||
|
|
01cdd70dbc | ||
|
|
78cc493af7 | ||
|
|
3294407384 | ||
|
|
edcb2107b6 | ||
|
|
28e202ec71 | ||
|
|
8b9e779ec3 | ||
|
|
27222e10a2 | ||
|
|
b4d59a3886 | ||
|
|
8330752a06 | ||
|
|
4f1cae1061 | ||
|
|
76f8d6b6f9 | ||
|
|
2dfe5e75dc | ||
|
|
d6a743a118 | ||
|
|
5190740b0f | ||
|
|
a88f117a66 | ||
|
|
6589c5679c | ||
|
|
297463f9d4 | ||
|
|
1b725b0c56 | ||
|
|
e2986d4d59 | ||
|
|
c28511e6af | ||
|
|
6a78ee7c6b | ||
|
|
8f43c251b2 | ||
|
|
b41b4de9df | ||
|
|
f96c4c0867 | ||
|
|
56c1793bb4 | ||
|
|
22787f6804 | ||
|
|
adc0292c67 | ||
|
|
37e9547d38 | ||
|
|
4206bb5ad9 | ||
|
|
d42cfd1942 | ||
|
|
de3618b275 | ||
|
|
3da97d401e | ||
|
|
3acb3c5b0d | ||
|
|
b58e3e3539 | ||
|
|
15abb0394b | ||
|
|
c4fbdd1d07 | ||
|
|
434bf27500 | ||
|
|
0b749791b3 | ||
|
|
511d7ff741 | ||
|
|
4bf88ad6bc | ||
|
|
0cc808a884 | ||
|
|
49f24d1970 | ||
|
|
ca686b17e0 | ||
|
|
345a71dc71 | ||
|
|
ebdd48354c | ||
|
|
c7bc2f3e19 | ||
|
|
9aaf8d381a | ||
|
|
42a9de71de | ||
|
|
7b82fc3bc8 | ||
|
|
fd8d85ed65 | ||
|
|
64fb71cc3f | ||
|
|
f525db811e | ||
|
|
730454e7b6 | ||
|
|
fa64f632d6 | ||
|
|
6e8459c0ad | ||
|
|
1ac7817ff3 | ||
|
|
940aa4a8a6 | ||
|
|
a59a27915e | ||
|
|
c69a9df7e7 | ||
|
|
cb066bc22f | ||
|
|
ddff911ace | ||
|
|
8fc6882c22 | ||
|
|
f53edb599a |
No files matched your search
@@ -145,14 +145,15 @@ jobs:
|
||||
# internal/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
|
||||
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
|
||||
# step failed on slow runners without a code change; a hang still trips
|
||||
# the 15 minute job budget above.
|
||||
# the 15 minute job budget above. internal/control runs in its own
|
||||
# Windows job below so its durable-session I/O does not compete here.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=8m ./internal/agent/... ./internal/appidentity/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
|
||||
run: go test -p 4 -timeout=8m ./internal/agent/... ./internal/appidentity/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -195,6 +196,30 @@ jobs:
|
||||
shell: powershell
|
||||
run: .\scripts\test-scoop-desktop-launch.ps1
|
||||
|
||||
windows-control:
|
||||
needs: changes
|
||||
if: always()
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
|
||||
steps:
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: test
|
||||
if: env.RUN_STEPS == 'true'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 1 -timeout=8m ./internal/control/...
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
@@ -528,9 +553,14 @@ jobs:
|
||||
|
||||
- name: test (Windows desktop and update helper)
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
go test ./...
|
||||
(cd third_party/go-webview2 && go test ./pkg/edge)
|
||||
run: go test ./...
|
||||
|
||||
# A cold nested-module cache can spend several minutes compiling the
|
||||
# real WebView2 cookie integration test. Keep its budget independent of
|
||||
# the already-complete desktop suite and retain a Go timeout stack dump.
|
||||
- name: test (vendored WebView2 edge)
|
||||
timeout-minutes: 10
|
||||
run: (cd third_party/go-webview2 && go test -timeout=8m ./pkg/edge)
|
||||
|
||||
# Installer packaging is packaging validation, not a code gate: run it
|
||||
# on pushes to main-v2 (the release pipeline builds installers again
|
||||
|
||||
@@ -6,6 +6,18 @@ branch.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fact-driven execution:** Ordinary requests always enter the executor.
|
||||
There is no automatic simple / light / full task mode and no per-turn
|
||||
`TaskPolicy` classification. The planner runs only for an explicit Plan,
|
||||
an approval boundary, or Goal start. The host builds verification
|
||||
obligations from concrete tool effects and receipts. Plan, Goal,
|
||||
permission, and sandbox stay independent. Tool schemas and the executor
|
||||
system prefix stay byte-stable. Historical `<execution-policy>` tags remain
|
||||
readable on old sessions and are stripped from new provider context.
|
||||
Old `--preset`/`--profile` compatibility no-ops are unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **v1.24.2 session snapshot & recovery root fix:** Keep PR #7982's WAL/CAS/lease
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ Default is none — the code is the truth. Write one only when the **why** is
|
||||
non-obvious: a hidden constraint, a workaround anchored to something verifiable,
|
||||
an invariant the type system cannot express, or an external-protocol quirk.
|
||||
|
||||
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
|
||||
- Declaration doc: ≤15 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
|
||||
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
|
||||
- Never: restatements of the code, phase/stage narrative, incident or
|
||||
conversation history, section banners, commented-out code, `@param` lists.
|
||||
|
||||
@@ -64,7 +64,7 @@ func anchorSkip(cfg suiteConfig, t task) (result, bool) {
|
||||
return result{}, false
|
||||
}
|
||||
return result{
|
||||
task: t, Profile: cfg.profile, Anchor: cfg.anchor, Skipped: true,
|
||||
task: t, Profile: benchmarkProfileStandard, Anchor: cfg.anchor, Skipped: true,
|
||||
Note: "skipped: no seed_" + cfg.anchor + " authored for this task",
|
||||
}, true
|
||||
}
|
||||
|
||||
+6
-16
@@ -16,9 +16,9 @@ import (
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
bin, model, repo, base, testCmd, profile string
|
||||
ablate ablation.Set
|
||||
maxSteps, timeoutSec, attempts int
|
||||
bin, model, repo, base, testCmd string
|
||||
ablate ablation.Set
|
||||
maxSteps, timeoutSec, attempts int
|
||||
}
|
||||
|
||||
type testRef struct{ name, pkg string }
|
||||
@@ -39,11 +39,7 @@ type pinResult struct {
|
||||
func runDiff(o diffOpts) string {
|
||||
srcFiles := changedGoFiles(o.repo, o.base, false)
|
||||
if len(srcFiles) == 0 {
|
||||
profile := o.profile
|
||||
if profile == "" {
|
||||
profile = benchmarkProfileBaseline
|
||||
}
|
||||
return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile)
|
||||
return "## 🤖 Reasonix e2e — diff test-gen\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n"
|
||||
}
|
||||
pkgs := packagesOf(srcFiles)
|
||||
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
|
||||
@@ -83,7 +79,6 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
if o.model != "" {
|
||||
args = append(args, "--model", o.model)
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, o.profile)
|
||||
if !o.ablate.Empty() {
|
||||
args = append(args, "--ablate", o.ablate.String())
|
||||
}
|
||||
@@ -121,7 +116,7 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
|
||||
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
|
||||
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
|
||||
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
|
||||
passed: passed, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +192,6 @@ type diffReport struct {
|
||||
buildOut string
|
||||
failing []string
|
||||
passed bool
|
||||
profile string
|
||||
attempt, attempts int
|
||||
m runMetrics
|
||||
runErr error
|
||||
@@ -211,11 +205,7 @@ func renderDiff(r diffReport) string {
|
||||
if r.passed {
|
||||
result = "✅ pass"
|
||||
}
|
||||
profile := r.profile
|
||||
if profile == "" {
|
||||
profile = benchmarkProfileBaseline
|
||||
}
|
||||
fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile)
|
||||
fmt.Fprint(&b, "## 🤖 Reasonix e2e — diff test-gen\n\n")
|
||||
fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
|
||||
|
||||
pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
|
||||
|
||||
+20
-23
@@ -82,7 +82,7 @@ type runMetrics struct {
|
||||
ParentNamedFiles int `json:"parent_named_files,omitempty"`
|
||||
ChildEvidencePaths int `json:"child_evidence_paths,omitempty"`
|
||||
ChildDiscoveredPaths int `json:"child_discovered_paths,omitempty"`
|
||||
// Optional Delivery capability counters (omitempty for baseline/old metrics).
|
||||
// Optional capability counters (omitempty for older metrics).
|
||||
ReadinessChecks int `json:"readiness_checks,omitempty"`
|
||||
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
|
||||
CapabilityRoutes int `json:"capability_routes,omitempty"`
|
||||
@@ -118,6 +118,8 @@ type sourceUsage struct {
|
||||
type result struct {
|
||||
task
|
||||
runMetrics
|
||||
// Profile is retained for readers of older benchmark JSON. New runs always
|
||||
// record "standard" because execution mode is no longer an experiment axis.
|
||||
Profile string `json:"profile"`
|
||||
// Arm is the ablation arm the harness requested, not the arm the child
|
||||
// reported, so a run that died before writing metrics is still attributable.
|
||||
@@ -233,8 +235,6 @@ func main() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
}
|
||||
|
||||
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | traj | serve | fork")
|
||||
@@ -262,7 +262,6 @@ func main() {
|
||||
forkReps := flag.Int("reps", 1, "fork mode: continuation repetitions per bundle per arm")
|
||||
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
|
||||
model := flag.String("model", "", "provider/model name (default: config default)")
|
||||
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
|
||||
ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off (evidence, planner, subagent, retrieval, compaction; none|all)")
|
||||
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
|
||||
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory")
|
||||
@@ -278,12 +277,12 @@ func main() {
|
||||
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
|
||||
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
|
||||
flag.Parse()
|
||||
axes, err := resolveExperimentAxes(*profileFlag, *ablateFlag, *cacheArm, *anchorFlag)
|
||||
axes, err := resolveExperimentAxes(*ablateFlag, *cacheArm, *anchorFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
profile, arm, cache, anchor := axes.profile, axes.arm, axes.cache, axes.anchor
|
||||
arm, cache, anchor := axes.arm, axes.cache, axes.anchor
|
||||
|
||||
if *mode == "swebench" {
|
||||
if _, err := permissionFlag(*permission); err != nil {
|
||||
@@ -293,7 +292,7 @@ func main() {
|
||||
cwd, _ := os.Getwd()
|
||||
report := runSwebench(swebenchOpts{
|
||||
bin: *bin, subset: *subset, namespace: *namespace, model: *model,
|
||||
profile: profile, permission: *permission, arm: arm, runID: *runID, workDir: cwd,
|
||||
permission: *permission, arm: arm, runID: *runID, workDir: cwd,
|
||||
harness: *harnessPy, dataset: *dataset, maxSteps: *maxSteps,
|
||||
timeoutSec: *timeoutSec, workers: *workers, keepImages: *keepImages,
|
||||
network: *network, proxyURL: *proxyURL,
|
||||
@@ -319,7 +318,7 @@ func main() {
|
||||
}
|
||||
return
|
||||
case "fork":
|
||||
cfg := suiteConfig{bin: *bin, model: *model, profile: profile, arm: arm,
|
||||
cfg := suiteConfig{bin: *bin, model: *model, arm: arm,
|
||||
cacheArm: cache, effort: *effort, policy: *policyFlag}
|
||||
if err := runForkMode(*bundles, *suite, *forkArms, *forkReps, cfg, *trajDir, *outMD, *outJSON); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "fork mode:", err)
|
||||
@@ -331,7 +330,7 @@ func main() {
|
||||
if *mode == "diff" {
|
||||
report := runDiff(diffOpts{
|
||||
bin: *bin, model: *model, repo: *repo, base: *base,
|
||||
testCmd: *testCmd, profile: profile, ablate: arm, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
|
||||
testCmd: *testCmd, ablate: arm, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
|
||||
})
|
||||
emit(report, *outMD, "")
|
||||
return
|
||||
@@ -339,7 +338,7 @@ func main() {
|
||||
|
||||
meterSource, faults, segments, steers := pressure.settings()
|
||||
runSuiteMode(suiteConfig{
|
||||
bin: *bin, model: *model, profile: profile, arm: arm, budget: *budget,
|
||||
bin: *bin, model: *model, arm: arm, budget: *budget,
|
||||
trajDir: *trajDir, forcePlanner: *forcePlanner, attempts: *attempts, anchor: anchor,
|
||||
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
|
||||
forkCapture: *forkCapture, meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
|
||||
@@ -470,15 +469,15 @@ func filterTasks(tasks []task, filter string) ([]task, error) {
|
||||
}
|
||||
|
||||
// suiteConfig carries one suite invocation's fixed experiment axes: binary,
|
||||
// model, tool-surface profile, ablation arm, cache arm, and reasoning effort.
|
||||
// model, ablation arm, cache arm, and reasoning effort.
|
||||
type suiteConfig struct {
|
||||
bin, model, profile, cacheArm, effort string
|
||||
arm ablation.Set
|
||||
anchor string
|
||||
policy, forkCapture string
|
||||
trajDir string
|
||||
forcePlanner, checkpoints bool
|
||||
attempts, budget int
|
||||
bin, model, cacheArm, effort string
|
||||
arm ablation.Set
|
||||
anchor string
|
||||
policy, forkCapture string
|
||||
trajDir string
|
||||
forcePlanner, checkpoints bool
|
||||
attempts, budget int
|
||||
// meterConfig is the real config.toml whose provider endpoint each run is
|
||||
// redirected through the neutral meter; empty leaves runs unmetered.
|
||||
meterConfig string
|
||||
@@ -499,7 +498,7 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
total := 0
|
||||
for _, t := range tasks {
|
||||
if cfg.budget > 0 && total >= cfg.budget {
|
||||
results = append(results, result{task: t, Profile: cfg.profile, Skipped: true, Note: "skipped: token budget reached"})
|
||||
results = append(results, result{task: t, Profile: benchmarkProfileStandard, Skipped: true, Note: "skipped: token budget reached"})
|
||||
continue
|
||||
}
|
||||
if skipped, ok := anchorSkip(cfg, t); ok {
|
||||
@@ -528,7 +527,7 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
// then drops in verify.sh and runs it as the grader. The grader is added only
|
||||
// after the run so the agent can't read the answer key.
|
||||
func runTask(cfg suiteConfig, t task) result {
|
||||
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort}
|
||||
r := result{task: t, Profile: benchmarkProfileStandard, CacheArm: cfg.cacheArm, Effort: cfg.effort}
|
||||
r.Arm = cfg.arm.Arm()
|
||||
r.Anchor = cfg.anchor
|
||||
t.Prompt = anchorPrompt(cfg.anchor, t)
|
||||
@@ -652,7 +651,6 @@ func buildRunTaskArgs(cfg suiteConfig, metricsPath, trajectoryPath string, maxSt
|
||||
if maxSteps > 0 {
|
||||
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, cfg.profile)
|
||||
if cfg.effort != "" {
|
||||
args = append(args, "--effort", cfg.effort)
|
||||
}
|
||||
@@ -679,7 +677,7 @@ func buildSegmentArgs(cfg suiteConfig, seg segment, metricsPath, trajectoryPath
|
||||
// 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
|
||||
// state, not the price of reaching it. Prefix-shaping flags (model, profile,
|
||||
// state, not the price of reaching it. Prefix-shaping flags (model, effort,
|
||||
// ablation, cwd) must match the graded invocation exactly.
|
||||
func warmPrefix(cfg suiteConfig, work string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
@@ -688,7 +686,6 @@ func warmPrefix(cfg suiteConfig, work string) {
|
||||
if cfg.model != "" {
|
||||
args = append(args, "--model", cfg.model)
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, cfg.profile)
|
||||
if cfg.effort != "" {
|
||||
args = append(args, "--effort", cfg.effort)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func mechanismFacts(r result) map[string]mechanismRow {
|
||||
|
||||
// renderToolSurface is the schema-tax line: what every request re-pays for
|
||||
// the visible tool surface, and the churn (connects, prefix resets) the
|
||||
// economy tier trades that tax against. Fresh-session benchmarks re-pay the
|
||||
// adaptive runtime trades that tax against. Fresh-session benchmarks re-pay the
|
||||
// miss on every task, so the surface size prices differently than in a
|
||||
// long-lived session.
|
||||
func renderToolSurface(results []result) string {
|
||||
|
||||
@@ -32,20 +32,20 @@ func TestMarkDominatedIgnoresUnsolvedArms(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPerClassWinners(t *testing.T) {
|
||||
economy := armStats{ByClass: map[string]classStats{
|
||||
control := armStats{ByClass: map[string]classStats{
|
||||
"atomic-bugfix": {Ran: 2, Solved: 2, TTCS: []int64{15_000, 17_000}},
|
||||
"repo-exploration": {Ran: 2, Solved: 1, TTCS: []int64{20_000}},
|
||||
}}
|
||||
balanced := armStats{ByClass: map[string]classStats{
|
||||
treatment := armStats{ByClass: map[string]classStats{
|
||||
"atomic-bugfix": {Ran: 2, Solved: 2, TTCS: []int64{22_000, 24_000}},
|
||||
"repo-exploration": {Ran: 2, Solved: 2, TTCS: []int64{30_000, 31_000}},
|
||||
"unclassified": {Ran: 1, Solved: 1, TTCS: []int64{5_000}},
|
||||
}}
|
||||
got := perClassWinners([]string{"economy.json", "balanced.json"}, []armStats{economy, balanced})
|
||||
got := perClassWinners([]string{"control.json", "treatment.json"}, []armStats{control, treatment})
|
||||
for _, want := range []string{
|
||||
"### Per-class winners",
|
||||
"| atomic-bugfix | 100% · 17.0s | 100% · 24.0s | economy |",
|
||||
"| repo-exploration | 50% · 20.0s | 100% · 31.0s | balanced |",
|
||||
"| atomic-bugfix | 100% · 17.0s | 100% · 24.0s | control |",
|
||||
"| repo-exploration | 50% · 20.0s | 100% · 31.0s | treatment |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("per-class table missing %q:\n%s", want, got)
|
||||
@@ -54,7 +54,7 @@ func TestPerClassWinners(t *testing.T) {
|
||||
if strings.Contains(got, "unclassified") {
|
||||
t.Fatalf("unclassified rows must not render:\n%s", got)
|
||||
}
|
||||
if perClassWinners([]string{"a.json"}, []armStats{economy}) != "" {
|
||||
if perClassWinners([]string{"a.json"}, []armStats{control}) != "" {
|
||||
t.Fatal("single arm has no winners to declare")
|
||||
}
|
||||
}
|
||||
|
||||
+7
-36
@@ -12,49 +12,20 @@ import (
|
||||
// on. They are resolved together so a second bad flag is reported alongside
|
||||
// the first instead of being masked by an early exit.
|
||||
type experimentAxes struct {
|
||||
profile, cache, anchor string
|
||||
arm ablation.Set
|
||||
cache, anchor string
|
||||
arm ablation.Set
|
||||
}
|
||||
|
||||
func resolveExperimentAxes(profile, ablate, cache, anchor string) (experimentAxes, error) {
|
||||
p, perr := normalizeBenchmarkProfile(profile)
|
||||
func resolveExperimentAxes(ablate, cache, anchor string) (experimentAxes, error) {
|
||||
a, aerr := ablation.Parse(ablate)
|
||||
c, cerr := normalizeCacheArm(cache)
|
||||
an, anerr := normalizeAnchorArm(anchor)
|
||||
return experimentAxes{profile: p, cache: c, anchor: an, arm: a}, errors.Join(perr, aerr, cerr, anerr)
|
||||
return experimentAxes{cache: c, anchor: an, arm: a}, errors.Join(aerr, cerr, anerr)
|
||||
}
|
||||
|
||||
const (
|
||||
benchmarkProfileBaseline = "baseline"
|
||||
benchmarkProfileEconomy = "economy"
|
||||
benchmarkProfileBalanced = "balanced"
|
||||
benchmarkProfileDelivery = "delivery"
|
||||
)
|
||||
|
||||
// normalizeBenchmarkProfile validates the tool-surface arm. baseline stays
|
||||
// distinct from balanced: it passes no --profile flag at all, preserving the
|
||||
// byte-identical control command line older comparisons were recorded with.
|
||||
func normalizeBenchmarkProfile(profile string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(profile)) {
|
||||
case "", benchmarkProfileBaseline:
|
||||
return benchmarkProfileBaseline, nil
|
||||
case benchmarkProfileEconomy:
|
||||
return benchmarkProfileEconomy, nil
|
||||
case benchmarkProfileBalanced:
|
||||
return benchmarkProfileBalanced, nil
|
||||
case benchmarkProfileDelivery:
|
||||
return benchmarkProfileDelivery, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown benchmark profile %q (want baseline, economy, balanced, or delivery)", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func appendBenchmarkProfileArgs(args []string, profile string) []string {
|
||||
if profile == benchmarkProfileBaseline {
|
||||
return args
|
||||
}
|
||||
return append(args, "--profile", profile)
|
||||
}
|
||||
// benchmarkProfileStandard keeps the historical JSON field readable while the
|
||||
// harness itself has one execution contract. It is metadata, not an arm.
|
||||
const benchmarkProfileStandard = "standard"
|
||||
|
||||
const (
|
||||
benchmarkCacheCold = "cold"
|
||||
|
||||
@@ -7,30 +7,13 @@ import (
|
||||
"reasonix/internal/ablation"
|
||||
)
|
||||
|
||||
func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) {
|
||||
args := []string{"run", "fix the bug"}
|
||||
if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) {
|
||||
t.Fatalf("baseline args changed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) {
|
||||
args := []string{"run"}
|
||||
got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery)
|
||||
want := []string{"run", "--profile", "delivery"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("delivery args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRunTaskArgsEnablesUnattendedWorkspaceWrites(t *testing.T) {
|
||||
cfg := suiteConfig{model: "e2e", profile: benchmarkProfileDelivery}
|
||||
cfg := suiteConfig{model: "e2e"}
|
||||
got := buildRunTaskArgs(cfg, "metrics.json", "run.trajectory.jsonl", 12, "fix it")
|
||||
want := []string{
|
||||
"run", "--auto", "--metrics", "metrics.json",
|
||||
"--trajectory", "run.trajectory.jsonl",
|
||||
"--model", "e2e", "--max-steps", "12",
|
||||
"--profile", "delivery", "fix it",
|
||||
"--model", "e2e", "--max-steps", "12", "fix it",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("run task args = %v, want %v", got, want)
|
||||
@@ -38,7 +21,7 @@ func TestBuildRunTaskArgsEnablesUnattendedWorkspaceWrites(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRunTaskArgsPassesTheAblationArmThrough(t *testing.T) {
|
||||
cfg := suiteConfig{profile: benchmarkProfileBaseline, arm: ablation.New(ablation.Evidence, ablation.Planner)}
|
||||
cfg := suiteConfig{arm: ablation.New(ablation.Evidence, ablation.Planner)}
|
||||
got := buildRunTaskArgs(cfg, "m.json", "", 0, "fix it")
|
||||
want := []string{"run", "--auto", "--metrics", "m.json", "--ablate", "evidence,planner", "fix it"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -46,15 +29,25 @@ func TestBuildRunTaskArgsPassesTheAblationArmThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRunTaskArgsPassesEffortThrough(t *testing.T) {
|
||||
cfg := suiteConfig{profile: benchmarkProfileEconomy, effort: "low"}
|
||||
func TestBuildRunTaskArgsPassesEffortThroughWithoutLegacyMode(t *testing.T) {
|
||||
cfg := suiteConfig{effort: "low"}
|
||||
got := buildRunTaskArgs(cfg, "m.json", "", 0, "fix it")
|
||||
want := []string{"run", "--auto", "--metrics", "m.json", "--profile", "economy", "--effort", "low", "fix it"}
|
||||
want := []string{"run", "--auto", "--metrics", "m.json", "--effort", "low", "fix it"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("effort args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExperimentAxesHasNoExecutionModeArm(t *testing.T) {
|
||||
got, err := resolveExperimentAxes("evidence", "warm", "wrong")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.arm.String() != "evidence" || got.cache != "warm" || got.anchor != "wrong" {
|
||||
t.Fatalf("axes = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSuiteBudgetCoversCurrentFiveTaskBaseline(t *testing.T) {
|
||||
// The real-provider baseline exceeded 400k after only three successful
|
||||
// tasks. Keep enough headroom to grade all five instead of silently skipping
|
||||
@@ -64,22 +57,6 @@ func TestDefaultSuiteBudgetCoversCurrentFiveTaskBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBenchmarkProfile(t *testing.T) {
|
||||
for _, input := range []string{"", "baseline", " BASELINE "} {
|
||||
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
|
||||
t.Fatalf("normalize(%q) = %q, %v", input, got, err)
|
||||
}
|
||||
}
|
||||
for _, tier := range []string{"economy", "balanced", "delivery"} {
|
||||
if got, err := normalizeBenchmarkProfile(tier); err != nil || got != tier {
|
||||
t.Fatalf("normalize(%q) = %q, %v", tier, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := normalizeBenchmarkProfile("fast"); err == nil {
|
||||
t.Fatal("unknown profile should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCacheArm(t *testing.T) {
|
||||
for input, want := range map[string]string{"": "cold", "cold": "cold", " WARM ": "warm"} {
|
||||
if got, err := normalizeCacheArm(input); err != nil || got != want {
|
||||
@@ -90,13 +67,3 @@ func TestNormalizeCacheArm(t *testing.T) {
|
||||
t.Fatal("unknown cache arm should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBenchmarkProfileArgsPassesToolSurfaceTiers(t *testing.T) {
|
||||
for _, tier := range []string{"economy", "balanced"} {
|
||||
got := appendBenchmarkProfileArgs([]string{"run"}, tier)
|
||||
want := []string{"run", "--profile", tier}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s args = %v, want %v", tier, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func render(results []result) string {
|
||||
profile := benchmarkProfileBaseline
|
||||
arm := "full"
|
||||
if len(results) > 0 {
|
||||
if results[0].Profile != "" {
|
||||
profile = results[0].Profile
|
||||
}
|
||||
if results[0].Arm != "" {
|
||||
arm = results[0].Arm
|
||||
}
|
||||
@@ -23,7 +19,7 @@ func render(results []result) string {
|
||||
if len(results) > 0 && results[0].CacheArm != "" && results[0].CacheArm != benchmarkCacheCold {
|
||||
cache = " · " + results[0].CacheArm + "-cache"
|
||||
}
|
||||
return fmt.Sprintf("## 🤖 Reasonix e2e benchmark (%s · arm `%s`%s)\n\n", profile, arm, cache) + renderBody(results)
|
||||
return fmt.Sprintf("## 🤖 Reasonix e2e benchmark (arm `%s`%s)\n\n", arm, cache) + renderBody(results)
|
||||
}
|
||||
|
||||
// suiteStats aggregates result entries; ran/pass1 count tasks (first
|
||||
|
||||
@@ -59,7 +59,7 @@ func permissionFlag(mode string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func swebenchAgentArgs(metricsPath, model, profile, permission string, arm ablation.Set, maxSteps int, prompt string) []string {
|
||||
func swebenchAgentArgs(metricsPath, model, permission string, arm ablation.Set, maxSteps int, prompt string) []string {
|
||||
posture, err := permissionFlag(permission)
|
||||
if err != nil {
|
||||
panic(err) // validated at flag-parse time; reaching here is a wiring bug
|
||||
@@ -71,7 +71,6 @@ func swebenchAgentArgs(metricsPath, model, profile, permission string, arm ablat
|
||||
if maxSteps > 0 {
|
||||
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, profile)
|
||||
if !arm.Empty() {
|
||||
args = append(args, "--ablate", arm.String())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ type swebenchOpts struct {
|
||||
subset string
|
||||
namespace string
|
||||
model string
|
||||
profile string
|
||||
permission string
|
||||
arm ablation.Set
|
||||
runID string
|
||||
@@ -58,7 +57,7 @@ func loadSwebenchSubset(path string) ([]swebenchInstance, error) {
|
||||
// the agent inside it against /testbed, and take whatever the working tree
|
||||
// became as the candidate patch. Grading happens later, in one batch.
|
||||
func runSwebenchInstance(o swebenchOpts, inst swebenchInstance) (result, string) {
|
||||
r := result{task: task{ID: inst.InstanceID}, Profile: o.profile}
|
||||
r := result{task: task{ID: inst.InstanceID}, Profile: benchmarkProfileStandard}
|
||||
r.Arm = o.arm.Arm()
|
||||
|
||||
image := swebenchImage(o.namespace, inst.InstanceID)
|
||||
@@ -92,7 +91,7 @@ func runSwebenchInstance(o swebenchOpts, inst swebenchInstance) (result, string)
|
||||
}
|
||||
|
||||
metricsPath := "/tmp/reasonix-metrics.json"
|
||||
args := swebenchAgentArgs(metricsPath, o.model, o.profile, o.permission, o.arm, o.maxSteps, swebenchPrompt(inst))
|
||||
args := swebenchAgentArgs(metricsPath, o.model, o.permission, o.arm, o.maxSteps, swebenchPrompt(inst))
|
||||
agentCmd := append([]string{"exec", "-e", "REASONIX_HOME=/opt/rxhome", container},
|
||||
testbedShell("/usr/local/bin/reasonix "+shellQuoteAll(args))...)
|
||||
|
||||
|
||||
@@ -133,12 +133,12 @@ func TestShellQuoteAllContainsHostileIssueText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSwebenchAgentArgsKeepTheControlArmClean(t *testing.T) {
|
||||
got := swebenchAgentArgs("/tmp/m.json", "e2e", benchmarkProfileBaseline, "auto", ablation.Set{}, 60, "fix it")
|
||||
got := swebenchAgentArgs("/tmp/m.json", "e2e", "auto", ablation.Set{}, 60, "fix it")
|
||||
want := []string{"run", "--permission-mode=auto", "--metrics", "/tmp/m.json", "--model", "e2e", "--max-steps", "60", "fix it"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("control args = %v, want %v", got, want)
|
||||
}
|
||||
ablated := swebenchAgentArgs("/tmp/m.json", "", benchmarkProfileBaseline, "auto", ablation.New(ablation.Evidence), 0, "fix it")
|
||||
ablated := swebenchAgentArgs("/tmp/m.json", "", "auto", ablation.New(ablation.Evidence), 0, "fix it")
|
||||
if !reflect.DeepEqual(ablated, []string{"run", "--permission-mode=auto", "--metrics", "/tmp/m.json", "--ablate", "evidence", "fix it"}) {
|
||||
t.Fatalf("ablated args = %v", ablated)
|
||||
}
|
||||
@@ -147,8 +147,8 @@ func TestSwebenchAgentArgsKeepTheControlArmClean(t *testing.T) {
|
||||
// The two postures must differ in exactly one argument. If anything else moved
|
||||
// between arms, the published delta would not isolate the permission gate.
|
||||
func TestPermissionPostureIsTheOnlyDifferenceBetweenArms(t *testing.T) {
|
||||
a := swebenchAgentArgs("/m.json", "e2e", benchmarkProfileBaseline, "auto", ablation.Set{}, 60, "fix it")
|
||||
b := swebenchAgentArgs("/m.json", "e2e", benchmarkProfileBaseline, "yolo", ablation.Set{}, 60, "fix it")
|
||||
a := swebenchAgentArgs("/m.json", "e2e", "auto", ablation.Set{}, 60, "fix it")
|
||||
b := swebenchAgentArgs("/m.json", "e2e", "yolo", ablation.Set{}, 60, "fix it")
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("arms differ in argument count: %v vs %v", a, b)
|
||||
}
|
||||
|
||||
@@ -27,10 +27,28 @@ func TestSaveTabSessionMetaReplacesStaleAgentPreset(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
if got.AgentPreset != boot.AgentPresetDelivery || got.TokenMode != boot.TokenModeDelivery {
|
||||
t.Fatalf("persisted role = preset:%q tokenMode:%q, want delivery dual-write", got.AgentPreset, got.TokenMode)
|
||||
if got.AgentPreset != boot.AgentPresetBalanced || got.TokenMode != boot.TokenModeFull {
|
||||
t.Fatalf("persisted role = preset:%q tokenMode:%q, want pinned balanced/full", got.AgentPreset, got.TokenMode)
|
||||
}
|
||||
if restored := tabSessionProfileFromMeta(path, got).tokenMode; restored != boot.TokenModeDelivery {
|
||||
t.Fatalf("restored tokenMode = %q, want delivery", restored)
|
||||
if restored := tabSessionProfileFromMeta(path, got).tokenMode; restored != boot.TokenModeFull {
|
||||
t.Fatalf("restored tokenMode = %q, want pinned full", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabSessionProfileFromMetaDecodesLegacyDelivery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.jsonl")
|
||||
meta, err := agent.EnsureBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.AgentPreset = boot.AgentPresetDelivery
|
||||
meta.TokenMode = boot.TokenModeDelivery
|
||||
if restored := tabSessionProfileFromMeta(path, meta).tokenMode; restored != boot.TokenModeDelivery {
|
||||
t.Fatalf("legacy decode tokenMode = %q, want delivery", restored)
|
||||
}
|
||||
tab := &WorkspaceTab{}
|
||||
applyTabSessionProfile(tab, tabSessionProfileFromMeta(path, meta))
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("legacy delivery must not change runtime tokenMode: got %q", got)
|
||||
}
|
||||
}
|
||||
+209
-494
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,8 @@ import (
|
||||
|
||||
type stubProvider struct{}
|
||||
|
||||
const autosaveTestTimeout = 10 * time.Second
|
||||
|
||||
func (stubProvider) Name() string { return "stub" }
|
||||
|
||||
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
@@ -38,7 +40,7 @@ func controllerWithContent(t *testing.T, path string) *control.Controller {
|
||||
|
||||
func waitForFile(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
deadline := time.Now().Add(autosaveTestTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
|
||||
return
|
||||
@@ -50,7 +52,7 @@ func waitForFile(t *testing.T, path, want string) {
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
||||
waitForAutosaveIdleWithin(t, tab, autosaveTestTimeout)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
||||
|
||||
+120
-128
@@ -2957,10 +2957,10 @@ func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
|
||||
|
||||
lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
|
||||
t.Fatalf("conflict log lines = %v, want one recovery diagnostic", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_isolated"`) {
|
||||
t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
|
||||
if !strings.Contains(lines[0], `"outcome":"forked_recovery_branch"`) {
|
||||
t.Fatalf("conflict diagnostic = %s, want stable recovery fork", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
|
||||
t.Fatalf("conflict diagnostic leaked local path: %s", lines[0])
|
||||
@@ -5363,8 +5363,68 @@ func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func captureTabNotices(app *App, tab *WorkspaceTab) *[]string {
|
||||
var notices []string
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app, ctx: context.Background()}
|
||||
}
|
||||
tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice && strings.TrimSpace(e.Text) != "" {
|
||||
notices = append(notices, e.Text)
|
||||
}
|
||||
}))
|
||||
return ¬ices
|
||||
}
|
||||
|
||||
func assertDeprecatedExecutionModeNoop(t *testing.T, app *App, tab *WorkspaceTab, old control.SessionAPI, notices []string) {
|
||||
t.Helper()
|
||||
if tab == nil {
|
||||
t.Fatal("tab missing")
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl != old {
|
||||
t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
|
||||
}
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller AgentPreset = %q, want balanced", got)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", got)
|
||||
}
|
||||
meta := app.MetaForTab(tab.ID)
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("meta token/preset = %q/%q, want full/balanced", meta.TokenMode, meta.AgentPreset)
|
||||
}
|
||||
if len(notices) == 0 || notices[len(notices)-1] != SetAgentPresetDeprecatedNotice {
|
||||
t.Fatalf("deprecation notice = %q, want %q", notices, SetAgentPresetDeprecatedNotice)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSetTokenModeDidNotPersistLiveModes(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, entry := range loadTabsFile().Tabs {
|
||||
if entry.TokenMode == boot.TokenModeEconomy || entry.TokenMode == boot.TokenModeDelivery {
|
||||
t.Fatalf("SetTokenMode persisted live mode %q", entry.TokenMode)
|
||||
}
|
||||
if entry.AgentPreset == boot.AgentPresetLight || entry.AgentPreset == boot.AgentPresetDelivery {
|
||||
t.Fatalf("SetTokenMode persisted live preset %q", entry.AgentPreset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertPinnedCompatPersisted(t *testing.T, app *App, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
app.persistTabTokenMode(tab)
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 {
|
||||
t.Fatalf("saved tabs = %+v, want 1", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].TokenMode != boot.TokenModeFull || saved.Tabs[0].AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("saved compat = token:%q preset:%q, want full/balanced", saved.Tabs[0].TokenMode, saved.Tabs[0].AgentPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTokenModeRebuildsController(t *testing.T) {
|
||||
// Name kept for history; role settings now switch in place without rebuild.
|
||||
// Name kept for history; SetTokenMode is a deprecated no-op wrapper.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
@@ -5377,43 +5437,19 @@ func TestSetTokenModeRebuildsController(t *testing.T) {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode(economy): %v", err)
|
||||
}
|
||||
if c := app.activeCtrl(); c == nil {
|
||||
t.Fatal("SetTokenMode should keep a live controller")
|
||||
}
|
||||
if c := app.activeCtrl(); c != old {
|
||||
t.Fatal("SetTokenMode/role setting must switch in place without rebuilding the controller")
|
||||
}
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetLight {
|
||||
t.Fatalf("controller AgentPreset = %q, want light", got)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if tab == nil {
|
||||
t.Fatal("active tab missing")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != "economy" {
|
||||
t.Fatalf("Meta token mode = %q, want economy", got)
|
||||
}
|
||||
if got := app.Meta().AgentPreset; got != boot.AgentPresetLight {
|
||||
t.Fatalf("Meta agentPreset = %q, want light", got)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" {
|
||||
t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].AgentPreset != boot.AgentPresetLight {
|
||||
t.Fatalf("saved agentPreset = %q, want light", saved.Tabs[0].AgentPreset)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
assertSetTokenModeDidNotPersistLiveModes(t)
|
||||
assertPinnedCompatPersisted(t, app, tab)
|
||||
}
|
||||
|
||||
func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
|
||||
// Name kept for history; delivery role setting switches in place and dual-writes.
|
||||
// Name kept for history; SetTokenMode(delivery) is ignored and does not persist delivery.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
@@ -5426,52 +5462,20 @@ func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
|
||||
t.Fatalf("SetTokenMode(delivery): %v", err)
|
||||
}
|
||||
if c := app.activeCtrl(); c == nil || c != old {
|
||||
t.Fatal("delivery role setting should keep the same controller (in-place switch)")
|
||||
}
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetDelivery {
|
||||
t.Fatalf("controller AgentPreset = %q, want delivery", got)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != boot.TokenModeDelivery {
|
||||
t.Fatalf("Meta token mode = %q, want delivery", got)
|
||||
}
|
||||
if got := app.Meta().AgentPreset; got != boot.AgentPresetDelivery {
|
||||
t.Fatalf("Meta agentPreset = %q, want delivery", got)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != boot.TokenModeDelivery {
|
||||
t.Fatalf("saved tabs = %+v, want delivery profile", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].AgentPreset != boot.AgentPresetDelivery {
|
||||
t.Fatalf("saved agentPreset = %q, want delivery", saved.Tabs[0].AgentPreset)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
assertSetTokenModeDidNotPersistLiveModes(t)
|
||||
|
||||
// Leaving delivery must clear the persisted tokenMode so a restart does not
|
||||
// re-arm final-readiness gates (#6582).
|
||||
if err := app.SetTokenMode(boot.TokenModeFull); err != nil {
|
||||
t.Fatalf("SetTokenMode(full): %v", err)
|
||||
}
|
||||
if got := currentTabTokenMode(app.activeTab()); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode after full = %q, want full", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != boot.TokenModeFull {
|
||||
t.Fatalf("Meta token mode after full = %q, want full", got)
|
||||
}
|
||||
saved = loadTabsFile()
|
||||
if len(saved.Tabs) != 1 {
|
||||
t.Fatalf("saved tabs = %+v", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].TokenMode != "" {
|
||||
t.Fatalf("saved tokenMode = %q, want omitted/empty for full", saved.Tabs[0].TokenMode)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
assertPinnedCompatPersisted(t, app, tab)
|
||||
}
|
||||
|
||||
func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
|
||||
@@ -5522,19 +5526,11 @@ func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
notices := captureTabNotices(app, tab)
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
// Role setting switches in place: same controller, same lease, same history.
|
||||
if tab.Ctrl == nil || tab.Ctrl != oldCtrl {
|
||||
t.Fatalf("tab controller should be reused in place, got %p want %p", tab.Ctrl, oldCtrl)
|
||||
}
|
||||
if got := oldCtrl.AgentPreset(); got != boot.AgentPresetLight {
|
||||
t.Fatalf("controller AgentPreset = %q, want light", got)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
|
||||
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
|
||||
t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
|
||||
}
|
||||
@@ -5593,23 +5589,16 @@ func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
// Role setting switches in place and does not re-acquire the session lease,
|
||||
// so an externally held lease does not block the switch.
|
||||
// Deprecated wrapper must not re-acquire the session lease, so an
|
||||
// externally held lease does not block the call or replace the controller.
|
||||
notices := captureTabNotices(app, tab)
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
if tab.Ctrl != oldCtrl {
|
||||
t.Fatalf("tab controller changed after in-place role switch")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
if got := oldCtrl.AgentPreset(); got != boot.AgentPresetLight {
|
||||
t.Fatalf("controller AgentPreset = %q, want light", got)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
|
||||
meta := app.MetaForTab(tab.ID)
|
||||
if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
|
||||
t.Fatalf("role switch disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
|
||||
t.Fatalf("deprecated mode call disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5642,24 +5631,20 @@ func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode(economy): %v", err)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if tab == nil {
|
||||
t.Fatal("active tab missing")
|
||||
}
|
||||
// Role setting no longer rebuilds the controller, so stale model aliases
|
||||
// are not migrated on this path (migration still runs on model/effort rebuilds).
|
||||
// SetTokenMode does not rebuild, so stale model aliases stay put
|
||||
// (migration still runs on model/effort rebuilds).
|
||||
if tab.model != "deepseek-flash/deepseek-v4-flash" {
|
||||
t.Fatalf("tab model = %q, want unchanged stale ref without rebuild", tab.model)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
if c := app.activeCtrl(); c != old {
|
||||
t.Fatal("role setting must keep the same controller")
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
}
|
||||
|
||||
func TestMetaForTabReportsImageInputCapability(t *testing.T) {
|
||||
@@ -5752,7 +5737,7 @@ func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
|
||||
// Role setting no longer rebuilds; an unknown model must not block the switch.
|
||||
// Name kept for history; an unknown model must not block the deprecated no-op.
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("DEEPSEEK_API_KEY", "")
|
||||
t.Setenv("MIMO_API_KEY", "")
|
||||
@@ -5767,23 +5752,13 @@ func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode(economy) in-place switch: %v", err)
|
||||
}
|
||||
if c := app.activeCtrl(); c != old {
|
||||
t.Fatalf("SetTokenMode replaced controller: got %p want %p", c, old)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if tab == nil {
|
||||
t.Fatal("active tab missing")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode after in-place switch = %q, want economy", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != "economy" {
|
||||
t.Fatalf("Meta token mode after in-place switch = %q, want economy", got)
|
||||
t.Fatalf("SetTokenMode(economy): %v", err)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
}
|
||||
|
||||
func TestSetEffortRejectsRunningTurn(t *testing.T) {
|
||||
@@ -5805,24 +5780,29 @@ func TestSetEffortRejectsRunningTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
|
||||
// Name kept for history; the deprecated wrapper does not require an idle tab.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
|
||||
app.activeCtrl().Submit("work")
|
||||
old := control.New(control.Options{Runner: runner})
|
||||
app.setTestCtrl(old, "")
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
old.Submit("work")
|
||||
<-runner.started
|
||||
|
||||
err := app.SetTokenMode("economy")
|
||||
if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
|
||||
t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err)
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode while running: %v", err)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
|
||||
close(runner.release)
|
||||
waitNotRunning(t, app.activeCtrl())
|
||||
}
|
||||
|
||||
func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
// Name kept for history; background jobs must not block the deprecated wrapper.
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
@@ -5851,6 +5831,8 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
current.Close()
|
||||
}
|
||||
})
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
release := make(chan struct{})
|
||||
job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
@@ -5863,10 +5845,10 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
err := app.SetTokenMode("economy")
|
||||
if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
|
||||
t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode with background job: %v", err)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, ctrl, *notices)
|
||||
cancelled, err := app.CancelJobForTab("", job.ID)
|
||||
if err != nil || !cancelled {
|
||||
t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
|
||||
@@ -5874,8 +5856,18 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
|
||||
t.Fatalf("stopped background job = %+v, want one killed result", result)
|
||||
}
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode after stopping background job: %v", err)
|
||||
}
|
||||
|
||||
func TestSetTokenModeUnknownTabErrors(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
err := app.SetTokenModeForTab("missing-tab", "economy")
|
||||
if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
|
||||
t.Fatalf("SetTokenModeForTab(unknown) = %v, want tab not found", err)
|
||||
}
|
||||
err = app.SetAgentPresetForTab("missing-tab", boot.AgentPresetLight)
|
||||
if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
|
||||
t.Fatalf("SetAgentPresetForTab(unknown) = %v, want tab not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/build"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWindowsDesktopCommandsUseProcConstructors keeps new Windows-capable
|
||||
// desktop code from bypassing internal/proc. Background commands must be
|
||||
// hidden by default; user-visible launches have to opt in with VisibleCommand.
|
||||
func TestWindowsDesktopCommandsUseProcConstructors(t *testing.T) {
|
||||
roots := []string{".", "../internal"}
|
||||
// These packages either are the constructor implementation itself or launch
|
||||
// a real user-facing terminal/application from the CLI/desktop launcher.
|
||||
visibleOrInfrastructure := []string{
|
||||
"../internal/cli/",
|
||||
"../internal/desktoplauncher/",
|
||||
"../internal/notify/",
|
||||
"../internal/proc/",
|
||||
}
|
||||
windowsBuild := build.Default
|
||||
windowsBuild.GOOS = "windows"
|
||||
windowsBuild.GOARCH = "amd64"
|
||||
|
||||
checkRoot := func(root string) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
cleanPath := filepath.ToSlash(path)
|
||||
for _, prefix := range visibleOrInfrastructure {
|
||||
if strings.HasPrefix(cleanPath, prefix) {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if entry.Name() == "third_party" || entry.Name() == "frontend" || entry.Name() == "build" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
dir, name := filepath.Split(path)
|
||||
matched, err := windowsBuild.MatchFile(filepath.Clean(dir), name)
|
||||
if err != nil || !matched {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
execNames := map[string]bool{}
|
||||
for _, imp := range file.Imports {
|
||||
importPath, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil || importPath != "os/exec" {
|
||||
continue
|
||||
}
|
||||
name := "exec"
|
||||
if imp.Name != nil {
|
||||
name = imp.Name.Name
|
||||
}
|
||||
execNames[name] = true
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
selector, ok := node.(*ast.SelectorExpr)
|
||||
if !ok || (selector.Sel.Name != "Command" && selector.Sel.Name != "CommandContext") {
|
||||
return true
|
||||
}
|
||||
ident, ok := selector.X.(*ast.Ident)
|
||||
if ok && execNames[ident.Name] {
|
||||
t.Errorf("%s bypasses internal/proc with %s.%s", path, ident.Name, selector.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
for _, root := range roots {
|
||||
if err := checkRoot(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// PickBlankProjectParent opens a folder chooser defaulting to the active
|
||||
// project's parent, where sibling projects are normally created.
|
||||
func (a *App) PickBlankProjectParent() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", nil
|
||||
}
|
||||
cur, _ := os.Getwd()
|
||||
a.mu.RLock()
|
||||
if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
|
||||
cur = filepath.Dir(tab.WorkspaceRoot)
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Choose where to create the project",
|
||||
DefaultDirectory: dialogDefaultDirectory(cur),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateBlankProject creates one new directory below parentDir and returns its
|
||||
// absolute path; opening it remains on the existing workspace navigation path.
|
||||
func (a *App) CreateBlankProject(parentDir, projectName string) (string, error) {
|
||||
return createBlankProject(parentDir, projectName)
|
||||
}
|
||||
|
||||
func createBlankProject(parentDir, projectName string) (string, error) {
|
||||
parentDir = strings.TrimSpace(parentDir)
|
||||
if parentDir == "" {
|
||||
return "", fmt.Errorf("parent folder is required")
|
||||
}
|
||||
if abs, err := filepath.Abs(parentDir); err == nil {
|
||||
parentDir = abs
|
||||
} else {
|
||||
return "", fmt.Errorf("resolve parent folder: %w", err)
|
||||
}
|
||||
info, err := os.Stat(parentDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open parent folder: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("parent path is not a directory: %s", parentDir)
|
||||
}
|
||||
|
||||
projectName = strings.TrimSpace(projectName)
|
||||
if projectName == "" {
|
||||
return "", fmt.Errorf("project name is required")
|
||||
}
|
||||
if projectName == "." || projectName == ".." || strings.ContainsAny(projectName, `/\\`) {
|
||||
return "", fmt.Errorf("project name must be a single folder name")
|
||||
}
|
||||
for _, r := range projectName {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return "", fmt.Errorf("project name cannot contain control characters")
|
||||
}
|
||||
}
|
||||
|
||||
target := filepath.Join(parentDir, projectName)
|
||||
if filepath.Dir(target) != filepath.Clean(parentDir) {
|
||||
return "", fmt.Errorf("project name must be a single folder name")
|
||||
}
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return "", fmt.Errorf("project folder already exists: %s", target)
|
||||
}
|
||||
return "", fmt.Errorf("create project folder: %w", err)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateBlankProjectCreatesOneChildDirectory(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
want := filepath.Join(parent, "new-project")
|
||||
|
||||
got, err := createBlankProject(parent, " new-project ")
|
||||
if err != nil {
|
||||
t.Fatalf("createBlankProject: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("createBlankProject path = %q, want %q", got, want)
|
||||
}
|
||||
if info, err := os.Stat(got); err != nil {
|
||||
t.Fatalf("stat created project: %v", err)
|
||||
} else if !info.IsDir() {
|
||||
t.Fatalf("created path is not a directory: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlankProjectRejectsUnsafeNames(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
tests := []string{"", " ", ".", "..", "nested/project", `nested\project`, "bad\nname"}
|
||||
for _, name := range tests {
|
||||
t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) {
|
||||
if got, err := createBlankProject(parent, name); err == nil {
|
||||
t.Fatalf("createBlankProject(%q) = %q, want error", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
if entries, err := os.ReadDir(parent); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(entries) != 0 {
|
||||
t.Fatalf("unsafe names created entries: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlankProjectRejectsInvalidParent(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
file := filepath.Join(parent, "file.txt")
|
||||
if err := os.WriteFile(file, []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, invalidParent := range []string{"", filepath.Join(parent, "missing"), file} {
|
||||
if got, err := createBlankProject(invalidParent, "project"); err == nil {
|
||||
t.Fatalf("createBlankProject(%q) = %q, want error", invalidParent, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlankProjectNeverOverwritesExistingFolder(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
existing := filepath.Join(parent, "existing")
|
||||
if err := os.Mkdir(existing, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(existing, "keep.txt")
|
||||
if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, err := createBlankProject(parent, "existing"); err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("createBlankProject existing = %q, %v; want already exists error", got, err)
|
||||
}
|
||||
if body, err := os.ReadFile(marker); err != nil {
|
||||
t.Fatalf("existing marker was removed: %v", err)
|
||||
} else if string(body) != "keep" {
|
||||
t.Fatalf("existing marker changed to %q", body)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
root := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
@@ -56,8 +58,16 @@ func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T)
|
||||
}
|
||||
ag := agent.New(nil, nil, session, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, Runner: ag, SessionDir: dir, SessionPath: sessionPath, WorkspaceRoot: root, Label: "test"})
|
||||
app := &App{}
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
app.tabs["test"].WorkspaceRoot = root
|
||||
defer func() {
|
||||
for _, tab := range app.tabs {
|
||||
if tab != nil && tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
plan := app.PreviewRewindForTab("test", 1, "both")
|
||||
if !plan.OK || !plan.CanFiles || !plan.CanConversation {
|
||||
@@ -67,16 +77,46 @@ func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T)
|
||||
if !result.OK || !result.UndoAvailable || result.TransactionID == "" {
|
||||
t.Fatalf("commit = %+v", result)
|
||||
}
|
||||
if !result.ConversationForked || result.Branch == "" || result.TabID == "" || result.Tab == nil {
|
||||
t.Fatalf("commit fork wiring = %+v, want branch and tab", result)
|
||||
}
|
||||
if got, err := os.ReadFile(filePath); err != nil || string(got) != before {
|
||||
t.Fatalf("file after commit = %q err=%v", got, err)
|
||||
}
|
||||
if got := ctrl.History(); len(got) != 3 {
|
||||
t.Fatalf("controller history after commit = %d, want 3", len(got))
|
||||
if got := ctrl.History(); len(got) != 5 {
|
||||
t.Fatalf("source controller history after commit = %d, want 5", len(got))
|
||||
}
|
||||
if got := app.HistoryForTab("test"); len(got) != 3 {
|
||||
t.Fatalf("desktop history after commit = %d, want 3", len(got))
|
||||
if got := app.HistoryForTab("test"); len(got) != 5 {
|
||||
t.Fatalf("source desktop history after commit = %d, want 5", len(got))
|
||||
}
|
||||
if got := ctrl.SessionPath(); got != sessionPath {
|
||||
t.Fatalf("source session path = %q, want %q", got, sessionPath)
|
||||
}
|
||||
forkTab := app.tabs[result.TabID]
|
||||
if forkTab == nil || forkTab.SessionPath != result.Branch {
|
||||
t.Fatalf("fork tab = %+v, want session %q", forkTab, result.Branch)
|
||||
}
|
||||
if app.activeTabID != result.TabID {
|
||||
t.Fatalf("active tab = %q, want fork %q", app.activeTabID, result.TabID)
|
||||
}
|
||||
forkSess, err := agent.LoadSession(result.Branch)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession(fork): %v", err)
|
||||
}
|
||||
var forkContents []string
|
||||
for _, msg := range forkSess.Messages {
|
||||
forkContents = append(forkContents, msg.Content)
|
||||
}
|
||||
if !slices.Contains(forkContents, "first") || !slices.Contains(forkContents, "answer") {
|
||||
t.Fatalf("fork history missing prefix: %q", forkContents)
|
||||
}
|
||||
if slices.Contains(forkContents, "edit") || slices.Contains(forkContents, "done") {
|
||||
t.Fatalf("fork history still contains rewound turn: %q", forkContents)
|
||||
}
|
||||
|
||||
parentMessages := session.Snapshot()
|
||||
parentMessages = append(parentMessages, provider.Message{Role: provider.RoleUser, Content: "parent continued"})
|
||||
session.Replace(parentMessages)
|
||||
undo := app.UndoRewindForTab("test", result.TransactionID)
|
||||
if !undo.OK {
|
||||
t.Fatalf("undo = %+v", undo)
|
||||
@@ -84,11 +124,30 @@ func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T)
|
||||
if got, err := os.ReadFile(filePath); err != nil || string(got) != "after" {
|
||||
t.Fatalf("file after undo = %q err=%v", got, err)
|
||||
}
|
||||
if got := ctrl.History(); len(got) != 5 {
|
||||
t.Fatalf("controller history after undo = %d, want 5", len(got))
|
||||
if got := ctrl.History(); len(got) != 6 || got[5].Content != "parent continued" {
|
||||
t.Fatalf("controller history after undo = %+v, want continued parent", got)
|
||||
}
|
||||
if got := app.HistoryForTab("test"); len(got) != 5 {
|
||||
t.Fatalf("desktop history after undo = %d, want 5", len(got))
|
||||
if got := app.HistoryForTab("test"); len(got) != 6 || got[5].Content != "parent continued" {
|
||||
t.Fatalf("desktop history after undo = %+v, want continued parent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachForkedRewindTabFailsClosedWhenSourceIsGone(t *testing.T) {
|
||||
app := NewApp()
|
||||
source := &WorkspaceTab{ID: "removed"}
|
||||
result := app.attachForkedRewindTab(source, RewindResultView{
|
||||
OK: true,
|
||||
ConversationForked: true,
|
||||
Branch: filepath.Join(t.TempDir(), "fork.jsonl"),
|
||||
})
|
||||
if result.OK || !result.Partial {
|
||||
t.Fatalf("result = %+v, want failed partial result", result)
|
||||
}
|
||||
if result.Error != rewindForkAttachError {
|
||||
t.Fatalf("error = %q, want stable path-free error", result.Error)
|
||||
}
|
||||
if result.TabID != "" || result.Tab != nil {
|
||||
t.Fatalf("failed attach exposed target tab: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
|
||||
"reasonix/desktop/internal/winuninstall"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
@@ -522,7 +522,7 @@ func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
cmd := proc.VisibleCommand(installer)
|
||||
// Keep the helper itself hidden, but let the NSIS update-progress window be
|
||||
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
|
||||
// performs every live replacement through the claimed transaction.
|
||||
@@ -574,7 +574,7 @@ func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd := proc.VisibleCommand(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
|
||||
@@ -23,6 +23,7 @@ type ContextInfo struct {
|
||||
SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
|
||||
Sources map[string]usageSourceStats `json:"sources,omitempty"`
|
||||
Maintenance *ContextMaintenanceInfo `json:"maintenance,omitempty"`
|
||||
ContextBudget *ContextBudgetInfo `json:"contextBudget,omitempty"`
|
||||
}
|
||||
|
||||
// ContextMaintenanceInfo is the Wails-safe current-view snapshot. Optional
|
||||
@@ -42,6 +43,28 @@ type ContextMaintenanceInfo struct {
|
||||
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
LastReceipt *ContextMaintenanceReceiptInfo `json:"lastReceipt,omitempty"`
|
||||
ContextBudget *ContextBudgetInfo `json:"contextBudget,omitempty"`
|
||||
}
|
||||
|
||||
// ContextBudgetInfo is the optional send-time admission view. Older desktop
|
||||
// builds ignore unknown fields; omission is the compatibility default.
|
||||
type ContextBudgetInfo struct {
|
||||
WindowMode string `json:"windowMode,omitempty"`
|
||||
LimitMode string `json:"limitMode,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
WindowTokens int `json:"windowTokens,omitempty"`
|
||||
PromptTokens int `json:"promptTokens,omitempty"`
|
||||
AutoOutputTokens int `json:"autoOutputTokens,omitempty"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
RequestedOutputTokens int `json:"requestedOutputTokens,omitempty"`
|
||||
EffectiveOutputTokens int `json:"effectiveOutputTokens,omitempty"`
|
||||
ReserveTokens int `json:"reserveTokens,omitempty"`
|
||||
PhysicalRemaining int `json:"physicalRemaining,omitempty"`
|
||||
Clipped bool `json:"clipped,omitempty"`
|
||||
LastRecovery string `json:"lastRecovery,omitempty"`
|
||||
ObservedWindow int `json:"observedWindow,omitempty"`
|
||||
ObservedPrompt int `json:"observedPrompt,omitempty"`
|
||||
ObservedCompletion int `json:"observedCompletion,omitempty"`
|
||||
}
|
||||
|
||||
type ContextMaintenanceReceiptInfo struct {
|
||||
@@ -80,12 +103,31 @@ func contextMaintenanceInfo(snapshot agent.ContextMaintenanceSnapshot) *ContextM
|
||||
Headroom: snapshot.Headroom, ProjectionVersion: snapshot.ProjectionVersion,
|
||||
Blocked: snapshot.Blocked,
|
||||
}
|
||||
if snapshot.ContextBudget != nil {
|
||||
info.ContextBudget = contextBudgetInfo(snapshot.ContextBudget)
|
||||
}
|
||||
if source := snapshot.LastReceipt; source != nil {
|
||||
info.LastReceipt = contextMaintenanceReceiptInfo(source)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func contextBudgetInfo(source *agent.ContextBudgetSnapshot) *ContextBudgetInfo {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
return &ContextBudgetInfo{
|
||||
WindowMode: source.WindowMode, LimitMode: source.LimitMode, Source: source.Source,
|
||||
WindowTokens: source.WindowTokens, PromptTokens: source.PromptTokens,
|
||||
AutoOutputTokens: source.AutoOutputTokens, MaxOutputTokens: source.MaxOutputTokens,
|
||||
RequestedOutputTokens: source.RequestedOutputTokens, EffectiveOutputTokens: source.EffectiveOutputTokens,
|
||||
ReserveTokens: source.ReserveTokens, PhysicalRemaining: source.PhysicalRemaining,
|
||||
Clipped: source.Clipped, LastRecovery: source.LastRecovery,
|
||||
ObservedWindow: source.ObservedWindow, ObservedPrompt: source.ObservedPrompt,
|
||||
ObservedCompletion: source.ObservedCompletion,
|
||||
}
|
||||
}
|
||||
|
||||
func contextMaintenanceReceiptInfo(source *agent.ContextMaintenanceReceipt) *ContextMaintenanceReceiptInfo {
|
||||
receipt := &ContextMaintenanceReceiptInfo{
|
||||
OperationID: source.OperationID, Status: source.Status, Action: source.Action, Trigger: source.Trigger,
|
||||
|
||||
@@ -13,6 +13,7 @@ func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) {
|
||||
LastReceipt: &ContextMaintenanceReceiptInfo{
|
||||
OperationID: "op", Action: "summary", SavedTokens: 4096,
|
||||
},
|
||||
ContextBudget: &ContextBudgetInfo{WindowMode: "shared", Source: "official", WindowTokens: 1_048_576},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -22,6 +23,7 @@ func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) {
|
||||
`"maintenance"`, `"projectedTokens":160000`, `"canonicalTokens":800000`,
|
||||
`"triggerTokens":850000`, `"checkpointState":"applied"`,
|
||||
`"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`, `"action":"summary"`,
|
||||
`"contextBudget"`, `"windowMode":"shared"`, `"source":"official"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("ContextInfo JSON missing %s: %s", want, got)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
@@ -14,9 +13,9 @@ var (
|
||||
createDeliveryWorktree = worktree.Create
|
||||
)
|
||||
|
||||
// DeliveryWorktreeOpenResult is returned after an isolated Git workspace has
|
||||
// IsolatedWorktreeOpenResult is returned after an isolated Git workspace has
|
||||
// been created and opened as a normal Reasonix project.
|
||||
type DeliveryWorktreeOpenResult struct {
|
||||
type IsolatedWorktreeOpenResult struct {
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
WorktreeRoot string `json:"worktreeRoot"`
|
||||
SourceRoot string `json:"sourceRoot"`
|
||||
@@ -25,33 +24,38 @@ type DeliveryWorktreeOpenResult struct {
|
||||
Tab TabMeta `json:"tab"`
|
||||
}
|
||||
|
||||
// DeliveryWorktreeAvailability reports whether workspaceRoot can use the
|
||||
// optional Git isolation path. A false result never disables Delivery itself;
|
||||
// DeliveryWorktreeOpenResult is the deprecated alias of
|
||||
// IsolatedWorktreeOpenResult kept bound for one compatibility version.
|
||||
type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
|
||||
|
||||
// IsolatedWorktreeAvailability reports whether workspaceRoot can use the
|
||||
// optional Git isolation path. A false result never disables writing itself;
|
||||
// the cross-platform workspace writer lease remains the no-Git fallback.
|
||||
func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
|
||||
}
|
||||
|
||||
// CreateDeliveryWorktree creates a durable branch-backed worktree and opens it
|
||||
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
|
||||
// as a project. It never switches or modifies the source checkout, and it does
|
||||
// not delete the new worktree if later UI registration fails.
|
||||
func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
|
||||
// not delete the new worktree if later UI registration fails. Creation does
|
||||
// not force any execution mode: the standard policy adapts per task.
|
||||
func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpenResult, error) {
|
||||
workspaceRoot = strings.TrimSpace(workspaceRoot)
|
||||
created, err := createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
if err != nil {
|
||||
return DeliveryWorktreeOpenResult{}, err
|
||||
return IsolatedWorktreeOpenResult{}, err
|
||||
}
|
||||
|
||||
var tab TabMeta
|
||||
if a.singleSurfaceLayoutEnabled() {
|
||||
tab, err = a.ensureBlankSurface("project", created.WorkspaceRoot, boot.TokenModeDelivery)
|
||||
tab, err = a.ensureBlankSurface("project", created.WorkspaceRoot)
|
||||
} else {
|
||||
tab, err = a.ensureBlankTab("project", created.WorkspaceRoot, boot.TokenModeDelivery)
|
||||
tab, err = a.ensureBlankTab("project", created.WorkspaceRoot)
|
||||
}
|
||||
if err != nil {
|
||||
return DeliveryWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
|
||||
return IsolatedWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
|
||||
}
|
||||
return DeliveryWorktreeOpenResult{
|
||||
return IsolatedWorktreeOpenResult{
|
||||
WorkspaceRoot: created.WorkspaceRoot,
|
||||
WorktreeRoot: created.WorktreeRoot,
|
||||
SourceRoot: created.SourceRoot,
|
||||
@@ -60,3 +64,15 @@ func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpen
|
||||
Tab: tab,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeliveryWorktreeAvailability is the deprecated alias of
|
||||
// IsolatedWorktreeAvailability, kept bound for one compatibility version.
|
||||
func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return a.IsolatedWorktreeAvailability(workspaceRoot)
|
||||
}
|
||||
|
||||
// CreateDeliveryWorktree is the deprecated alias of CreateIsolatedWorktree,
|
||||
// kept bound for one compatibility version.
|
||||
func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
|
||||
return a.CreateIsolatedWorktree(workspaceRoot)
|
||||
}
|
||||
@@ -60,7 +60,10 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
if result.WorkspaceRoot != isolatedRoot || result.Branch != "reasonix/delivery-test" || !result.SourceDirty {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active || result.Tab.TokenMode != "delivery" {
|
||||
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active {
|
||||
t.Fatalf("opened tab = %+v", result.Tab)
|
||||
}
|
||||
if result.Tab.TokenMode != "full" {
|
||||
t.Fatalf("isolated worktree tokenMode = %q, want pinned full (must not force delivery)", result.Tab.TokenMode)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,42 @@
|
||||
package main
|
||||
|
||||
func initialDesktopWindowSize() (int, int) {
|
||||
width, height := 1240, 720
|
||||
const (
|
||||
defaultDesktopWindowWidth = 1240
|
||||
defaultDesktopWindowHeight = 720
|
||||
)
|
||||
|
||||
// desktopWindowFrameless reports whether the Wails window is created without a
|
||||
// native OS frame.
|
||||
//
|
||||
// The main window is frameless on Windows and draws its own chrome: the
|
||||
// frontend supplies the drag rail via --wails-draggable and the
|
||||
// minimise/maximise/close buttons via the App bindings.
|
||||
//
|
||||
// The remote web child window cannot do either. It is launched with no Wails
|
||||
// bindings (see main) and then navigates to the host's Serve page over loopback
|
||||
// HTTP, so neither window.go.main.App nor the Wails runtime exists in that
|
||||
// document; internal/serve's UI has no drag regions or window controls at all.
|
||||
// A frameless remote window therefore has no titlebar, cannot be moved, and
|
||||
// cannot be minimised or restored — the only ways out are the keyboard system
|
||||
// menu or killing the process. Remote windows keep the native frame.
|
||||
func desktopWindowFrameless(goos string, remoteWindow bool) bool {
|
||||
return goos == "windows" && !remoteWindow
|
||||
}
|
||||
|
||||
// initialDesktopWindowSize returns the startup size for the Wails window.
|
||||
//
|
||||
// The main window restores the saved geometry. The remote web child window must
|
||||
// not: desktop-window.json stores the main window's *outer* size, which for a
|
||||
// maximised window is the screen plus the invisible resize borders (e.g.
|
||||
// 2574x1454 on a 2560x1440 display). The remote window is created without
|
||||
// WS_MAXIMIZE and then centred by domReadyRemoteWindow, so inheriting that size
|
||||
// centres a window larger than the screen and pushes its top-left corner —
|
||||
// including the titlebar — off-display. Remote windows use the default size.
|
||||
func initialDesktopWindowSize(remoteWindow bool) (int, int) {
|
||||
width, height := defaultDesktopWindowWidth, defaultDesktopWindowHeight
|
||||
if remoteWindow {
|
||||
return width, height
|
||||
}
|
||||
if saved, ok := loadWindowState(); ok {
|
||||
if saved.Width > 0 {
|
||||
width = saved.Width
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The remote web child window has no Wails bindings and navigates to the host's
|
||||
// Serve page, which carries no drag regions or window controls. Frameless there
|
||||
// leaves a window that cannot be moved, minimised, or restored.
|
||||
func TestDesktopWindowFramelessExcludesRemoteWindow(t *testing.T) {
|
||||
cases := []struct {
|
||||
goos string
|
||||
remoteWindow bool
|
||||
want bool
|
||||
}{
|
||||
{goos: "windows", remoteWindow: false, want: true},
|
||||
{goos: "windows", remoteWindow: true, want: false},
|
||||
{goos: "darwin", remoteWindow: false, want: false},
|
||||
{goos: "darwin", remoteWindow: true, want: false},
|
||||
{goos: "linux", remoteWindow: false, want: false},
|
||||
{goos: "linux", remoteWindow: true, want: false},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
if got := desktopWindowFrameless(tt.goos, tt.remoteWindow); got != tt.want {
|
||||
t.Fatalf("desktopWindowFrameless(%q, %v) = %v, want %v", tt.goos, tt.remoteWindow, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialDesktopWindowSizeRestoresSavedGeometryForMainWindow(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
resetLastKnownWindowStateForTest()
|
||||
t.Cleanup(resetLastKnownWindowStateForTest)
|
||||
|
||||
app := NewApp()
|
||||
saved := DesktopWindowState{Width: 1100, Height: 900, X: 40, Y: 60, Maximised: false}
|
||||
if err := app.SaveWindowState(saved); err != nil {
|
||||
t.Fatalf("SaveWindowState: %v", err)
|
||||
}
|
||||
|
||||
w, h := initialDesktopWindowSize(false)
|
||||
if w != saved.Width || h != saved.Height {
|
||||
t.Fatalf("main window size = %dx%d, want %dx%d", w, h, saved.Width, saved.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// desktop-window.json holds the main window's outer size, which for a maximised
|
||||
// window exceeds the display. domReadyRemoteWindow centres the remote window, so
|
||||
// inheriting that size would push its titlebar off-screen.
|
||||
func TestInitialDesktopWindowSizeIgnoresSavedGeometryForRemoteWindow(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
resetLastKnownWindowStateForTest()
|
||||
t.Cleanup(resetLastKnownWindowStateForTest)
|
||||
|
||||
app := NewApp()
|
||||
maximised := DesktopWindowState{Width: 2574, Height: 1454, X: -11, Y: -11, Maximised: true}
|
||||
if err := app.SaveWindowState(maximised); err != nil {
|
||||
t.Fatalf("SaveWindowState: %v", err)
|
||||
}
|
||||
|
||||
w, h := initialDesktopWindowSize(true)
|
||||
if w != defaultDesktopWindowWidth || h != defaultDesktopWindowHeight {
|
||||
t.Fatalf("remote window size = %dx%d, want default %dx%d",
|
||||
w, h, defaultDesktopWindowWidth, defaultDesktopWindowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialDesktopWindowSizeFallsBackToDefaults(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
resetLastKnownWindowStateForTest()
|
||||
t.Cleanup(resetLastKnownWindowStateForTest)
|
||||
|
||||
w, h := initialDesktopWindowSize(false)
|
||||
if w != defaultDesktopWindowWidth || h != defaultDesktopWindowHeight {
|
||||
t.Fatalf("no saved state = %dx%d, want default %dx%d",
|
||||
w, h, defaultDesktopWindowWidth, defaultDesktopWindowHeight)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -374,11 +376,11 @@ func launchPlatformExternalOpener(spec externalOpenerSpec, path string, isDir bo
|
||||
case "shell-open":
|
||||
return openWorkspacePathWithType(path, isDir)
|
||||
case "path":
|
||||
cmd = exec.Command(spec.Target, path)
|
||||
cmd = proc.VisibleCommand(spec.Target, path)
|
||||
case "windows-terminal":
|
||||
// exec.Command uses CreateProcess argument escaping, not cmd.exe, so
|
||||
// workspace paths with shell metacharacters stay a single -d argument.
|
||||
cmd = exec.Command(spec.Target, "-d", launchPath)
|
||||
cmd = proc.VisibleCommand(spec.Target, "-d", launchPath)
|
||||
case "console":
|
||||
// Never route console openers through cmd.exe / start: working-directory
|
||||
// text would be re-parsed as shell syntax (& | ^ etc.). ShellExecute
|
||||
@@ -389,7 +391,7 @@ func launchPlatformExternalOpener(spec externalOpenerSpec, path string, isDir bo
|
||||
}
|
||||
return shellExecuteOpenFile(plan.File, "", plan.Dir)
|
||||
default:
|
||||
cmd = exec.Command(spec.Target, path)
|
||||
cmd = proc.VisibleCommand(spec.Target, path)
|
||||
}
|
||||
return startDetachedExternalOpener(cmd)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func historyLocalOnlyRows(m provider.Message) ([]HistoryMessage, bool) {
|
||||
if !m.LocalOnly {
|
||||
return nil, false
|
||||
}
|
||||
if recovery := m.FinalReadinessRecovery; recovery != nil && recovery.Pending {
|
||||
return []HistoryMessage{{
|
||||
Role: "notice", Code: event.NoticeCodeFinalReadiness, Level: "info", Pending: true,
|
||||
Content: "Task status needs one more check; continue the remaining work.",
|
||||
Readiness: &event.FinalReadiness{
|
||||
Attempts: 1,
|
||||
Missing: append([]string(nil), recovery.Missing...),
|
||||
},
|
||||
}}, true
|
||||
}
|
||||
return historySteerRows(agent.UserMessageText(m), true)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestHistoryMessagesRestorePendingFinalReadinessAction(t *testing.T) {
|
||||
marker := provider.Message{
|
||||
Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, LocalOnly: true,
|
||||
FinalReadinessRecovery: &provider.FinalReadinessRecovery{Pending: true, Missing: []string{"verification", "review"}},
|
||||
}
|
||||
got := historyMessages([]provider.Message{
|
||||
{Role: provider.RoleUser, Content: "fix it"},
|
||||
{Role: provider.RoleAssistant, Content: "partial result"},
|
||||
marker,
|
||||
}, strings.TrimSpace)
|
||||
if len(got) != 3 || got[2].Role != "notice" || got[2].Code != event.NoticeCodeFinalReadiness || !got[2].Pending || got[2].Readiness == nil {
|
||||
t.Fatalf("pending readiness history row = %+v", got)
|
||||
}
|
||||
if missing := got[2].Readiness.Missing; len(missing) != 2 || missing[0] != "verification" || missing[1] != "review" {
|
||||
t.Fatalf("pending readiness missing = %v", missing)
|
||||
}
|
||||
wired, err := json.Marshal(got[2])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pending readiness: %v", err)
|
||||
}
|
||||
if text := string(wired); !strings.Contains(text, `"readiness":{"attempts":1,"missing":["verification","review"]}`) || strings.Contains(text, `"Attempts"`) {
|
||||
t.Fatalf("pending readiness JSON = %s, want frontend-compatible camel case", text)
|
||||
}
|
||||
|
||||
marker.FinalReadinessRecovery.Pending = false
|
||||
consumed := historyMessages([]provider.Message{{Role: provider.RoleUser, Content: "fix it"}, marker}, strings.TrimSpace)
|
||||
if len(consumed) != 1 {
|
||||
t.Fatalf("consumed readiness marker should be hidden: %+v", consumed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
const rewindForkAttachError = "conversation fork was created but could not be opened; open the recovery branch from session history"
|
||||
|
||||
// openForkedSessionTab attaches an already-written fork session to a new tab.
|
||||
// The source tab keeps its controller and transcript. The fork becomes active
|
||||
// only while the source tab still owns focus.
|
||||
func (a *App) openForkedSessionTab(sourceTab *WorkspaceTab, newPath string) (TabMeta, error) {
|
||||
if sourceTab == nil || strings.TrimSpace(newPath) == "" {
|
||||
return TabMeta{}, fmt.Errorf("fork tab needs a source tab and session path")
|
||||
}
|
||||
a.mu.RLock()
|
||||
if a.tabs[sourceTab.ID] != sourceTab {
|
||||
a.mu.RUnlock()
|
||||
return TabMeta{}, nil
|
||||
}
|
||||
scope := sourceTab.Scope
|
||||
workspaceRoot := sourceTab.WorkspaceRoot
|
||||
sourceTitle := sourceTab.TopicTitle
|
||||
model := sourceTab.model
|
||||
effort := cloneStringPtr(sourceTab.effort)
|
||||
mode := currentTabMode(sourceTab)
|
||||
toolApprovalMode := currentTabToolApprovalMode(sourceTab)
|
||||
disabledMCP := cloneServerViewMap(sourceTab.disabledMCP)
|
||||
mcpOrder := append([]string(nil), sourceTab.mcpOrder...)
|
||||
a.mu.RUnlock()
|
||||
|
||||
topicID := newTopicID()
|
||||
topicTitle := a.forkTopicTitle(sourceTitle)
|
||||
titleRoot := workspaceRoot
|
||||
if scope == "global" {
|
||||
titleRoot = ""
|
||||
}
|
||||
if err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {
|
||||
return TabMeta{}, err
|
||||
}
|
||||
m, _ := agent.EnsureBranchMeta(newPath)
|
||||
m.Scope = scope
|
||||
m.WorkspaceRoot = workspaceRoot
|
||||
m.TopicID = topicID
|
||||
m.TopicTitle = topicTitle
|
||||
if err := agent.SaveBranchMeta(newPath, m); err != nil {
|
||||
return TabMeta{}, err
|
||||
}
|
||||
invalidateTopicSessionIndexForPath(newPath)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.tabs[sourceTab.ID] != sourceTab {
|
||||
a.mu.Unlock()
|
||||
return TabMeta{}, nil
|
||||
}
|
||||
newTabID := a.newUniqueTabIDLocked()
|
||||
tab := &WorkspaceTab{
|
||||
ID: newTabID,
|
||||
Scope: scope,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: topicTitle,
|
||||
topicTitleSource: topicTitleSourceManual,
|
||||
SessionPath: newPath,
|
||||
model: model,
|
||||
effort: effort,
|
||||
mode: mode,
|
||||
toolApprovalMode: toolApprovalMode,
|
||||
disabledMCP: disabledMCP,
|
||||
mcpOrder: mcpOrder,
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: newTabID, app: a}
|
||||
a.tabs[newTabID] = tab
|
||||
a.tabOrder = append(a.tabOrder, newTabID)
|
||||
activateFork := a.activeTabID == sourceTab.ID
|
||||
if activateFork {
|
||||
a.activeTabID = newTabID
|
||||
}
|
||||
a.saveTabsLocked()
|
||||
meta := a.tabMeta(tab, activateFork)
|
||||
a.mu.Unlock()
|
||||
|
||||
a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(newPath))
|
||||
a.startTabControllerBuild(tab)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// attachForkedRewindTab fails closed when the durable branch cannot be attached
|
||||
// to a tab. In particular, callers must not treat the source tab as the rewind
|
||||
// target and accidentally resubmit the edited prompt into the parent session.
|
||||
func (a *App) attachForkedRewindTab(sourceTab *WorkspaceTab, view RewindResultView) RewindResultView {
|
||||
meta, err := a.openForkedSessionTab(sourceTab, view.Branch)
|
||||
if err != nil || meta.ID == "" {
|
||||
slog.Warn("rewind: fork created but tab attach failed", "err", err)
|
||||
view.OK = false
|
||||
view.Partial = true
|
||||
view.Error = rewindForkAttachError
|
||||
return view
|
||||
}
|
||||
view.TabID = meta.ID
|
||||
view.Tab = &meta
|
||||
return view
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
// REASONIX_BENCH_LONGTASK_P95_MS (50)
|
||||
// REASONIX_BENCH_LONGTASK_MAX_MS (500)
|
||||
// REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS (3000)
|
||||
// REASONIX_BENCH_IDLE_CPU_PERCENT (3)
|
||||
// REASONIX_BENCH_HEAP_GROWTH_MIB (20)
|
||||
// REASONIX_BENCH_SWITCHES (100)
|
||||
// REASONIX_BENCH_COLD_RUNS (5)
|
||||
@@ -58,6 +59,7 @@ const GATES = {
|
||||
longTaskP95Ms: numEnv("REASONIX_BENCH_LONGTASK_P95_MS", 50),
|
||||
longTaskMaxMs: numEnv("REASONIX_BENCH_LONGTASK_MAX_MS", 500),
|
||||
markdownParseMaxMs: numEnv("REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS", 3000),
|
||||
idleCpuPercent: numEnv("REASONIX_BENCH_IDLE_CPU_PERCENT", 3),
|
||||
heapGrowthMiB: numEnv("REASONIX_BENCH_HEAP_GROWTH_MIB", 20),
|
||||
};
|
||||
const SWITCHES = Math.round(numEnv("REASONIX_BENCH_SWITCHES", 100));
|
||||
@@ -213,6 +215,12 @@ async function forceGcAndHeap(cdp, page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function rendererTaskDuration(cdp) {
|
||||
await cdp.send("Performance.enable").catch(() => {});
|
||||
const response = await cdp.send("Performance.getMetrics");
|
||||
return response.metrics.find((metric) => metric.name === "TaskDuration")?.value ?? 0;
|
||||
}
|
||||
|
||||
const INTERACTIVE_FN = () => {
|
||||
const input = document.querySelector("textarea.composer__input");
|
||||
const inputReady = Boolean(input && !input.disabled);
|
||||
@@ -268,14 +276,14 @@ async function settleMarkdownWorker(page, timeoutMs = 10_000) {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Progressive history blocks intentionally mount in idle slices after parsing.
|
||||
// Stabilize that deferred work only around heap/DOM snapshots; waiting on every
|
||||
// switch would change the interaction workload the benchmark is measuring.
|
||||
// Giant history rows retain only a viewport-driven Markdown tail. Stabilize
|
||||
// worker/React publication around heap/DOM snapshots without forcing cold
|
||||
// blocks into the DOM; scrolling the older sentinel owns those later pages.
|
||||
async function settleMarkdownMounts(page, timeoutMs = 10_000) {
|
||||
try {
|
||||
await page.waitForFunction(() => (
|
||||
[...document.querySelectorAll(".transcript [data-markdown-blocks]")].every((element) => (
|
||||
element.getAttribute("data-markdown-visible-blocks") === element.getAttribute("data-markdown-blocks")
|
||||
Number(element.getAttribute("data-markdown-visible-blocks")) > 0
|
||||
))
|
||||
), undefined, { timeout: timeoutMs, polling: 100 });
|
||||
} catch (error) {
|
||||
@@ -370,6 +378,14 @@ async function main() {
|
||||
}
|
||||
|
||||
await settleMarkdownMounts(page);
|
||||
// Session activation is complete and the worker is drained. Sustained task
|
||||
// time here is background UI churn; the original regression kept
|
||||
// committing Markdown blocks after the visible switch had finished.
|
||||
const idleSampleMs = 3_000;
|
||||
const idleTaskStart = await rendererTaskDuration(cdp);
|
||||
await page.waitForTimeout(idleSampleMs);
|
||||
const idleTaskEnd = await rendererTaskDuration(cdp);
|
||||
const idleMainThreadPercent = ((idleTaskEnd - idleTaskStart) * 1000 / idleSampleMs) * 100;
|
||||
|
||||
// Freeze interaction metrics before the explicit HeapProfiler GC below.
|
||||
// The GC is part of retained-heap measurement, not the user switching
|
||||
@@ -409,6 +425,7 @@ async function main() {
|
||||
domNodes: { baseline: baseline.domNodes, final: final.domNodes },
|
||||
transcriptCache: cache,
|
||||
markdownWorker: worker,
|
||||
idleMainThreadPercent,
|
||||
baselineCache: baselineStats?.transcriptCache,
|
||||
};
|
||||
|
||||
@@ -426,6 +443,7 @@ async function main() {
|
||||
GATES.markdownParseMaxMs,
|
||||
(worker.completed ?? 0) >= 1 && (worker.maxParseMs ?? Infinity) <= GATES.markdownParseMaxMs,
|
||||
);
|
||||
check("B settled renderer task time", idleMainThreadPercent, GATES.idleCpuPercent, idleMainThreadPercent <= GATES.idleCpuPercent, "%");
|
||||
check("B retained heap growth", heapGrowthMiB, GATES.heapGrowthMiB, heapGrowthMiB <= GATES.heapGrowthMiB, "MiB");
|
||||
check(
|
||||
"B cache: body bytes within budget",
|
||||
|
||||
@@ -12,6 +12,10 @@ process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH ||
|
||||
const { chromium } = await import("playwright");
|
||||
const port = Number(process.env.REASONIX_TRANSCRIPT_SCROLL_PORT ?? 4619);
|
||||
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
|
||||
const maxFrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_FRAME_GAP_MS ?? 250);
|
||||
const p95FrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_P95_FRAME_GAP_MS ?? 80);
|
||||
const maxLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_LONG_TASK_MS ?? 250);
|
||||
const totalLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_TOTAL_LONG_TASK_MS ?? 750);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -49,15 +53,194 @@ try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.click('.project-tree__topic-main:has-text("bench:small-6t")');
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Asynchronously hydrated verification appendix"), undefined, { timeout: 30_000 });
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
let frames = 8;
|
||||
const settle = () => frames-- <= 0 ? resolve() : requestAnimationFrame(settle);
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
const hydrationTranscript = page.locator(".transcript");
|
||||
const hydrationBox = await hydrationTranscript.boundingBox();
|
||||
assert(hydrationBox != null, "async hydration exposes the transcript viewport");
|
||||
const hydrationStart = await hydrationTranscript.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
max: element.scrollHeight - element.clientHeight,
|
||||
}));
|
||||
assert(hydrationStart.max > 0, `async hydration fixture is scrollable (${hydrationStart.max}px)`);
|
||||
await page.mouse.move(hydrationBox.x + hydrationBox.width / 2, hydrationBox.y + hydrationBox.height / 2);
|
||||
await page.mouse.wheel(0, hydrationStart.top < hydrationStart.max / 2 ? 360 : -360);
|
||||
await page.waitForFunction(
|
||||
(startTop) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element instanceof HTMLElement
|
||||
&& element.dataset.scrollMode === "manual"
|
||||
&& Math.abs(element.scrollTop - startTop) > 1;
|
||||
},
|
||||
hydrationStart.top,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight * 2.5);
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".transcript")?.dataset.scrollMode === "manual",
|
||||
undefined,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const hydrationAnchor = await page.evaluate(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const visible = [...element.querySelectorAll(".transcript__row")]
|
||||
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const anchor = visible.find((row) => row.getBoundingClientRect().top >= viewport.top) ?? visible[0];
|
||||
return anchor ? { key: anchor.dataset.rowKey, offset: anchor.getBoundingClientRect().top - viewport.top } : null;
|
||||
});
|
||||
assert(hydrationAnchor?.key, "async hydration starts from a stable manual-reading anchor");
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"), undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction((anchor) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
const row = [...element.querySelectorAll(".transcript__row")].find((candidate) => candidate.dataset.rowKey === anchor.key);
|
||||
return Boolean(row && Math.abs((row.getBoundingClientRect().top - element.getBoundingClientRect().top) - anchor.offset) <= 8);
|
||||
}, hydrationAnchor, { timeout: 5_000 });
|
||||
const hydratedState = await page.evaluate((anchor) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return { occupied: false, anchorOffset: null };
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const anchored = rows.find((row) => row.dataset.rowKey === anchor.key);
|
||||
return { occupied: rows.some((row) => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
}), anchorOffset: anchored ? anchored.getBoundingClientRect().top - viewport.top : null };
|
||||
}, hydrationAnchor);
|
||||
assert(hydratedState.occupied, "async full-content hydration keeps a transcript row in the viewport");
|
||||
assert(
|
||||
hydratedState.anchorOffset != null && Math.abs(hydratedState.anchorOffset - hydrationAnchor.offset) <= 8,
|
||||
`async hydration preserves the manual-reading anchor (${hydrationAnchor.offset} → ${hydratedState.anchorOffset})`,
|
||||
);
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
let frames = 12;
|
||||
const settle = () => frames-- <= 0 ? resolve() : requestAnimationFrame(settle);
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element instanceof HTMLElement
|
||||
&& element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
|
||||
});
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.waitForFunction(() => document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t"));
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go"), undefined, { timeout: 30_000 });
|
||||
const markdownVisibility = await page.evaluate(() => {
|
||||
const row = document.querySelector(".transcript__row");
|
||||
if (!(row instanceof HTMLElement)) return { inside: null, outside: null };
|
||||
const mount = (parent) => {
|
||||
const host = document.createElement("div");
|
||||
host.className = "md";
|
||||
const probe = document.createElement("p");
|
||||
host.append(probe);
|
||||
parent.append(host);
|
||||
const value = getComputedStyle(probe).contentVisibility;
|
||||
host.remove();
|
||||
return value;
|
||||
};
|
||||
return { inside: mount(row), outside: mount(document.body) };
|
||||
});
|
||||
assert(
|
||||
markdownVisibility.inside === "visible",
|
||||
`mounted transcript markdown stays measurable (${markdownVisibility.inside})`,
|
||||
);
|
||||
assert(
|
||||
markdownVisibility.outside === "auto",
|
||||
`markdown outside the transcript still culls with content-visibility (${markdownVisibility.outside})`,
|
||||
);
|
||||
|
||||
const transcript = page.locator(".transcript");
|
||||
const box = await transcript.boundingBox();
|
||||
assert(box != null, "bench exposes the Virtuoso transcript viewport");
|
||||
assert(await page.locator('[data-virtuoso-scroller="true"]').count() === 1, "Transcript is backed by React Virtuoso");
|
||||
|
||||
// Stay on the tail. Opening the workspace dock must not crop right-aligned
|
||||
// user bubbles — that is a width/padding bug, not the scroll-up overlap.
|
||||
const measureDockCrop = () => page.evaluate(() => {
|
||||
const layout = document.querySelector(".layout");
|
||||
const chat = document.querySelector(".chat-pane");
|
||||
const dock = document.querySelector(".workbench-dock");
|
||||
const scroller = document.querySelector(".transcript");
|
||||
const bubbles = [...document.querySelectorAll(".msg--user .msg__body")];
|
||||
const bubble = bubbles.at(-1);
|
||||
if (!(chat instanceof HTMLElement) || !(bubble instanceof HTMLElement) || !(scroller instanceof HTMLElement)) {
|
||||
return { ok: false };
|
||||
}
|
||||
const chatBox = chat.getBoundingClientRect();
|
||||
const bubbleBox = bubble.getBoundingClientRect();
|
||||
const dockBox = dock instanceof HTMLElement ? dock.getBoundingClientRect() : null;
|
||||
return {
|
||||
ok: true,
|
||||
workspaceOpen: Boolean(layout?.classList.contains("layout--workspace-open")),
|
||||
overflowChatRight: +(bubbleBox.right - chatBox.right).toFixed(2),
|
||||
overflowDock: dockBox ? +(bubbleBox.right - dockBox.left).toFixed(2) : null,
|
||||
fromBottom: +(scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight).toFixed(2),
|
||||
};
|
||||
});
|
||||
const dockOpen = await measureDockCrop();
|
||||
assert(dockOpen.ok, "tail-follow dock check can see the chat and a user bubble");
|
||||
assert(dockOpen.workspaceOpen === true, "bench starts with the workspace dock open");
|
||||
assert(dockOpen.fromBottom <= 1, `dock-open check stays on the tail without scrolling up (${dockOpen.fromBottom})`);
|
||||
assert(dockOpen.overflowChatRight <= 1, `user bubble stays inside the chat column with the dock open (${dockOpen.overflowChatRight})`);
|
||||
assert(
|
||||
dockOpen.overflowDock == null || dockOpen.overflowDock <= 1,
|
||||
`user bubble does not extend into the workspace dock (${dockOpen.overflowDock})`,
|
||||
);
|
||||
|
||||
// Width changes remasure Virtuoso and can leave a few pixels off the
|
||||
// physical bottom. Keep the crop assertions tight; only the post-resize
|
||||
// stick-to-tail check gets this slack (CI saw 7px after collapse).
|
||||
const tailAfterResizePx = 16;
|
||||
const waitNearTailAfterResize = () => page.waitForFunction((limit) => {
|
||||
const scroller = document.querySelector(".transcript");
|
||||
return Boolean(scroller && scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= limit);
|
||||
}, tailAfterResizePx);
|
||||
|
||||
const collapse = page.getByRole("button", { name: /Collapse workspace|收起工作区/ });
|
||||
if (await collapse.count()) {
|
||||
await collapse.click();
|
||||
await page.waitForFunction(() => !document.querySelector(".layout")?.classList.contains("layout--workspace-open"));
|
||||
await waitNearTailAfterResize();
|
||||
const dockClosed = await measureDockCrop();
|
||||
assert(dockClosed.ok && dockClosed.workspaceOpen === false, "workspace toggle collapses the dock");
|
||||
assert(dockClosed.fromBottom <= tailAfterResizePx, `collapsing the dock does not require scrolling up (${dockClosed.fromBottom})`);
|
||||
assert(dockClosed.overflowChatRight <= 1, `user bubble stays inside the chat column with the dock closed (${dockClosed.overflowChatRight})`);
|
||||
const expand = page.getByRole("button", { name: /Expand workspace|展开工作区/ });
|
||||
await expand.click();
|
||||
await page.waitForFunction(() => Boolean(document.querySelector(".layout")?.classList.contains("layout--workspace-open")));
|
||||
await waitNearTailAfterResize();
|
||||
const dockReopen = await measureDockCrop();
|
||||
assert(dockReopen.ok && dockReopen.workspaceOpen === true, "workspace toggle reopens the dock");
|
||||
assert(dockReopen.fromBottom <= tailAfterResizePx, `reopening the dock stays on the tail (${dockReopen.fromBottom})`);
|
||||
assert(dockReopen.overflowChatRight <= 1, `user bubble stays inside the chat column after reopening the dock (${dockReopen.overflowChatRight})`);
|
||||
assert(
|
||||
dockReopen.overflowDock == null || dockReopen.overflowDock <= 1,
|
||||
`user bubble still does not enter the dock after reopen (${dockReopen.overflowDock})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Start away from either edge and record a visible stable row. Growing an
|
||||
// already-mounted row above it reproduces async Markdown/tool hydration.
|
||||
const beforeGrowth = await transcript.evaluate((element) => {
|
||||
@@ -135,11 +318,59 @@ try {
|
||||
assert(afterGrowth.samples.every((sample) => sample.occupied), "dynamic measurement never exposes a blank transcript viewport");
|
||||
|
||||
// Rapid direction changes are the exact user report. Sample every frame and
|
||||
// require that Virtuoso always maintains mounted coverage.
|
||||
for (const delta of [-700, -700, 480, -600, 520, -460]) {
|
||||
// require that Virtuoso always maintains mounted coverage. Keep a real
|
||||
// Chromium long-task budget around 60 events so accidental synchronous
|
||||
// storage/layout work cannot return unnoticed.
|
||||
await page.evaluate(() => {
|
||||
const probe = {
|
||||
active: true,
|
||||
frameGaps: [],
|
||||
lastFrame: null,
|
||||
longTasks: [],
|
||||
observer: null,
|
||||
};
|
||||
if (PerformanceObserver.supportedEntryTypes?.includes("longtask")) {
|
||||
probe.observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) probe.longTasks.push(entry.duration);
|
||||
});
|
||||
probe.observer.observe({ type: "longtask", buffered: false });
|
||||
}
|
||||
const sampleFrame = (now) => {
|
||||
if (!probe.active) return;
|
||||
if (probe.lastFrame != null) probe.frameGaps.push(now - probe.lastFrame);
|
||||
probe.lastFrame = now;
|
||||
requestAnimationFrame(sampleFrame);
|
||||
};
|
||||
window.__reasonixScrollPerfProbe = probe;
|
||||
requestAnimationFrame(sampleFrame);
|
||||
});
|
||||
const rapidDeltas = Array.from({ length: 10 }, () => [-700, -700, 480, -600, 520, -460]).flat();
|
||||
for (const delta of rapidDeltas) {
|
||||
await page.mouse.wheel(0, delta);
|
||||
await page.waitForTimeout(24);
|
||||
await page.waitForTimeout(16);
|
||||
}
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const perf = await page.evaluate(() => {
|
||||
const probe = window.__reasonixScrollPerfProbe;
|
||||
if (!probe) return null;
|
||||
probe.active = false;
|
||||
probe.observer?.disconnect();
|
||||
const gaps = [...probe.frameGaps].sort((left, right) => left - right);
|
||||
const percentileIndex = Math.max(0, Math.ceil(gaps.length * 0.95) - 1);
|
||||
return {
|
||||
frames: gaps.length,
|
||||
maxFrameGap: gaps.at(-1) ?? 0,
|
||||
p95FrameGap: gaps[percentileIndex] ?? 0,
|
||||
maxLongTask: Math.max(0, ...probe.longTasks),
|
||||
totalLongTask: probe.longTasks.reduce((sum, duration) => sum + duration, 0),
|
||||
longTasks: probe.longTasks.length,
|
||||
};
|
||||
});
|
||||
assert(perf && perf.frames >= 30, `rapid-scroll performance probe samples enough frames (${perf?.frames ?? 0})`);
|
||||
assert(perf.maxFrameGap <= maxFrameGapMs, `rapid-scroll maximum frame gap stays within budget (${perf.maxFrameGap.toFixed(1)}ms <= ${maxFrameGapMs}ms)`);
|
||||
assert(perf.p95FrameGap <= p95FrameGapMs, `rapid-scroll p95 frame gap stays within budget (${perf.p95FrameGap.toFixed(1)}ms <= ${p95FrameGapMs}ms)`);
|
||||
assert(perf.maxLongTask <= maxLongTaskMs, `rapid-scroll longest main-thread task stays within budget (${perf.maxLongTask.toFixed(1)}ms <= ${maxLongTaskMs}ms)`);
|
||||
assert(perf.totalLongTask <= totalLongTaskMs, `rapid-scroll total long-task time stays within budget (${perf.totalLongTask.toFixed(1)}ms <= ${totalLongTaskMs}ms; ${perf.longTasks} tasks)`);
|
||||
const rapid = await transcript.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const visible = [...element.querySelectorAll(".transcript__row")].filter((row) => {
|
||||
@@ -176,46 +407,53 @@ try {
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
|
||||
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
|
||||
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
|
||||
await page.waitForFunction(
|
||||
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
const content = row?.firstElementChild;
|
||||
if (content instanceof HTMLElement) content.style.paddingBottom = `${Number.parseFloat(content.style.paddingBottom || "0") + 900}px`;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const duringNativeThumbDrag = await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return {
|
||||
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
|
||||
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
|
||||
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
|
||||
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
|
||||
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
|
||||
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(
|
||||
(knownSize) => {
|
||||
const transcriptElement = document.querySelector(".transcript");
|
||||
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return transcriptElement?.dataset.nativeScrollbarDrag !== "true"
|
||||
&& row instanceof HTMLElement
|
||||
&& Number.parseFloat(row.dataset.knownSize || "0") > knownSize + 800;
|
||||
},
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
assert(true, "native thumb release resumes real row measurement");
|
||||
if (process.platform === "darwin" && (!nativeThumbProbe || nativeThumbProbe.gutter <= 1)) {
|
||||
// macOS Chromium inherits system overlay scrollbars, so there is no
|
||||
// pointer-addressable native gutter. Linux/Windows CI still exercises the
|
||||
// complete thumb ownership and measurement-freeze path below.
|
||||
process.stdout.write(" SKIP native thumb drag (host uses overlay scrollbars)\n");
|
||||
} else {
|
||||
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
|
||||
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
|
||||
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
|
||||
await page.waitForFunction(
|
||||
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
const content = row?.firstElementChild;
|
||||
if (content instanceof HTMLElement) content.style.paddingBottom = `${Number.parseFloat(content.style.paddingBottom || "0") + 900}px`;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const duringNativeThumbDrag = await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return {
|
||||
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
|
||||
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
|
||||
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
|
||||
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
|
||||
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
|
||||
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(
|
||||
(knownSize) => {
|
||||
const transcriptElement = document.querySelector(".transcript");
|
||||
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return transcriptElement?.dataset.nativeScrollbarDrag !== "true"
|
||||
&& row instanceof HTMLElement
|
||||
&& Number.parseFloat(row.dataset.knownSize || "0") > knownSize + 800;
|
||||
},
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
assert(true, "native thumb release resumes real row measurement");
|
||||
}
|
||||
|
||||
// Explicit bottom owns the tail. Subsequent async growth must use Virtuoso's
|
||||
// autoscroll API and remain at the physical bottom without Reasonix scrollTop
|
||||
|
||||
@@ -12,12 +12,103 @@ process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH ||
|
||||
const { chromium } = await import("playwright");
|
||||
const port = Number(process.env.REASONIX_TRANSCRIPT_BROWSER_PORT ?? 4618);
|
||||
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
|
||||
const bottomTimeout = Number(process.env.REASONIX_TRANSCRIPT_BOTTOM_TIMEOUT ?? 30_000);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
async function waitForVisibleSelectionStart(page, { preferHighest, wheelDelta, timeout = 15_000 } = {}) {
|
||||
const box = await page.locator(".transcript").boundingBox();
|
||||
if (box) await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
const delta = wheelDelta ?? (preferHighest ? -400 : 400);
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const points = await page.evaluate((prefer) => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const candidates = [...transcript.querySelectorAll("[data-transcript-selectable]")]
|
||||
.map((element) => ({
|
||||
element,
|
||||
turn: Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1),
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter(({ turn, rect }) => turn >= 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom)
|
||||
.sort((left, right) => (prefer ? right.turn - left.turn : left.turn - right.turn));
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) return null;
|
||||
const walker = document.createTreeWalker(candidate.element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
const start = rects.find((rect) => rect.width > 8 && rect.bottom > viewport.top && rect.top < viewport.bottom) ?? candidate.rect;
|
||||
if (!start) return null;
|
||||
const startX = Math.min(start.right - 4, start.left + Math.max(start.width * 0.45, 60));
|
||||
const y = (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2;
|
||||
return {
|
||||
start: { x: startX, y },
|
||||
activate: { x: Math.min(start.right - 2, startX + 30), y },
|
||||
edge: { x: startX, y: prefer ? viewport.top + 2 : viewport.bottom - 2 },
|
||||
anchorTurn: candidate.turn,
|
||||
};
|
||||
}, preferHighest);
|
||||
if (points) return points;
|
||||
// Wheel, do not assign scrollTop: a pinned tail-follow snaps programmatic
|
||||
// writes back to the native bottom before Virtuoso can mount a user row.
|
||||
await page.mouse.wheel(0, delta);
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
const diag = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
const viewport = transcript?.getBoundingClientRect();
|
||||
return {
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
selectables: document.querySelectorAll("[data-transcript-selectable]").length,
|
||||
turnHits: [...document.querySelectorAll("[data-transcript-selectable]")].filter((el) => /\bbench turn \d+:/.test(el.textContent || "")).length,
|
||||
scrollTop: transcript?.scrollTop ?? null,
|
||||
scrollHeight: transcript?.scrollHeight ?? null,
|
||||
clientHeight: transcript?.clientHeight ?? null,
|
||||
mode: transcript?.dataset.scrollMode ?? null,
|
||||
viewport: viewport ? { top: viewport.top, bottom: viewport.bottom } : null,
|
||||
};
|
||||
});
|
||||
throw new Error(`no visible bench-turn selectable (${JSON.stringify(diag)})`);
|
||||
}
|
||||
|
||||
async function findVisibleTurnTarget(page, turnPredicate) {
|
||||
return page.evaluate((predicate) => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const root = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const turn = Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1);
|
||||
return (predicate.min != null ? turn >= predicate.min : turn <= predicate.max)
|
||||
&& rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
});
|
||||
if (!root) return null;
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(rect.right - 2, rect.left + Math.max(rect.width * 0.45, 24)),
|
||||
y: (Math.max(rect.top, viewport.top) + Math.min(rect.bottom, viewport.bottom)) / 2,
|
||||
};
|
||||
}, turnPredicate);
|
||||
}
|
||||
|
||||
async function waitForLogicalSelection(page, timeout = 8_000) {
|
||||
return page.waitForFunction(
|
||||
() => document.querySelector(".transcript")?.dataset.scrollMode === "selection",
|
||||
undefined,
|
||||
{ timeout },
|
||||
).then(() => true, () => false);
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -42,7 +133,10 @@ const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port)
|
||||
let browser;
|
||||
try {
|
||||
await waitForServer();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Performance.enable");
|
||||
@@ -74,66 +168,36 @@ try {
|
||||
}
|
||||
const jumpBottom = page.locator(".transcript__jump-bottom");
|
||||
if (await jumpBottom.count()) await jumpBottom.click();
|
||||
await page.waitForFunction(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
return transcript && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 1;
|
||||
}, undefined, { timeout: 30_000 });
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const visibleSelectable = await page.evaluate(() => {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return false;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const visible = [...transcript.querySelectorAll("[data-transcript-selectable]")].some((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
});
|
||||
if (!visible) transcript.scrollTop -= transcript.clientHeight;
|
||||
return visible;
|
||||
return transcript && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 1;
|
||||
}, undefined, { timeout: bottomTimeout });
|
||||
} catch (error) {
|
||||
const bottomState = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
return {
|
||||
distance: transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight,
|
||||
scrollTop: transcript.scrollTop,
|
||||
scrollHeight: transcript.scrollHeight,
|
||||
clientHeight: transcript.clientHeight,
|
||||
mode: transcript.dataset.scrollMode,
|
||||
rows: transcript.querySelectorAll(".transcript__row").length,
|
||||
padding: getComputedStyle(transcript).padding,
|
||||
viewportRect: transcript.querySelector('[data-viewport-type="element"]')?.getBoundingClientRect().toJSON(),
|
||||
listRect: transcript.querySelector(".transcript__virtual-sizer")?.getBoundingClientRect().toJSON(),
|
||||
lastRowRect: [...transcript.querySelectorAll(".transcript__row")].at(-1)?.getBoundingClientRect().toJSON(),
|
||||
transcriptRect: transcript.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
});
|
||||
if (visibleSelectable) break;
|
||||
await page.waitForTimeout(50);
|
||||
throw new Error(`jump-bottom did not settle at the native tail (${JSON.stringify(bottomState)})`, { cause: error });
|
||||
}
|
||||
|
||||
// Tool-dense fixtures often land the native tail on non-selectable tool
|
||||
// rows. Wheel off the pinned tail until a mounted "bench turn N" row is
|
||||
// visible, then snapshot the virtual window for the later bound check.
|
||||
const points = await waitForVisibleSelectionStart(page, { preferHighest: true });
|
||||
const baselineRows = await page.locator(".transcript__row").count();
|
||||
const points = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const candidates = [...transcript.querySelectorAll("[data-transcript-selectable]")]
|
||||
.map((element) => ({
|
||||
element,
|
||||
turn: Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1),
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter(({ turn, rect }) => turn >= 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom)
|
||||
.sort((left, right) => right.turn - left.turn);
|
||||
const candidate = candidates[0];
|
||||
const textRects = candidate ? (() => {
|
||||
const walker = document.createTreeWalker(candidate.element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
return rects;
|
||||
})() : [];
|
||||
const start = textRects.find((rect) => rect.width > 8 && rect.bottom > viewport.top && rect.top < viewport.bottom) ?? candidate?.rect;
|
||||
if (!start) return null;
|
||||
// Press mid-text rather than at the row's left edge. A left-edge caret
|
||||
// freezes the anchor at offset 0, so an upward drag legitimately copies
|
||||
// nothing from the anchor turn and the 20-turn count would depend only on
|
||||
// how far the focus lands. Mid-text also exercises the frozen anchor's
|
||||
// real character offset instead of the trivial row boundary.
|
||||
const startX = Math.min(start.right - 4, start.left + Math.max(start.width * 0.45, 60));
|
||||
return {
|
||||
start: { x: startX, y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
|
||||
activate: { x: Math.min(start.right - 2, startX + 30), y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
|
||||
edge: { x: startX, y: viewport.top + 2 },
|
||||
anchorTurn: candidate.turn,
|
||||
};
|
||||
});
|
||||
assert(points != null, "bench transcript exposes a selectable visible message");
|
||||
|
||||
await page.evaluate(() => {
|
||||
@@ -149,7 +213,7 @@ try {
|
||||
mode: document.querySelector(".transcript")?.dataset.scrollMode,
|
||||
target: document.elementFromPoint(x, y)?.outerHTML.slice(0, 300),
|
||||
}), points.start);
|
||||
assert(downState.mode === "native-selecting", `primary pointerdown transfers scroll ownership to native selection (${downState.mode}; ${downState.target})`);
|
||||
assert(downState.mode === "selection", `primary pointerdown transfers scroll ownership to selection (${downState.mode}; ${downState.target})`);
|
||||
await page.mouse.move(points.activate.x, points.activate.y, { steps: 6 });
|
||||
await page.waitForTimeout(50);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
@@ -272,7 +336,7 @@ try {
|
||||
};
|
||||
}, logicalFocusPoint);
|
||||
assert(during.collapsed, `cross-row drag releases the browser Range after logical promotion (${JSON.stringify(during)})`);
|
||||
assert(during.mode === "logical-selecting", `cross-page drag remains owned by logical selection (${during.mode})`);
|
||||
assert(during.mode === "selection", `cross-page drag remains owned by selection (${during.mode})`);
|
||||
assert(during.rows <= Math.ceil(baselineRows * 1.1) + 2, `logical selection keeps the virtual DOM bounded (${baselineRows} → ${during.rows})`);
|
||||
assert(during.overlayRects > 0, `logical selection paints mounted-row overlay rectangles (${JSON.stringify(during)})`);
|
||||
if (during.writeOwners.some((owner) => owner !== "selection-edge-scroll")) {
|
||||
@@ -335,53 +399,28 @@ try {
|
||||
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing logical selection preserves the normal virtual DOM window");
|
||||
const selectionHeapBaseline = await retainedHeap();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = 0;
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
// Query and scroll advancement are separate steps: advancing scrollTop in
|
||||
// the same evaluate as a missed query lets the scroll position run ahead of
|
||||
// Virtuoso's row mounting under load, skipping every selectable row. Give
|
||||
// each position two settle periods and wrap around at the bottom.
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "manual", undefined, { timeout: 10_000 });
|
||||
await page.mouse.up();
|
||||
// Same contract as the upward drag: start mid-text on an early mounted
|
||||
// turn, then land on a target at least 21 turns later. Programmatic
|
||||
// scrollTop=0 does not unpin tail-follow or load older virtual pages.
|
||||
const transcriptBox = await page.locator(".transcript").boundingBox();
|
||||
if (transcriptBox) await page.mouse.move(transcriptBox.x + transcriptBox.width / 2, transcriptBox.y + transcriptBox.height / 2);
|
||||
const topDeadline = Date.now() + 15_000;
|
||||
let forwardPoints = null;
|
||||
for (let index = 0; index < 120 && !forwardPoints; index += 1) {
|
||||
await page.waitForTimeout(50);
|
||||
forwardPoints = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const textRects = [...transcript.querySelectorAll("[data-transcript-selectable]")].flatMap((element) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
return rects;
|
||||
}).filter((rect) => rect.width > 8 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4);
|
||||
const start = textRects[0];
|
||||
if (!start) return null;
|
||||
const y = (Math.max(start.top, viewport.top + 4) + Math.min(start.bottom, viewport.bottom - 4)) / 2;
|
||||
return {
|
||||
start: { x: start.left + 2, y },
|
||||
activate: { x: Math.min(start.right - 2, start.left + 32), y },
|
||||
edge: { x: start.left + 2, y: viewport.bottom - 2 },
|
||||
};
|
||||
});
|
||||
if (!forwardPoints && index % 2 === 1) {
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
const max = transcript.scrollHeight - transcript.clientHeight;
|
||||
transcript.scrollTop = transcript.scrollTop >= max - 4 ? 0 : transcript.scrollTop + transcript.clientHeight / 2;
|
||||
});
|
||||
while (Date.now() < topDeadline && (!forwardPoints || forwardPoints.anchorTurn > 17)) {
|
||||
const older = page.locator(".transcript__older");
|
||||
if (await older.count()) await older.click();
|
||||
await page.mouse.wheel(0, -700);
|
||||
await page.waitForTimeout(80);
|
||||
try {
|
||||
forwardPoints = await waitForVisibleSelectionStart(page, { preferHighest: false, wheelDelta: -400, timeout: 1_200 });
|
||||
} catch {
|
||||
forwardPoints = null;
|
||||
}
|
||||
}
|
||||
assert(forwardPoints != null, "settled reverse selection leaves a viewport where forward selection can start");
|
||||
assert(forwardPoints.anchorTurn <= 17, `forward drag must start early enough to cover 20 turns (${forwardPoints.anchorTurn})`);
|
||||
await page.evaluate(() => {
|
||||
window.__transcriptProgrammaticWrites = [];
|
||||
window.__logicalClipboardText = null;
|
||||
@@ -392,44 +431,30 @@ try {
|
||||
await page.mouse.move(forwardPoints.start.x, forwardPoints.start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(forwardPoints.activate.x, forwardPoints.activate.y, { steps: 6 });
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
await page.mouse.wheel(0, 500);
|
||||
await page.waitForTimeout(50);
|
||||
const visibleTarget = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const element = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.width > 8 && rect.height > 0 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4;
|
||||
});
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(rect.right - 2, rect.left + 8),
|
||||
y: (Math.max(rect.top, viewport.top + 4) + Math.min(rect.bottom, viewport.bottom - 4)) / 2,
|
||||
};
|
||||
});
|
||||
if (visibleTarget) {
|
||||
await page.mouse.move(visibleTarget.x, visibleTarget.y, { steps: 4 });
|
||||
} else {
|
||||
await page.mouse.move(
|
||||
forwardPoints.edge.x + (index % 2 === 0 ? 2 : -2),
|
||||
forwardPoints.edge.y - (index % 2),
|
||||
{ steps: 4 },
|
||||
);
|
||||
const forwardTargetTurn = forwardPoints.anchorTurn + 21;
|
||||
let forwardFocus = null;
|
||||
for (let index = 0; index < 80 && !forwardFocus; index += 1) {
|
||||
forwardFocus = await findVisibleTurnTarget(page, { min: forwardTargetTurn });
|
||||
if (!forwardFocus) {
|
||||
await page.mouse.wheel(0, 250);
|
||||
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y, { steps: 4 });
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
const mode = await page.locator(".transcript").getAttribute("data-scroll-mode");
|
||||
if (mode === "logical-selecting") break;
|
||||
}
|
||||
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y);
|
||||
await page.waitForTimeout(8_000);
|
||||
assert(forwardFocus != null, "downward logical drag settles over a visible 20+ turn target");
|
||||
let forwardPromoted = false;
|
||||
for (let attempt = 0; attempt < 5 && !forwardPromoted; attempt += 1) {
|
||||
await page.mouse.move(forwardFocus.x + 24, forwardFocus.y, { steps: 4 });
|
||||
await page.mouse.move(forwardFocus.x, forwardFocus.y, { steps: 8 });
|
||||
forwardPromoted = await waitForLogicalSelection(page, 3_000);
|
||||
if (!forwardPromoted) forwardFocus = (await findVisibleTurnTarget(page, { min: forwardTargetTurn })) ?? forwardFocus;
|
||||
}
|
||||
const forwardDuring = await page.evaluate(() => ({
|
||||
mode: document.querySelector(".transcript")?.dataset.scrollMode,
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
owners: [...new Set((window.__transcriptProgrammaticWrites ?? []).map((write) => write.owner))],
|
||||
}));
|
||||
assert(forwardDuring.mode === "logical-selecting", "downward cross-page drag also promotes to logical selection");
|
||||
assert(forwardDuring.mode === "selection", "downward cross-page drag also promotes to logical selection");
|
||||
assert(forwardDuring.rows <= Math.ceil(baselineRows * 1.1) + 2, "forward logical selection also keeps the virtual DOM bounded");
|
||||
assert(forwardDuring.owners.every((owner) => owner === "selection-edge-scroll"), "forward logical gesture preserves scroll ownership");
|
||||
await page.mouse.up();
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/workspace-layout.test.ts",
|
||||
"test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx",
|
||||
"test:task-monitor": "tsx src/components/TaskMonitorPanel.test.tsx && tsx src/__tests__/task-monitor-navigation.test.ts",
|
||||
"test:workspace": "tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-context-menu.test.tsx",
|
||||
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
|
||||
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
|
||||
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
|
||||
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx",
|
||||
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
|
||||
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
|
||||
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
|
||||
@@ -29,7 +29,7 @@
|
||||
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
|
||||
"test:bench": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/run.mjs",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
|
||||
"test:isolated-worktree": "tsx src/__tests__/isolated-worktree.test.ts",
|
||||
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
|
||||
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
|
||||
@@ -82,6 +82,7 @@
|
||||
"vite": "^8.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "3.4.13",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
Generated
+5
-4
@@ -5,6 +5,7 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
dompurify: 3.4.13
|
||||
mdast-util-gfm-autolink-literal: 2.0.0
|
||||
undici: 7.29.0
|
||||
|
||||
@@ -1122,8 +1123,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
|
||||
[email protected]3:
|
||||
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==}
|
||||
@@ -3021,7 +3022,7 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
[email protected]2:
|
||||
[email protected]3:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -3635,7 +3636,7 @@ snapshots:
|
||||
d3-sankey: 0.12.3
|
||||
dagre-d3-es: 7.0.14
|
||||
dayjs: 1.11.21
|
||||
dompurify: 3.4.12
|
||||
dompurify: 3.4.13
|
||||
es-toolkit: 1.49.0
|
||||
katex: 0.16.47
|
||||
khroma: 2.1.0
|
||||
|
||||
@@ -2,5 +2,6 @@ allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
overrides:
|
||||
dompurify: 3.4.13
|
||||
mdast-util-gfm-autolink-literal: 2.0.0
|
||||
undici: 7.29.0
|
||||
@@ -60,9 +60,15 @@ console.log("\nbundle budgets");
|
||||
// React Virtuoso replaces the transcript's custom measurement/anchor engine.
|
||||
// Its production runtime adds 16.9 KiB gzip (4.2%) over the 402 KiB baseline.
|
||||
// This exceptional overrun is locally attributable and trades ~1400 lines of
|
||||
// competing state machines for a maintained library. The new gates retain 1%
|
||||
// headroom (4.6 KiB gzip / 23.2 KiB raw) to bound incidental feature growth.
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, 423.5 * 1024);
|
||||
// competing state machines for a maintained library. Native-tail finish helpers
|
||||
// then sat on the 423.5 KiB gate (Windows CI: 423.5 / 423.5); this 0.5 KiB
|
||||
// raise (0.12%) absorbs that leave-cancel / remasure-once code without
|
||||
// widening the original Virtuoso exception. The project-tree archive race
|
||||
// guards add 611 bytes gzip over main-v2's 423.988 KiB startup path after the
|
||||
// blank-project flow landed; project-topic sort invalidation and request
|
||||
// ordering add another bounded 0.2 KiB. Retain both owner boundaries with a
|
||||
// narrowly rounded 1 KiB ratchet.
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, 425.0 * 1024);
|
||||
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
|
||||
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
|
||||
// Extension surfaces, Task Monitor, and compact decision receipts share the
|
||||
@@ -75,9 +81,10 @@ if (localeChunks.length !== 2) {
|
||||
for (const path of localeChunks) {
|
||||
const name = basename(path);
|
||||
// Task Monitor, billing, indexed history, Task Center, Extension UI, and
|
||||
// runtime controls plus execution-setting receipts add localized copy. Keep
|
||||
// both dictionaries bounded.
|
||||
const budget = name.startsWith("zh-TW-") ? 55.5 * 1024 : 54.5 * 1024;
|
||||
// runtime controls plus execution-setting receipts add localized copy. The
|
||||
// write-access approval card adds four scoped actions and a home-risk
|
||||
// warning (~0.15 KiB gzip, +0.27% over the old 54.75 gate).
|
||||
const budget = name.startsWith("zh-TW-") ? 55.8 * 1024 : 55.0 * 1024;
|
||||
assertBudget(`${name} gzip`, gzipBytes(path), budget);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const packageJSON = JSON.parse(readFileSync(resolve(repoRoot, "desktop/frontend/
|
||||
const appSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/App.tsx"), "utf8");
|
||||
const bridgeSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/lib/bridge.ts"), "utf8");
|
||||
const desktopMainSource = readFileSync(resolve(repoRoot, "desktop/main.go"), "utf8");
|
||||
const transcriptScrollBenchSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/transcript-scroll-stability.mjs"), "utf8");
|
||||
|
||||
function jobBody(name, nextName) {
|
||||
const match = workflow.match(new RegExp(`\\n ${name}:\\n([\\s\\S]*?)\\n ${nextName}:`));
|
||||
@@ -138,5 +139,16 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability
|
||||
throw new Error(`motion-ci-contract: test:transcript-browser must include ${required}`);
|
||||
}
|
||||
}
|
||||
for (const required of [
|
||||
"PerformanceObserver",
|
||||
"__reasonixScrollPerfProbe",
|
||||
"REASONIX_TRANSCRIPT_MAX_FRAME_GAP_MS",
|
||||
"REASONIX_TRANSCRIPT_MAX_LONG_TASK_MS",
|
||||
"Array.from({ length: 10 }",
|
||||
]) {
|
||||
if (!transcriptScrollBenchSource.includes(required)) {
|
||||
throw new Error(`motion-ci-contract: transcript scroll browser gate must retain performance probe ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("motion-ci-contract: browser approval behavior and exact-binary WebView2 startup are separate release gates without production smoke instrumentation");
|
||||
@@ -19,6 +19,7 @@ const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toStrin
|
||||
const {
|
||||
dismissedTodoKeyForScope,
|
||||
resolveTodoPanelTodos,
|
||||
sameStringList,
|
||||
sameTodoList,
|
||||
scopedTodoBatchKey,
|
||||
scopedTodoDismissalKey,
|
||||
@@ -161,6 +162,19 @@ assert.equal(
|
||||
true,
|
||||
"an incomplete restored todo list must reappear even after a stale local dismissal",
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(activeKey, null, activeTodos, { batchKey: todoBatchKey(activeTodos), batches: [todoBatchKey(activeTodos)] }),
|
||||
true,
|
||||
"a persisted completed-batch dismissal cannot hide unfinished work",
|
||||
);
|
||||
const completedBatch = todoBatchKey(completedTodos);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, null, completedTodos, { batchKey: completedBatch, batches: [completedBatch] }),
|
||||
false,
|
||||
"a session-sidecar batch dismissal hides the completed shelf after upgrade remount",
|
||||
);
|
||||
assert.equal(sameStringList(["a"], ["a"]), true, "identical dismissed batch lists compare equal");
|
||||
assert.equal(sameStringList(["a"], ["b"]), false, "changed dismissed batch lists compare unequal");
|
||||
assert.notEqual(
|
||||
activeKey,
|
||||
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
|
||||
+90
-225
@@ -52,6 +52,7 @@ import { ClearContextCard } from "./components/ClearContextCard";
|
||||
import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard";
|
||||
import { decisionSurfaceMockFromInput, type DecisionSurfaceKind as MockDecisionSurfaceKind } from "./lib/decisionSurfaceMock";
|
||||
const UndoRewindBanner = lazy(() => import("./components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner })));
|
||||
const ProjectTree = lazy(() => import("./components/ProjectTree").then((module) => ({ default: module.ProjectTree })));
|
||||
/** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */
|
||||
type DecisionSurfaceKind = MockDecisionSurfaceKind | "extension_form";
|
||||
import { StatusBar } from "./components/StatusBar";
|
||||
@@ -68,7 +69,6 @@ import { OnboardingOverlay } from "./components/OnboardingOverlay";
|
||||
import { dismissOnboarding, shouldOpenOnboarding } from "./lib/onboarding";
|
||||
import { AppChrome } from "./components/AppChrome";
|
||||
import { ShortcutsCheatsheet } from "./components/ShortcutsCheatsheet";
|
||||
import { ProjectTree } from "./components/ProjectTree";
|
||||
import { WorktreeBadge } from "./components/WorktreeBadge";
|
||||
import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
@@ -106,13 +106,12 @@ import {
|
||||
type SessionMeta,
|
||||
type SettingsView,
|
||||
type TabMeta,
|
||||
type TokenMode,
|
||||
type ToolApprovalMode,
|
||||
type WorkspaceConflictView,
|
||||
} from "./lib/types";
|
||||
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
|
||||
import type { RewindUndoState } from "./lib/rewindTypes";
|
||||
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
|
||||
import { workspaceTreeVisitId } from "./lib/workspaceTreeMemory";
|
||||
import { resolveTaskMonitorSession } from "./lib/taskMonitorNavigation";
|
||||
import {
|
||||
composerProfileFromMeta,
|
||||
@@ -195,7 +194,7 @@ import { applyConfiguredBaseAppearance, applyThemePack, applyThemeScene, clearTh
|
||||
import { ThemeBackground } from "./components/ThemeBackground";
|
||||
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
|
||||
import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState";
|
||||
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelWidth, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { createRafResizeUpdater } from "./lib/resizeDrag";
|
||||
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { useMountTransition } from "./lib/useMountTransition";
|
||||
@@ -212,13 +211,6 @@ function noticePreviewMockEnabled(): boolean {
|
||||
const value = browserMockScenarioParam();
|
||||
return value === "notice" || value === "notices" || value === "notice-preview";
|
||||
}
|
||||
function runtimeProfileShortKey(mode: TokenMode) {
|
||||
return mode === "economy"
|
||||
? "composer.runtimeProfileEconomyShort" as const
|
||||
: mode === "delivery"
|
||||
? "composer.runtimeProfileDeliveryShort" as const
|
||||
: "composer.runtimeProfileBalancedShort" as const;
|
||||
}
|
||||
function noticePreviewItems(): Item[] {
|
||||
const notice = (index: number, level: "info" | "warn", text: string, detail: string, code?: string): Item => ({
|
||||
kind: "notice",
|
||||
@@ -288,7 +280,13 @@ const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then(
|
||||
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
|
||||
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
|
||||
const TaskMonitorPanel = lazy(() => import("./components/TaskMonitorPanel").then((module) => ({ default: module.TaskMonitorPanel })));
|
||||
const WorkspacePanel = lazy(() => import("./components/WorkspacePanel").then((module) => ({ default: module.WorkspacePanel })));
|
||||
const WorkspacePanel = lazy(async () => {
|
||||
const [module] = await Promise.all([
|
||||
import("./components/WorkspacePanel"),
|
||||
import("./components/WorkspacePanelStability.css"),
|
||||
]);
|
||||
return { default: module.WorkspacePanel };
|
||||
});
|
||||
|
||||
const CHAT_MIN_WIDTH = 400;
|
||||
const CHAT_COMFORT_MIN_WIDTH = 560;
|
||||
@@ -445,7 +443,7 @@ type SidebarImConnection = {
|
||||
type DesktopNavigationIntent =
|
||||
| { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string }
|
||||
| { kind: "blank"; scope: string; workspaceRoot: string }
|
||||
| { kind: "delivery-worktree"; workspaceRoot: string }
|
||||
| { kind: "isolated-worktree"; workspaceRoot: string }
|
||||
| { kind: "sidebar-im"; connection: SidebarImConnection }
|
||||
| { kind: "resume-session"; session: SessionMeta };
|
||||
type DesktopNavigationInput = DesktopNavigationIntent & { navigationIntentSeq: number };
|
||||
@@ -1094,11 +1092,10 @@ export default function App() {
|
||||
undoRewindForTab,
|
||||
setModel,
|
||||
setEffort,
|
||||
setTokenMode,
|
||||
cancelJob,
|
||||
switchTab,
|
||||
openProjectTab,
|
||||
createDeliveryWorktree,
|
||||
createIsolatedWorktree,
|
||||
openGlobalTab,
|
||||
closeTab,
|
||||
reorderTabs,
|
||||
@@ -1113,8 +1110,6 @@ export default function App() {
|
||||
const { locale, setPref: setLocalePref } = useI18n();
|
||||
const t = useT();
|
||||
const [composerProfilesByTab, setComposerProfilesByTab] = useState<Record<string, ComposerProfile>>({});
|
||||
const runtimeTransitionTabsRef = useRef<Set<string>>(new Set());
|
||||
const [runtimeTransitionsByTab, setRuntimeTransitionsByTab] = useState<Record<string, true>>({});
|
||||
const yoloRestoreToolApprovalModesRef = useRef<Record<string, RestorableToolApprovalMode>>({});
|
||||
const userPlanModeByTabRef = useRef<UserPlanModeIntents>({});
|
||||
const [tabMetas, setTabMetas] = useState<TabMeta[]>([]);
|
||||
@@ -1289,13 +1284,6 @@ export default function App() {
|
||||
const [clearContextPending, setClearContextPending] = useState(false);
|
||||
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
|
||||
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
|
||||
const [pendingModeSwitch, setPendingModeSwitch] = useState<{
|
||||
tabId: string;
|
||||
target: TokenMode;
|
||||
previous: TokenMode;
|
||||
work: ActiveWorkView;
|
||||
stopping: boolean;
|
||||
} | null>(null);
|
||||
const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null);
|
||||
const topicRenameSkipCommitRef = useRef(false);
|
||||
const prevDecisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
|
||||
@@ -1358,10 +1346,6 @@ export default function App() {
|
||||
};
|
||||
}, [activeTabId, state.running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingModeSwitch && pendingModeSwitch.tabId !== activeTabId) setPendingModeSwitch(null);
|
||||
}, [activeTabId, pendingModeSwitch]);
|
||||
|
||||
const closeTransientOverlays = useCallback(() => {
|
||||
setTransientOverlayDismissSignal((signal) => signal + 1);
|
||||
}, []);
|
||||
@@ -1597,32 +1581,18 @@ export default function App() {
|
||||
: RIGHT_DOCK_MIN_RENDER_WIDTH;
|
||||
const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth;
|
||||
const chatReservedWidth = workspacePanelOpen && !workspacePanelMaximized ? CHAT_COMFORT_MIN_WIDTH : CHAT_MIN_WIDTH;
|
||||
const workspacePanelAvailableWidth = availableWorkspacePanelWidth({
|
||||
viewportWidth,
|
||||
sidebarCollapsed,
|
||||
sidebarWidth,
|
||||
chatMinWidth: chatReservedWidth,
|
||||
resizerWidth: WORKSPACE_RESIZER_WIDTH,
|
||||
const {
|
||||
renderWidth: workspacePanelRenderWidth,
|
||||
overlay: workspacePanelOverlay,
|
||||
renderable: workspacePanelRenderable,
|
||||
gridOpen: workspacePanelGridOpen,
|
||||
} = resolveWorkspacePanelPlacement({
|
||||
viewportWidth, sidebarCollapsed, sidebarWidth, chatMinWidth: chatReservedWidth,
|
||||
resizerWidth: WORKSPACE_RESIZER_WIDTH, open: workspacePanelOpen,
|
||||
maximized: workspacePanelMaximized, preferredWidth: preferredWorkspacePanelWidth,
|
||||
minWidth: workspacePanelMinWidth, minRenderWidth: rightDockMinRenderWidth,
|
||||
liveWidth: liveWorkspacePanelRenderWidth,
|
||||
});
|
||||
|
||||
const resolvedWorkspacePanelWidth = resolveWorkspacePanelWidth({
|
||||
open: workspacePanelOpen,
|
||||
maximized: workspacePanelMaximized,
|
||||
preferredWidth: preferredWorkspacePanelWidth,
|
||||
minWidth: workspacePanelMinWidth,
|
||||
availableWidth: workspacePanelAvailableWidth,
|
||||
});
|
||||
|
||||
const storedWorkspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth;
|
||||
const workspacePanelRenderWidth = liveWorkspacePanelRenderWidth ?? storedWorkspacePanelRenderWidth;
|
||||
// The terminal is an independent bottom drawer; workspace panel renderability
|
||||
// no longer depends on terminal mode.
|
||||
const workspacePanelRenderable =
|
||||
workspacePanelOpen && (
|
||||
workspacePanelMaximized ||
|
||||
workspacePanelRenderWidth >= rightDockMinRenderWidth
|
||||
);
|
||||
const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized;
|
||||
const resolveLiveWorkspacePanelRenderWidth = useCallback(
|
||||
(preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
|
||||
resolveLiveWorkspacePanelWidth({
|
||||
@@ -1671,14 +1641,17 @@ export default function App() {
|
||||
state.sessionGen,
|
||||
workspaceControllerEpoch,
|
||||
].join("\u0000");
|
||||
// A topic may contain multiple saved sessions; the concrete session path is
|
||||
// the runtime conversation identity, with topic/tab ids only as fallbacks.
|
||||
// Workspace navigation belongs to the project, not to a single conversation.
|
||||
// A session switch inside the same project must therefore retain the dock,
|
||||
// tree and selection state.
|
||||
const workspaceTreeMemoryKey = [
|
||||
activeTab?.scope ?? "",
|
||||
activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
|
||||
activeTab?.sessionPath || state.meta?.sessionPath || activeTab?.topicId || activeTabId || "",
|
||||
].join("\u0000");
|
||||
const workspaceTreeMemoryVisitId = workspaceTreeVisitId(workspaceTreeMemoryKey);
|
||||
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, previewWidth: number) => {
|
||||
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth));
|
||||
setRightDockPreviewWidth(clampRightDockPreviewWidth(previewWidth));
|
||||
}, [rightDockTreeWidthClamp]);
|
||||
const sidebarImDetailConnection = useMemo(
|
||||
() => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
|
||||
[sidebarImConnections, sidebarImDetailConnectionId],
|
||||
@@ -1729,8 +1702,7 @@ export default function App() {
|
||||
const goal = composerProfile.goal;
|
||||
const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile);
|
||||
const toolApprovalMode = composerProfile.toolApprovalMode;
|
||||
const tokenMode: TokenMode = composerProfile.tokenMode;
|
||||
const runtimeTransitioning = Boolean(activeTabId && runtimeTransitionsByTab[activeTabId]);
|
||||
const runtimeTransitioning = false;
|
||||
const controllerReady =
|
||||
state.meta?.ready === true &&
|
||||
(!state.meta.runtime || state.meta.runtime.phase === "ready") &&
|
||||
@@ -1746,11 +1718,10 @@ export default function App() {
|
||||
if (state.ask) return "ask";
|
||||
if (state.extensionForm) return "extension_form";
|
||||
if (workspaceConflict) return "workspace_conflict";
|
||||
if (pendingModeSwitch) return "mode_jobs";
|
||||
if (pendingClose) return "close_active";
|
||||
if (clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}, [clearContextPending, pendingClose, pendingModeSwitch, state.approval, state.ask, state.extensionForm, workspaceConflict]);
|
||||
}, [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, workspaceConflict]);
|
||||
decisionSurfaceRef.current = decisionSurface;
|
||||
useEffect(() => {
|
||||
// Close composer menus/popovers when a decision takes over the footer.
|
||||
@@ -1849,7 +1820,6 @@ export default function App() {
|
||||
mode: composerProfileMode(profile),
|
||||
collaborationMode: displayedComposerProfileCollaborationMode(profile),
|
||||
toolApprovalMode: profile.toolApprovalMode,
|
||||
tokenMode: profile.tokenMode,
|
||||
goal: profile.goal,
|
||||
active: tab.id === visibleTabId,
|
||||
};
|
||||
@@ -1977,77 +1947,6 @@ export default function App() {
|
||||
},
|
||||
[activeTabId, applyGoalForTab],
|
||||
);
|
||||
const commitTokenModeSwitch = useCallback(
|
||||
async (tabId: string, m: TokenMode, previous: TokenMode, profile: ComposerProfile): Promise<void> => {
|
||||
if (!tabId || activeTabIdRef.current !== tabId || runtimeTransitionTabsRef.current.has(tabId)) return;
|
||||
runtimeTransitionTabsRef.current.add(tabId);
|
||||
setRuntimeTransitionsByTab((current) => ({ ...current, [tabId]: true }));
|
||||
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, profile, { tokenMode: m }, ["tokenMode"]));
|
||||
const switched = await setTokenMode(m);
|
||||
if (!switched) {
|
||||
setComposerProfilesByTab((current) => {
|
||||
const currentProfile = current[tabId] ?? profile;
|
||||
const pending = { ...currentProfile.pending };
|
||||
delete pending.tokenMode;
|
||||
return { ...current, [tabId]: { ...currentProfile, tokenMode: previous, pending } };
|
||||
});
|
||||
}
|
||||
runtimeTransitionTabsRef.current.delete(tabId);
|
||||
setRuntimeTransitionsByTab((current) => {
|
||||
if (!current[tabId]) return current;
|
||||
const next = { ...current };
|
||||
delete next[tabId];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setTokenMode],
|
||||
);
|
||||
const applyTokenMode = useCallback(
|
||||
async (m: TokenMode): Promise<void> => {
|
||||
const tabId = activeTabId;
|
||||
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
|
||||
const previous = composerProfile.tokenMode;
|
||||
try {
|
||||
const work = await app.ActiveWorkForTab(tabId);
|
||||
if (work.jobs.length > 0) {
|
||||
setPendingModeSwitch({ tabId, target: m, previous, work, stopping: false });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The atomic backend guard remains authoritative on older runtimes.
|
||||
}
|
||||
await commitTokenModeSwitch(tabId, m, previous, composerProfile);
|
||||
},
|
||||
[activeTabId, commitTokenModeSwitch, composerProfile],
|
||||
);
|
||||
const stopJobsAndSwitchMode = useCallback(async () => {
|
||||
const request = pendingModeSwitch;
|
||||
if (!request || request.stopping) return;
|
||||
setPendingModeSwitch({ ...request, stopping: true });
|
||||
try {
|
||||
await app.CancelJobsForTab(request.tabId, request.work.jobs.map((job) => job.id));
|
||||
const deadline = Date.now() + 15_000;
|
||||
let work = await app.ActiveWorkForTab(request.tabId);
|
||||
while (work.jobs.length > 0 && Date.now() < deadline) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work } : current);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 150));
|
||||
work = await app.ActiveWorkForTab(request.tabId);
|
||||
}
|
||||
if (work.jobs.length > 0) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work, stopping: false } : current);
|
||||
showToast(t("status.jobStopFailed"), "error");
|
||||
return;
|
||||
}
|
||||
setPendingModeSwitch(null);
|
||||
await refreshBackgroundRuntimes();
|
||||
if (activeTabIdRef.current === request.tabId) {
|
||||
await commitTokenModeSwitch(request.tabId, request.target, request.previous, composerProfile);
|
||||
}
|
||||
} catch (err) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [commitTokenModeSwitch, composerProfile, pendingModeSwitch, refreshBackgroundRuntimes, showToast, t]);
|
||||
const cancelRuntimeJob = useCallback(async (tabId: string, jobId: string): Promise<boolean> => {
|
||||
try {
|
||||
const cancelled = await app.CancelJobForTab(tabId, jobId);
|
||||
@@ -2107,12 +2006,10 @@ export default function App() {
|
||||
// a stale local dismissal cannot hide work that still blocks final readiness;
|
||||
// every new list starts collapsed while its header keeps showing live progress
|
||||
// and the current task; completed lists can then be dismissed. The dismissal
|
||||
// key is still based on stable todo content/state so history reloads
|
||||
// do not resurrect the same finished list under a different event id. The
|
||||
// batch key ignores status changes so progress within the same task list does
|
||||
// not look like a brand-new task batch. Dismissal and open state are scoped to
|
||||
// the active session/topic/tab so different projects and sessions do not hide
|
||||
// or reopen each other's todo panels.
|
||||
// key is still based on stable todo content/state so history reloads do not
|
||||
// resurrect the same finished list under a different event id. The batch key
|
||||
// ignores status so progress in the same list is not a new batch. Dismissal
|
||||
// is scoped per session/topic/tab and also persisted on the session sidecar.
|
||||
const todoEntry = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const it = state.items[i];
|
||||
@@ -2141,7 +2038,7 @@ export default function App() {
|
||||
);
|
||||
const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
|
||||
const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
|
||||
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
|
||||
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos, { batchKey: todoBatch, batches: state.meta?.sessionPath === activeTab?.sessionPath ? state.meta?.dismissedTodoBatches : undefined });
|
||||
const dismissTodos = useCallback(() => {
|
||||
if (!scopedTodoKey) return;
|
||||
setDismissedTodoKeys((current) => {
|
||||
@@ -2151,7 +2048,8 @@ export default function App() {
|
||||
saveDismissedTodoKeys(next);
|
||||
return next;
|
||||
});
|
||||
}, [scopedTodoKey]);
|
||||
if (activeTabId && todoBatch) void app.DismissTodoBatchForTab(activeTabId, todoBatch).catch(() => undefined);
|
||||
}, [activeTabId, scopedTodoKey, todoBatch]);
|
||||
|
||||
const sessionTitle = topicTitle(activeTab);
|
||||
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
|
||||
@@ -2319,7 +2217,6 @@ export default function App() {
|
||||
if (activeTabIdRef.current !== sourceTabId) return;
|
||||
closeTransientOverlays();
|
||||
setWorkspaceConflict(null);
|
||||
setPendingModeSwitch(null);
|
||||
setPendingClose(null);
|
||||
setClearContextPending(false);
|
||||
const mockWork: ActiveWorkView = {
|
||||
@@ -2340,14 +2237,6 @@ export default function App() {
|
||||
canReveal: true,
|
||||
canCreateWorktree: true,
|
||||
});
|
||||
} else if (decisionMock === "mode_jobs") {
|
||||
setPendingModeSwitch({
|
||||
tabId: sourceTabId,
|
||||
target: tokenMode === "delivery" ? "full" : "delivery",
|
||||
previous: tokenMode,
|
||||
work: mockWork,
|
||||
stopping: false,
|
||||
});
|
||||
} else if (decisionMock === "close_active") {
|
||||
setPendingClose({ tabId: sourceTabId, work: mockWork, stopping: false });
|
||||
} else {
|
||||
@@ -2455,7 +2344,7 @@ export default function App() {
|
||||
await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim(), structured);
|
||||
},
|
||||
[activeTabId, applyGoal, closeTransientOverlays, collaborationMode, composerProfile, controllerReady, goal, notice, runShellForTab,
|
||||
patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, tokenMode, toolApprovalMode, showToast],
|
||||
patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, toolApprovalMode, showToast],
|
||||
);
|
||||
|
||||
const handleSteer = useCallback(async (text: string, requestedTabId = activeTabId) => {
|
||||
@@ -3333,12 +3222,12 @@ export default function App() {
|
||||
cancel();
|
||||
setWorkspaceConflict(null);
|
||||
try {
|
||||
await createDeliveryWorktree(root);
|
||||
await createIsolatedWorktree(root);
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [cancel, createDeliveryWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
|
||||
}, [cancel, createIsolatedWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
|
||||
|
||||
const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => {
|
||||
closeTransientOverlays();
|
||||
@@ -3383,21 +3272,14 @@ export default function App() {
|
||||
// On confirm, call Go prepare+commit immediately. Only after success does
|
||||
// the UI truncate the transcript, refresh files, and fill the composer.
|
||||
// Real backend undo uses UndoRewindForTab when a transaction id is available.
|
||||
type RewindState = {
|
||||
turnDiff: number; // turns rolled back
|
||||
transactionId?: string;
|
||||
undoAvailable?: boolean;
|
||||
filesRestored?: string[];
|
||||
filesRemoved?: string[];
|
||||
};
|
||||
const [rewindStatesByTab, setRewindStatesByTab] = useState<Record<string, RewindState>>({});
|
||||
const [rewindStatesByTab, setRewindStatesByTab] = useState<Record<string, RewindUndoState>>({});
|
||||
const rewindStatesByTabRef = useRef(rewindStatesByTab);
|
||||
rewindStatesByTabRef.current = rewindStatesByTab;
|
||||
const [rewindCommittingByTab, setRewindCommittingByTab] = useState<Record<string, boolean>>({});
|
||||
const rewindState = activeTabId ? rewindStatesByTab[activeTabId] ?? null : null;
|
||||
const rewindCommitting = Boolean(activeTabId && rewindCommittingByTab[activeTabId]);
|
||||
|
||||
const setRewindStateForTab = useCallback((tabId: string, nextState: RewindState | null) => {
|
||||
const setRewindStateForTab = useCallback((tabId: string, nextState: RewindUndoState | null) => {
|
||||
if (!tabId) return;
|
||||
const next = { ...rewindStatesByTabRef.current };
|
||||
if (nextState) next[tabId] = nextState;
|
||||
@@ -3594,17 +3476,19 @@ export default function App() {
|
||||
// Keep conversation/files as-is; notices already carry the reason.
|
||||
return;
|
||||
}
|
||||
setRewindStateForTab(sourceTabId, {
|
||||
turnDiff,
|
||||
const targetTabId = outcome.tabId || sourceTabId;
|
||||
setRewindStateForTab(targetTabId, {
|
||||
turnDiff: outcome.tabId ? 0 : turnDiff,
|
||||
transactionId: outcome.transactionId,
|
||||
undoAvailable: outcome.undoAvailable,
|
||||
undoTabId: sourceTabId,
|
||||
filesRestored: outcome.written ?? [],
|
||||
filesRemoved: outcome.deleted ?? [],
|
||||
});
|
||||
const insertId = Date.now();
|
||||
setComposerInsertRequestsByTab((current) => ({
|
||||
...current,
|
||||
[sourceTabId]: { id: insertId, text: prompt, mode: "replace" },
|
||||
[targetTabId]: { id: insertId, text: prompt, mode: "replace" },
|
||||
}));
|
||||
setRewindSignal((v) => v + 1);
|
||||
if (scope === "both" || scope === "code") {
|
||||
@@ -3632,16 +3516,17 @@ export default function App() {
|
||||
}
|
||||
userCount++;
|
||||
}
|
||||
const ok = await rewindForTab(sourceTabId, turn, "conversation");
|
||||
if (!ok) return false;
|
||||
const outcome = await rewindForTabDetailed(sourceTabId, turn, "conversation");
|
||||
if (!outcome.ok) return false;
|
||||
setRewindSignal((v) => v + 1);
|
||||
const targetTabId = outcome.tabId || sourceTabId;
|
||||
try {
|
||||
await sendToTab(sourceTabId, next, submit, original);
|
||||
await sendToTab(targetTabId, next, submit, original);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [activeTab?.readOnly, activeTabId, clearContextPending, controllerReady, hydratePlaceholderActive, sendToTab, state.approval, state.ask, state.items, state.messageAction, state.running, rewindForTab]);
|
||||
}, [activeTab?.readOnly, activeTabId, clearContextPending, controllerReady, hydratePlaceholderActive, sendToTab, state.approval, state.ask, state.items, state.messageAction, state.running, rewindForTabDetailed]);
|
||||
|
||||
const openTrash = useCallback(async () => {
|
||||
closeTransientOverlays();
|
||||
@@ -3710,8 +3595,8 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.kind === "delivery-worktree") {
|
||||
const result = await createDeliveryWorktree(request.workspaceRoot, request.navigationIntentSeq);
|
||||
if (request.kind === "isolated-worktree") {
|
||||
const result = await createIsolatedWorktree(request.workspaceRoot, request.navigationIntentSeq);
|
||||
if (!latest()) return;
|
||||
seedActiveTabMeta(result.tab);
|
||||
setProjectRevision((value) => value + 1);
|
||||
@@ -3788,7 +3673,7 @@ export default function App() {
|
||||
void refreshLatestTabMetas();
|
||||
return;
|
||||
}
|
||||
if (request.kind === "delivery-worktree") {
|
||||
if (request.kind === "isolated-worktree") {
|
||||
console.warn("isolated Delivery workspace creation failed", err);
|
||||
showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
|
||||
return;
|
||||
@@ -3810,7 +3695,7 @@ export default function App() {
|
||||
showToast(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
}, [activateTopic, createDeliveryWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
|
||||
}, [activateTopic, createIsolatedWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
|
||||
|
||||
const enqueueNavigationWithIntent = useCallback((input: DesktopNavigationIntent, navigationIntentSeq: number): Promise<void> => {
|
||||
return enqueueNavigationRequest(
|
||||
@@ -4293,6 +4178,7 @@ export default function App() {
|
||||
sidebarCollapsed ? "layout--sidebar-collapsed" : "",
|
||||
sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "",
|
||||
workspacePanelGridOpen ? "layout--workspace-open" : "",
|
||||
workspacePanelOverlay ? "layout--workspace-overlay" : "",
|
||||
"layout--terminal-drawer-open",
|
||||
terminalPanelOpen ? "layout--terminal-drawer-expanded" : "",
|
||||
terminalResizing ? "layout--terminal-resizing" : "",
|
||||
@@ -4424,30 +4310,30 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<section className="sidebar__section sidebar__section--projects">
|
||||
<ProjectTree
|
||||
activeScope={activeTab?.scope}
|
||||
activeWorkspaceRoot={activeTab?.workspaceRoot}
|
||||
activeTopicId={activeTab?.topicId}
|
||||
activeSessionPath={activeTab?.sessionPath}
|
||||
imTopicSources={imTopicSources}
|
||||
onOpenTopic={handleOpenTopic}
|
||||
onCreateTopic={(scope, workspaceRoot) => openBlankSession(scope, scope === "project" ? workspaceRoot : "")}
|
||||
onCreateDeliveryWorktree={(workspaceRoot) => enqueueNavigation({ kind: "delivery-worktree", workspaceRoot })}
|
||||
onTopicsChanged={refreshProjectsAndTabs}
|
||||
onRenameTopic={renameTopic}
|
||||
refreshSignal={projectRevision}
|
||||
onAddProject={async () => {
|
||||
await switchFolder();
|
||||
}}
|
||||
timeFilter={topicTimeFilter}
|
||||
onTimeFilterChange={setTopicTimeFilter}
|
||||
variant={sidebarWorkbench ? "workbench" : sidebarCreation ? "creation" : "classic"}
|
||||
searchExpanded={!sidebarCreation || sidebarSearchOpen}
|
||||
searchFocusSignal={sidebarSearchFocusSignal}
|
||||
showShortcutBadges={showTopicBadges}
|
||||
shortcutPlatform={desktopPlatform}
|
||||
onVisibleTopicsChange={handleVisibleTopicsChange}
|
||||
/>
|
||||
<Suspense fallback={null}><ProjectTree
|
||||
activeScope={activeTab?.scope}
|
||||
activeWorkspaceRoot={activeTab?.workspaceRoot}
|
||||
activeTopicId={activeTab?.topicId}
|
||||
activeSessionPath={activeTab?.sessionPath}
|
||||
imTopicSources={imTopicSources}
|
||||
onOpenTopic={handleOpenTopic}
|
||||
onCreateTopic={(scope, workspaceRoot) => openBlankSession(scope, scope === "project" ? workspaceRoot : "")}
|
||||
onCreateIsolatedWorktree={(workspaceRoot) => enqueueNavigation({ kind: "isolated-worktree", workspaceRoot })}
|
||||
onTopicsChanged={refreshProjectsAndTabs}
|
||||
onRenameTopic={renameTopic}
|
||||
refreshSignal={projectRevision}
|
||||
onAddProject={async (path) => {
|
||||
await switchFolder(path);
|
||||
}}
|
||||
timeFilter={topicTimeFilter}
|
||||
onTimeFilterChange={setTopicTimeFilter}
|
||||
variant={sidebarWorkbench ? "workbench" : sidebarCreation ? "creation" : "classic"}
|
||||
searchExpanded={!sidebarCreation || sidebarSearchOpen}
|
||||
searchFocusSignal={sidebarSearchFocusSignal}
|
||||
showShortcutBadges={showTopicBadges}
|
||||
shortcutPlatform={desktopPlatform}
|
||||
onVisibleTopicsChange={handleVisibleTopicsChange}
|
||||
/></Suspense>
|
||||
</section>
|
||||
|
||||
{sidebarWorkbench ? (
|
||||
@@ -4876,6 +4762,7 @@ export default function App() {
|
||||
actionHoverMenus={sidebarCreation && !hydratePlaceholderActive}
|
||||
rewindSignal={rewindSignal}
|
||||
revealSignal={transcriptRevealSignal}
|
||||
historyLayoutRevision={state.historyLayoutRevision}
|
||||
hydrating={transcriptHydrating}
|
||||
hasOlderHistory={state.historyHasOlder && !rewindState}
|
||||
olderHistoryCount={state.historyStartTurn}
|
||||
@@ -4908,7 +4795,8 @@ export default function App() {
|
||||
const tabId = activeTabId;
|
||||
if (!tabId) return;
|
||||
const tx = rewindState.transactionId;
|
||||
const undo = tx && rewindState.undoAvailable ? undoRewindForTab(tabId, tx) : Promise.resolve(true);
|
||||
const undoTabId = rewindState.undoTabId || tabId;
|
||||
const undo = tx && rewindState.undoAvailable ? undoRewindForTab(undoTabId, tx) : Promise.resolve(true);
|
||||
void undo.then((ok) => {
|
||||
if (!ok) return;
|
||||
setRewindStateForTab(tabId, null);
|
||||
@@ -5019,30 +4907,6 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "mode_jobs" && pendingModeSwitch ? (
|
||||
<RuntimeDecisionCard
|
||||
id="mode-jobs"
|
||||
title={t("runtime.modeJobsTitle", { mode: t(runtimeProfileShortKey(pendingModeSwitch.target)) })}
|
||||
badge={t("status.jobs", { n: pendingModeSwitch.work.jobs.length })}
|
||||
meta={t("runtime.modeJobsMeta")}
|
||||
note={pendingModeSwitch.work.jobs.map((job) => job.label || job.kind).join(" · ")}
|
||||
onCancel={() => setPendingModeSwitch(null)}
|
||||
actions={[
|
||||
{
|
||||
key: "1", label: pendingModeSwitch.stopping
|
||||
? t("status.jobStopping")
|
||||
: t("runtime.stopAndSwitch", { n: pendingModeSwitch.work.jobs.length }),
|
||||
description: t("runtime.stopAndSwitchDesc", { mode: t(runtimeProfileShortKey(pendingModeSwitch.target)) }),
|
||||
onClick: () => void stopJobsAndSwitchMode(),
|
||||
danger: true, disabled: pendingModeSwitch.stopping,
|
||||
},
|
||||
]}
|
||||
secondaryAction={{
|
||||
key: "Esc", label: t("runtime.keepMode"), description: t("runtime.keepModeDesc"),
|
||||
onClick: () => setPendingModeSwitch(null), disabled: pendingModeSwitch.stopping,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "close_active" && pendingClose ? (
|
||||
<RuntimeDecisionCard
|
||||
id="close-active"
|
||||
@@ -5094,7 +4958,6 @@ export default function App() {
|
||||
running={state.running || rewindCommitting}
|
||||
collaborationMode={collaborationMode}
|
||||
toolApprovalMode={toolApprovalMode}
|
||||
tokenMode={tokenMode}
|
||||
turnPhase={state.turnPhase}
|
||||
goal={goal}
|
||||
goalStatus={state.meta?.goalStatus}
|
||||
@@ -5118,7 +4981,6 @@ export default function App() {
|
||||
onResumeGoal={resumeGoalFromUi}
|
||||
onSwitchModel={switchModelFromUi}
|
||||
onSetEffort={setEffort}
|
||||
onSetTokenMode={applyTokenMode}
|
||||
insertRequest={composerInsertRequest}
|
||||
selectedTextRequest={selectedTextRequest}
|
||||
readOnly={Boolean(activeTab?.readOnly)}
|
||||
@@ -5181,6 +5043,7 @@ export default function App() {
|
||||
className={[
|
||||
"workbench-dock",
|
||||
`workbench-dock--${rightDockMode}`,
|
||||
workspacePanelOverlay ? "workbench-dock--overlay" : "",
|
||||
].join(" ")}
|
||||
aria-label={t("rightDock.workbench")}
|
||||
>
|
||||
@@ -5263,7 +5126,9 @@ export default function App() {
|
||||
cwd={state.meta?.cwd}
|
||||
workspaceScopeKey={workspaceScopeKey}
|
||||
workspaceMemoryKey={workspaceTreeMemoryKey}
|
||||
workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
|
||||
dockTreeWidth={rightDockTreeWidth}
|
||||
dockPreviewWidth={rightDockPreviewWidth}
|
||||
onRestoreDockWidths={restoreWorkspaceDockWidths}
|
||||
maximized={workspacePanelMaximized}
|
||||
panelWidth={workspacePanelRenderWidth}
|
||||
onClose={() => setWorkspacePanel(false)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { AppBindings } from "../lib/bridge";
|
||||
import { enqueueNavigationRequest, type NavigationCoalescingRefs } from "../lib/openTopicCoalescing";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest } from "../lib/types";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -140,6 +140,8 @@ let backendActiveId = "tab-a";
|
||||
const activationHolds = new Map<string, Promise<void>>();
|
||||
const tabsById = new Map([tabA, tabX, tabY].map((tab) => [tab.id, tab]));
|
||||
const topicActivationHandlers: Array<(e: TopicActivationEvent) => void> = [];
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const replayTargets: string[] = [];
|
||||
// The pending ticketed activation backend-side: a newer StartTopicActivation
|
||||
// supersedes it (cancelled), exactly like the real generation protocol.
|
||||
let mockPendingActivation: { requestId: string; tabId: string } | undefined;
|
||||
@@ -154,6 +156,7 @@ function currentTabs(): TabMeta[] {
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
|
||||
if (name === "topic:activation") topicActivationHandlers.push(cb as (e: TopicActivationEvent) => void);
|
||||
return () => {};
|
||||
},
|
||||
@@ -208,9 +211,22 @@ window.go = {
|
||||
return { requestId, tabId: target.id, meta: { ...target, active: true } };
|
||||
},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
const hold = activationHolds.get(tabID);
|
||||
if (hold) await hold;
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
ReplayPendingPromptsForTab: async (tabID: string) => {
|
||||
replayTargets.push(tabID);
|
||||
if (!tabsById.get(tabID)?.pendingPrompt) return;
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: tabID,
|
||||
ask: { id: `pending-${tabID}`, questions: [{ id: "choice", prompt: "Keep me through A-X-A", options: [] }] },
|
||||
});
|
||||
}
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
@@ -328,6 +344,50 @@ await act(async () => {
|
||||
});
|
||||
await waitFor("queued last click applies once it runs", () => controller?.activeTabId === "tab-a");
|
||||
|
||||
// A prompt can arrive on A before a slow A→X→A switch completes. The final A
|
||||
// activation must preserve its card, and X's stale completion must reassert A
|
||||
// without clearing or replaying a sibling tab's prompt.
|
||||
const promptBlockedA = { ...tabA, running: true, pendingPrompt: true, cancellable: true };
|
||||
tabsById.set(tabA.id, promptBlockedA);
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: tabA.id,
|
||||
ask: { id: "pending-tab-a", questions: [{ id: "choice", prompt: "Keep me through A-X-A", options: [] }] },
|
||||
});
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "pending-tab-a", "A starts the rapid switch with a visible ask");
|
||||
|
||||
const slowSwitchGate = deferred<void>();
|
||||
activationHolds.set(tabX.id, slowSwitchGate.promise);
|
||||
let slowSwitch: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
slowSwitch = controller?.switchTab(tabX.id, tabX);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, tabX.id, "A→X renders the slow target optimistically");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.switchTab(tabA.id, promptBlockedA);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, tabA.id, "A→X→A returns to the prompt owner");
|
||||
eq(controller?.state.ask?.id, "pending-tab-a", "returning to A preserves its ask");
|
||||
ok(replayTargets.includes(tabA.id), "post-activation replay is scoped to A");
|
||||
|
||||
await act(async () => {
|
||||
slowSwitchGate.resolve();
|
||||
activationHolds.delete(tabX.id);
|
||||
await slowSwitch;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, tabA.id, "late X activation cannot replace A");
|
||||
eq(backendActiveId, tabA.id, "late X activation reasserts A as backend owner");
|
||||
eq(controller?.state.ask?.id, "pending-tab-a", "late X completion cannot clear A's ask");
|
||||
|
||||
// Wiring lock: App.enqueueNavigation must invalidate in-flight activations at
|
||||
// enqueue time — the queue-based scenario above only proves the mechanism.
|
||||
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -425,7 +425,7 @@ ok(
|
||||
);
|
||||
|
||||
ok(
|
||||
/const \[rewindStatesByTab, setRewindStatesByTab\] = useState<Record<string, RewindState>>\(\{\}\);/.test(appSource) &&
|
||||
/const \[rewindStatesByTab, setRewindStatesByTab\] = useState<Record<string, RewindUndoState>>\(\{\}\);/.test(appSource) &&
|
||||
/setRewindStateForTab\(sourceTabId, null\);/.test(appSource) &&
|
||||
/setRewindCommittingForTab\(sourceTabId, true\);/.test(appSource),
|
||||
"committing optimistic rewind clears only the source tab before awaiting the backend",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Run: tsx src/__tests__/blank-project-dialog.test.tsx
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
passed += 1;
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
} else {
|
||||
failed += 1;
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nblank project creation");
|
||||
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.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
|
||||
const [{ createRoot }, { BlankProjectDialog, blankProjectNameProblem }, { BlankProjectFlow }, { LocaleProvider }] = await Promise.all([
|
||||
import("react-dom/client"),
|
||||
import("../components/BlankProjectDialog"),
|
||||
import("../components/BlankProjectFlow"),
|
||||
import("../lib/i18n"),
|
||||
]);
|
||||
|
||||
ok(blankProjectNameProblem(" ") === "required", "empty names are rejected before IPC");
|
||||
ok(blankProjectNameProblem("nested/project") === "invalid", "nested paths are rejected before IPC");
|
||||
ok(blankProjectNameProblem("project") === null, "a single folder name is accepted");
|
||||
|
||||
const submitted: string[] = [];
|
||||
let cancelled = 0;
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<BlankProjectDialog
|
||||
parentDirectory="/Users/test/Projects"
|
||||
busy={false}
|
||||
onSubmit={(name) => submitted.push(name)}
|
||||
onCancel={() => { cancelled += 1; }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
const input = document.querySelector<HTMLInputElement>(".blank-project-dialog__input");
|
||||
const submit = document.querySelector<HTMLButtonElement>('button[type="submit"]');
|
||||
ok(Boolean(dialog) && dialog?.getAttribute("aria-modal") === "true", "creation uses an accessible modal dialog");
|
||||
ok(document.body.textContent?.includes("/Users/test/Projects") === true, "the chosen parent folder is shown before creation");
|
||||
ok(document.activeElement === input, "project name receives initial focus");
|
||||
ok(submit?.disabled === true, "create stays disabled until the name is valid");
|
||||
|
||||
await act(async () => {
|
||||
if (!input) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, " demo-project ");
|
||||
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
});
|
||||
ok(submit?.disabled === false, "a valid project name enables creation");
|
||||
await act(async () => {
|
||||
dialog?.querySelector("form")?.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
ok(submitted[0] === "demo-project", "submission trims and forwards the project name");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
ok(cancelled === 1, "Escape cancels the dialog");
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const flowSource = readFileSync(resolve(here, "../components/BlankProjectFlow.tsx"), "utf8");
|
||||
const creationSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
|
||||
ok(/key: "blank-project"[\s\S]*openBlankProjectFlow\(\)/.test(treeSource), "workbench menu starts the blank-project flow");
|
||||
ok(/CreateBlankProject\(draft\.parentDirectory, projectName\)/.test(flowSource), "the dialog delegates atomic directory creation to Go");
|
||||
ok(/await onOpenProject\(createdPath\)/.test(flowSource), "the new directory reuses workspace navigation and registration");
|
||||
ok(/lazy\(\(\) => import\("\.\/BlankProjectFlow"\)/.test(creationSource), "the creation flow stays outside the startup bundle");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
const bridgeCalls: string[] = [];
|
||||
window.go = { main: { App: {
|
||||
async PickBlankProjectParent() {
|
||||
bridgeCalls.push("pick");
|
||||
return "/Users/test/Projects";
|
||||
},
|
||||
async CreateBlankProject(parentDirectory: string, projectName: string) {
|
||||
bridgeCalls.push(`create:${parentDirectory}:${projectName}`);
|
||||
return `${parentDirectory}/${projectName}`;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings } };
|
||||
const flowRoot = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
flowRoot.render(
|
||||
<LocaleProvider>
|
||||
<BlankProjectFlow
|
||||
onOpenProject={async (path) => { bridgeCalls.push(`open:${path}`); }}
|
||||
onRefresh={async () => { bridgeCalls.push("refresh"); }}
|
||||
onClose={() => { bridgeCalls.push("close"); }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const flowInput = document.querySelector<HTMLInputElement>(".blank-project-dialog__input");
|
||||
await act(async () => {
|
||||
if (!flowInput) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(flowInput, "flow-project");
|
||||
flowInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
flowInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
flowInput?.closest("form")?.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(
|
||||
bridgeCalls.join("|") === "pick|create:/Users/test/Projects:flow-project|open:/Users/test/Projects/flow-project|refresh|close",
|
||||
"the live flow picks, creates, opens, refreshes, and closes in order",
|
||||
);
|
||||
|
||||
await act(async () => flowRoot.unmount());
|
||||
dom.window.close();
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -48,6 +48,14 @@ ok(
|
||||
appSource.includes('import("./components/HistoryPanel")'),
|
||||
"App loads secondary drawers on demand",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{\s*ProjectTree\s*\}\s+from\s+["']\.\/components\/ProjectTree["']/.test(appSource),
|
||||
"App keeps the project tree out of the first-paint bundle",
|
||||
);
|
||||
ok(
|
||||
appSource.includes('import("./components/ProjectTree")'),
|
||||
"App loads the project tree when the sidebar mounts",
|
||||
);
|
||||
ok(
|
||||
settingsEntrySource.includes('import "./CompactRatioSettings.css"') &&
|
||||
settingsEntrySource.includes('from "./SettingsPanel"') &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { activeWorkBusyNoticeText } from "../lib/capabilityMutations";
|
||||
import { t } from "../lib/i18n";
|
||||
import { effortSwitchNoticeText, modelSwitchNoticeText, tokenModeSwitchNoticeText } from "../lib/useController";
|
||||
import { effortSwitchNoticeText, modelSwitchNoticeText } from "../lib/useController";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
@@ -34,10 +34,6 @@ ok(
|
||||
effortSwitchNoticeText(activeWorkError(false, false, 1).message.replace("MCP server", "effort")) === "Reasoning effort cannot change while background work is active. Active jobs: 1. Open Background jobs in the status bar to stop them.",
|
||||
"effort switching uses grammatically neutral single-job guidance",
|
||||
);
|
||||
ok(
|
||||
tokenModeSwitchNoticeText(activeWorkError(false, false, 1).message.replace("MCP server", "token mode")) === "Execution setting cannot change while background work is active. Active jobs: 1. Open Background jobs in the status bar to stop them.",
|
||||
"execution-setting switching uses grammatically neutral single-job guidance",
|
||||
);
|
||||
ok(
|
||||
activeWorkBusyNoticeText(
|
||||
new Error("active work is still running; finish or cancel the current turn before changing MCP server"),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -108,7 +108,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -125,7 +125,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
|
||||
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -108,7 +108,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
tabId: "tab-a",
|
||||
@@ -132,7 +132,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
@@ -1180,7 +1180,7 @@ console.log("\ncomposer goal toggle");
|
||||
});
|
||||
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
|
||||
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
|
||||
const dismissButton = Array.from(dismissibleGuidanceItem.querySelectorAll<HTMLButtonElement>(".composer-guidance-item__action")).at(-1) ?? null;
|
||||
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
|
||||
await act(async () => {
|
||||
dismissButton.click();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Composer } from "../components/Composer";
|
||||
import { UserMessage } from "../components/Message";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -111,7 +111,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -128,7 +128,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -109,7 +109,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -125,7 +125,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
@@ -487,6 +487,161 @@ console.log("\ncomposer inbox recovery");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => ({
|
||||
revision: 7,
|
||||
paused: true,
|
||||
recovered: false,
|
||||
recoveredCount: 0,
|
||||
items: [{
|
||||
id: "processing-guidance",
|
||||
intent: "steer",
|
||||
state: "steer_accepted",
|
||||
preview: "Already being applied",
|
||||
byteSize: 64,
|
||||
position: 1,
|
||||
}],
|
||||
itemsCount: 1,
|
||||
bytes: 64,
|
||||
maxItems: 64,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
});
|
||||
const { root } = await renderComposer({ running: true });
|
||||
await waitFor("in-flight paused item rendered", () => document.querySelector(".composer-guidance-item") !== null);
|
||||
ok(document.querySelector(".composer-inbox-recovery") === null, "pause banner hides while guidance is already being applied");
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let updated = "";
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => ({
|
||||
revision: 8,
|
||||
paused: false,
|
||||
recovered: false,
|
||||
recoveredCount: 0,
|
||||
items: [{
|
||||
id: "editable-guidance",
|
||||
intent: "followup",
|
||||
state: "queued",
|
||||
preview: "Original queued text",
|
||||
byteSize: 64,
|
||||
position: 1,
|
||||
}],
|
||||
itemsCount: 1,
|
||||
bytes: 64,
|
||||
maxItems: 64,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
UpdateInboxItem: async (_tabId: string, _id: string, display: string) => {
|
||||
updated = display;
|
||||
},
|
||||
});
|
||||
const { root } = await renderComposer({ running: false });
|
||||
await waitFor("editable guidance rendered", () => document.querySelector("[aria-label=\"Edit this queued guidance\"]") !== null);
|
||||
const edit = document.querySelector("[aria-label=\"Edit this queued guidance\"]") as HTMLButtonElement;
|
||||
await act(async () => { edit.click(); await flushTimers(); });
|
||||
const editor = document.querySelector(".composer-guidance-item__editor") as HTMLInputElement;
|
||||
ok(editor?.value === "Original queued text", "edit mode loads the queued text");
|
||||
const save = document.querySelector("[aria-label=\"Save guidance edits\"]") as HTMLButtonElement;
|
||||
await act(async () => { save.click(); await flushTimers(); });
|
||||
ok(updated === "Original queued text", "edit saves through UpdateInboxItem");
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => ({
|
||||
revision: 9,
|
||||
paused: true,
|
||||
recovered: false,
|
||||
recoveredCount: 0,
|
||||
items: [{
|
||||
id: "empty-preview",
|
||||
intent: "followup",
|
||||
state: "queued",
|
||||
preview: "",
|
||||
byteSize: 64,
|
||||
position: 1,
|
||||
}],
|
||||
itemsCount: 1,
|
||||
bytes: 64,
|
||||
maxItems: 64,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
ReadInboxItem: async () => ({ id: "empty-preview", displayText: "Loaded body", rawText: "Loaded body", submitText: "Loaded body" }),
|
||||
});
|
||||
const { root } = await renderComposer({ running: false });
|
||||
await waitFor("empty preview hydrated", () => document.querySelector(".composer-guidance-item__text")?.textContent === "Loaded body");
|
||||
ok(document.querySelector(".composer-guidance-item__text")?.textContent === "Loaded body", "review path hydrates an empty snapshot preview");
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const snapshots: Record<string, ReturnType<typeof recoveredSnapshot>> = {
|
||||
"/sessions/a.jsonl": recoveredSnapshot(12),
|
||||
"/sessions/b.jsonl": { ...recoveredSnapshot(0), paused: false, recovered: false, recoveredCount: 0, items: [], itemsCount: 0, bytes: 0 },
|
||||
};
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => {
|
||||
const path = currentPath;
|
||||
return { ...snapshots[path], sessionPath: path };
|
||||
},
|
||||
});
|
||||
let currentPath = "/sessions/a.jsonl";
|
||||
const { root, rerender } = await renderComposer({
|
||||
tabId: "tab-shared",
|
||||
sessionKey: "session-topic\0project\0/repo\0topic-a",
|
||||
inboxSessionPath: currentPath,
|
||||
});
|
||||
await waitFor("session A recovered queue rendered", () => document.querySelectorAll(".composer-guidance-item").length === 2);
|
||||
ok(document.querySelector(".composer-inbox-recovery")?.textContent?.includes("Recovered 12 pending instructions") === true, "session A shows its own recovered inbox");
|
||||
|
||||
currentPath = "/sessions/b.jsonl";
|
||||
await rerender({ inboxSessionPath: currentPath });
|
||||
await waitFor("session B does not inherit session A inbox", () => document.querySelector(".composer-guidance-item") === null);
|
||||
ok(document.querySelector(".composer-inbox-recovery") === null, "sibling session does not inherit the previous recovered banner");
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "sibling session does not inherit the previous queued guidance");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let resolveStale!: (value: ReturnType<typeof recoveredSnapshot> & { sessionPath: string }) => void;
|
||||
const stalePromise = new Promise<ReturnType<typeof recoveredSnapshot> & { sessionPath: string }>((resolve) => { resolveStale = resolve; });
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async (_tabId: string) => currentPath === "/sessions/a.jsonl"
|
||||
? stalePromise
|
||||
: { ...recoveredSnapshot(0), paused: false, recovered: false, recoveredCount: 0, items: [], itemsCount: 0, bytes: 0, sessionPath: currentPath },
|
||||
});
|
||||
let currentPath = "/sessions/a.jsonl";
|
||||
const { root, rerender } = await renderComposer({
|
||||
tabId: "tab-shared",
|
||||
sessionKey: "session-topic\0project\0/repo\0topic-a",
|
||||
inboxSessionPath: currentPath,
|
||||
});
|
||||
currentPath = "/sessions/b.jsonl";
|
||||
await rerender({ inboxSessionPath: currentPath });
|
||||
resolveStale({ ...recoveredSnapshot(12), sessionPath: "/sessions/a.jsonl" });
|
||||
await act(async () => { await flushTimers(); });
|
||||
ok(document.querySelector(".composer-inbox-recovery") === null, "stale recovered snapshot cannot land on another session");
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "stale queued items cannot land on another session");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
process.stderr.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -47,7 +47,6 @@ function tab(overrides: Partial<LooseTabMeta> = {}): TabMeta {
|
||||
mode: "normal",
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
active: true,
|
||||
@@ -66,7 +65,6 @@ function meta(overrides: Partial<LooseMeta> = {}): Meta {
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
...overrides,
|
||||
@@ -77,15 +75,15 @@ console.log("\ncomposer profile");
|
||||
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "delivery" })]);
|
||||
eq(profiles["tab-1"].tokenMode, "delivery", "delivery runtime profile hydrates from persisted tabs");
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "delivery" }));
|
||||
eq(Boolean(profiles["tab-1"].pending.tokenMode), false, "delivery runtime profile is acknowledged by meta");
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ toolApprovalMode: "auto" })]);
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "auto tool approval hydrates from persisted tabs");
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "auto" }));
|
||||
eq(Boolean(profiles["tab-1"].pending.toolApprovalMode), false, "auto tool approval is acknowledged by meta");
|
||||
}
|
||||
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "economy" })]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
profiles = patchComposerProfile(
|
||||
profiles,
|
||||
"tab-1",
|
||||
@@ -101,14 +99,13 @@ console.log("\ncomposer profile");
|
||||
["collaborationMode", "goal"],
|
||||
);
|
||||
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "economy" })]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
|
||||
eq(displayedComposerProfileCollaborationMode(profiles["tab-1"]), "plan", "stale tab hydration keeps locally selected plan mode");
|
||||
eq(profiles["tab-1"].tokenMode, "economy", "token saver remains independent of collaboration mode changes");
|
||||
eq(composerProfileMode(profiles["tab-1"]), "plan", "compat mode keeps the plan axis enabled");
|
||||
eq(Boolean(profiles["tab-1"].pending.collaborationMode), true, "pending plan stays pending until backend acknowledges it");
|
||||
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ mode: "plan", collaborationMode: "plan", tokenMode: "economy" })]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ mode: "plan", collaborationMode: "plan" })]);
|
||||
|
||||
eq(displayedComposerProfileCollaborationMode(profiles["tab-1"]), "plan", "acknowledged tab hydration keeps plan visible");
|
||||
eq(Boolean(profiles["tab-1"].pending.collaborationMode), false, "backend acknowledgement clears pending plan");
|
||||
@@ -117,16 +114,22 @@ console.log("\ncomposer profile");
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
profiles = patchComposerProfile(profiles, "tab-1", profiles["tab-1"], { tokenMode: "economy" }, ["tokenMode"]);
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "full" }));
|
||||
profiles = patchComposerProfile(
|
||||
profiles,
|
||||
"tab-1",
|
||||
profiles["tab-1"],
|
||||
{ collaborationMode: "normal", goalDraftMode: false, goal: "", toolApprovalMode: "auto" },
|
||||
["toolApprovalMode"],
|
||||
);
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "ask" }));
|
||||
|
||||
eq(profiles["tab-1"].tokenMode, "economy", "stale meta cannot erase a pending token saver selection");
|
||||
eq(Boolean(profiles["tab-1"].pending.tokenMode), true, "token saver stays pending while meta is stale");
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "stale meta cannot erase a pending auto approval selection");
|
||||
eq(Boolean(profiles["tab-1"].pending.toolApprovalMode), true, "auto approval stays pending while meta is stale");
|
||||
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "economy" }));
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "auto" }));
|
||||
|
||||
eq(profiles["tab-1"].tokenMode, "economy", "acknowledged token saver remains enabled");
|
||||
eq(Boolean(profiles["tab-1"].pending.tokenMode), false, "token saver pending clears after matching meta");
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "acknowledged auto approval remains enabled");
|
||||
eq(Boolean(profiles["tab-1"].pending.toolApprovalMode), false, "auto approval pending clears after matching meta");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -149,7 +152,7 @@ console.log("\ncomposer profile");
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab(), tab({ id: "tab-2" })]);
|
||||
profiles = patchComposerProfile(profiles, "tab-2", profiles["tab-2"], { tokenMode: "economy" }, ["tokenMode"]);
|
||||
profiles = patchComposerProfile(profiles, "tab-2", profiles["tab-2"], { toolApprovalMode: "auto" }, ["toolApprovalMode"]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
|
||||
eq(Boolean(profiles["tab-2"]), false, "tab hydration removes profiles for closed tabs");
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -91,12 +91,11 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const calls = { cancel: 0, tokenModes: [] as TokenMode[], approvalModes: [] as ToolApprovalMode[] };
|
||||
const calls = { cancel: 0, approvalModes: [] as ToolApprovalMode[] };
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -115,9 +114,6 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: (mode) => {
|
||||
calls.tokenModes.push(mode);
|
||||
},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
@@ -200,45 +196,18 @@ console.log("\ncomposer run strip");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Work mode is a first-class, always-visible selector. Its three profiles live
|
||||
// in their own menu instead of the task-intent menu, and selecting a profile
|
||||
// preserves the existing token-mode callback contract.
|
||||
// Execution modes are gone. Composer keeps collaboration (direct/plan/goal)
|
||||
// and tool-approval controls; it must not render a Light/Balanced/Delivery
|
||||
// execution-setting trigger or menu.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer();
|
||||
const { root } = await renderComposer();
|
||||
|
||||
const profileTrigger = document.querySelector(".composer-profile-trigger") as HTMLButtonElement | null;
|
||||
if (!profileTrigger) throw new Error("work mode trigger did not render");
|
||||
eq(profileTrigger.textContent?.trim(), "Balanced", "standalone control shows only the current profile");
|
||||
eq(profileTrigger.getAttribute("aria-label"), "Execution setting · Balanced", "execution setting trigger keeps its full accessible name");
|
||||
ok(profileTrigger.querySelector(".lucide-equal") !== null, "balanced work mode uses a simple equal icon");
|
||||
await act(async () => {
|
||||
profileTrigger.focus();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(document.querySelector('[role="tooltip"]')?.textContent, "Execution setting · Balanced: Auto planning, risk-tiered verification", "execution setting tooltip combines category, value, and summary");
|
||||
await act(async () => {
|
||||
profileTrigger.blur();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
profileTrigger.click();
|
||||
await flushTimers();
|
||||
});
|
||||
const profileMenu = document.querySelector(".composer-profile-menu");
|
||||
ok(profileMenu !== null, "standalone work mode trigger opens its own menu");
|
||||
eq(profileMenu?.querySelectorAll('[role="menuitemradio"]').length, 3, "work mode menu exposes exactly three profiles");
|
||||
|
||||
const delivery = Array.from(profileMenu?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
|
||||
.find((item) => item.textContent?.includes("Delivery"));
|
||||
if (!delivery) throw new Error("delivery work mode option did not render");
|
||||
ok(delivery.querySelector(".lucide-flag") !== null, "delivery work mode uses a simple completion flag");
|
||||
await act(async () => {
|
||||
delivery.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.tokenModes.at(-1), "delivery", "selecting delivery keeps the token-mode callback contract");
|
||||
eq(document.querySelector(".composer-profile-trigger"), null, "composer has no execution-setting trigger");
|
||||
eq(document.querySelector(".composer-profile-menu"), null, "composer has no execution-setting menu");
|
||||
const chrome = document.body.textContent ?? "";
|
||||
eq(chrome.includes("Execution setting"), false, "composer chrome does not mention execution setting");
|
||||
eq(chrome.includes("Delivery"), false, "composer chrome does not mention Delivery mode");
|
||||
|
||||
const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
|
||||
if (!intentTrigger) throw new Error("task intent trigger did not render");
|
||||
@@ -246,7 +215,7 @@ console.log("\ncomposer run strip");
|
||||
intentTrigger.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(document.querySelector(".composer-intent-menu")?.textContent?.includes("Work mode"), false, "task-intent menu no longer owns work mode");
|
||||
eq(document.querySelector(".composer-intent-menu")?.textContent?.includes("Work mode"), false, "task-intent menu does not own a work-mode section");
|
||||
eq(document.querySelectorAll('.composer-intent-menu [role="menuitemradio"]').length, 3, "task method menu exposes direct, plan, and goal");
|
||||
|
||||
await act(async () => {
|
||||
@@ -263,7 +232,7 @@ console.log("\ncomposer run strip");
|
||||
const timers = installWindowTimerQueue();
|
||||
const { root } = await renderComposer({ showContextWindowRing: true });
|
||||
|
||||
for (const selector of [".composer-task-mode-trigger", ".composer-profile-trigger"]) {
|
||||
for (const selector of [".composer-task-mode-trigger"]) {
|
||||
const trigger = document.querySelector(selector) as HTMLButtonElement | null;
|
||||
if (!trigger) throw new Error(`missing Creation hover trigger: ${selector}`);
|
||||
|
||||
@@ -276,7 +245,7 @@ console.log("\ncomposer run strip");
|
||||
|
||||
ok(!trigger.classList.contains(`${selector.slice(1)}--open`), `${selector} stays visually closed after a short hover`);
|
||||
ok(
|
||||
document.querySelector(selector.includes("task") ? ".composer-intent-menu" : ".composer-profile-menu") === null,
|
||||
document.querySelector(".composer-intent-menu") === null,
|
||||
`${selector} does not render a closing-only menu`,
|
||||
);
|
||||
}
|
||||
@@ -297,16 +266,15 @@ console.log("\ncomposer run strip");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Runtime controller transitions disable every mode axis and submit together,
|
||||
// so rapid Goal + Delivery + approval-mode clicks cannot mutate a half-rebuilt runtime.
|
||||
// Runtime controller transitions disable collaboration, approval, and submit
|
||||
// together, so rapid Goal + approval-mode clicks cannot mutate a half-rebuilt runtime.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer({ disabled: true, goal: "ship it", collaborationMode: "goal" });
|
||||
const profile = document.querySelector<HTMLButtonElement>(".composer-profile-trigger");
|
||||
const task = document.querySelector<HTMLButtonElement>(".composer-task-mode-trigger");
|
||||
const approvals = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-modebar--approval button"));
|
||||
const send = document.querySelector<HTMLButtonElement>(".composer__btn--send");
|
||||
ok(Boolean(profile?.disabled), "runtime transition disables Delivery profile changes");
|
||||
eq(document.querySelector(".composer-profile-trigger"), null, "runtime transition has no execution-setting control");
|
||||
ok(Boolean(task?.disabled), "runtime transition disables Goal mode changes");
|
||||
ok(approvals.length === 3 && approvals.every((button) => button.disabled), "runtime transition disables Ask/Auto/Yolo changes");
|
||||
ok(Boolean(send?.disabled), "runtime transition disables submit");
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
selectedTextSnippet,
|
||||
} from "../lib/selectedTextContext";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -125,7 +125,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -141,7 +141,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Run: tsx src/__tests__/context-budget-card.test.ts
|
||||
|
||||
import {
|
||||
contextBudgetRecoveryKey,
|
||||
contextBudgetSourceKey,
|
||||
resolveContextBudget,
|
||||
sharedContextPhysicalRemaining,
|
||||
showsSharedContextOverflowRisk,
|
||||
} from "../components/ContextBudgetCard";
|
||||
import type { ContextInfo, ContextPanelInfo } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ncontext budget card");
|
||||
|
||||
eq(contextBudgetSourceKey("official"), "context.budgetSourceOfficial", "official source key");
|
||||
eq(contextBudgetSourceKey("learned"), "context.budgetSourceLearned", "learned source key");
|
||||
eq(contextBudgetSourceKey("nope"), "context.budgetSourceUnknown", "unknown source key");
|
||||
eq(contextBudgetRecoveryKey("proactive_clip"), "context.budgetRecoveryClip", "clip recovery");
|
||||
eq(contextBudgetRecoveryKey("none"), "", "none recovery is empty");
|
||||
|
||||
const fromPanel = resolveContextBudget(undefined, {
|
||||
contextBudget: { source: "official", windowTokens: 1000 },
|
||||
} as ContextPanelInfo);
|
||||
eq(fromPanel?.source, "official", "panel budget is preferred");
|
||||
|
||||
const fromContext = resolveContextBudget({
|
||||
used: 1,
|
||||
window: 2,
|
||||
sessionTokens: 3,
|
||||
contextBudget: { source: "learned" },
|
||||
} as ContextInfo, null);
|
||||
eq(fromContext?.source, "learned", "context info budget is used when panel omits it");
|
||||
|
||||
eq(sharedContextPhysicalRemaining({ windowMode: "shared", physicalRemaining: -12 }), 0, "shared remaining is clamped");
|
||||
eq(sharedContextPhysicalRemaining({ windowMode: "unknown", physicalRemaining: 99 }), undefined, "unknown provider has no asserted physical remainder");
|
||||
eq(showsSharedContextOverflowRisk({ windowMode: "shared", physicalRemaining: 10, clipped: true }), true, "shared clipping explains overflow risk");
|
||||
eq(showsSharedContextOverflowRisk({ windowMode: "shared", physicalRemaining: 0 }), true, "exhausted shared window explains overflow risk");
|
||||
eq(showsSharedContextOverflowRisk({ windowMode: "unknown", physicalRemaining: -10 }), false, "unknown provider does not claim shared-window overflow");
|
||||
eq(showsSharedContextOverflowRisk({ windowMode: "independent", physicalRemaining: -10, clipped: true }), false, "independent provider does not claim shared-window overflow");
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ${passed} passed`);
|
||||
@@ -8,6 +8,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
|
||||
const store = readFileSync(join(root, "lib/transcriptStore.ts"), "utf8");
|
||||
const app = readFileSync(join(root, "App.tsx"), "utf8");
|
||||
const transcript = readFileSync(join(root, "components/Transcript.tsx"), "utf8");
|
||||
|
||||
assert.match(controller, /deferResetUntilHistory \?\? true/, "history reset waits for successful load");
|
||||
assert.match(controller, /type: "hydrate_error"/, "history failure dispatches hydrate_error");
|
||||
@@ -15,6 +16,17 @@ assert.match(controller, /applyHydrateErrorState|hydratePlaceholderItems/, "hydr
|
||||
assert.match(readFileSync(join(root, "lib/hydrateErrorState.ts"), "utf8"), /keptItems/, "hydrateErrorState preserves items");
|
||||
assert.match(controller, /throw new Error\(t\("history\.failedLoadHistory"\)\)/, "listSessions does not swallow failures as empty");
|
||||
assert.match(controller, /retrySessionHistory/, "retry path is exported");
|
||||
assert.match(
|
||||
transcript,
|
||||
/if \(!virtuosoReadyRef\.current \|\| !stick\.current\) return;\n followGrowingTail\(\);\n \}, \[footerHeight, followGrowingTail, stick\]\);/,
|
||||
"footer resize is routed through the coalesced tail-follow path without depending on item appends",
|
||||
);
|
||||
assert.match(controller, /shouldPreferResidentHistory\(reset, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
|
||||
assert.match(
|
||||
controller,
|
||||
/loadSessionDataForTab\(tabId, false, "startup", \{ preserveCachedHistory: true \}\)/,
|
||||
"failed clear keeps the visible transcript instead of a resident snapshot",
|
||||
);
|
||||
assert.match(store, /slice\.error/, "transcript store rejects slice.error as failure");
|
||||
assert.match(app, /retrySessionHistory/, "App wires history retry control");
|
||||
assert.match(app, /history-load-error/, "App surfaces hydrate error banner");
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Run: tsx src/__tests__/hydrate-history-apply.test.ts
|
||||
|
||||
import {
|
||||
duplicateLiveItemIds,
|
||||
hasCachedLiveTurn,
|
||||
hydratedHistoryApplyMode,
|
||||
sameSessionPlaceholderItems,
|
||||
shouldPreferResidentHistory,
|
||||
} from "../lib/hydrateHistoryApply";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nhydrate history apply");
|
||||
|
||||
const mode = hydratedHistoryApplyMode;
|
||||
|
||||
ok(mode(true, true, false, { items: [] }) === "skip", "skipHistory blocks apply");
|
||||
ok(mode(false, false, false, { items: [] }) === "skip", "missing projection blocks apply");
|
||||
ok(mode(false, true, false, { items: [] }) === "replace", "idle empty surface applies history");
|
||||
ok(mode(false, true, true, { running: true, items: [] }) === "replace", "running empty surface applies history");
|
||||
ok(
|
||||
mode(false, true, true, { running: true, live: { text: "partial" }, items: [] }) === "replace",
|
||||
"a mid-stream surface with no rows yet applies history",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, true, { running: true, items: [{ kind: "user" }] }) === "prepend",
|
||||
"a running turn with no history page behind it gets one prepended",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, true, { running: true, historyTotalTurns: 3, items: [{ kind: "user" }] }) === "skip",
|
||||
"an already-hydrated running transcript is left alone",
|
||||
);
|
||||
ok(
|
||||
hasCachedLiveTurn({
|
||||
running: true,
|
||||
historyTotalTurns: 2,
|
||||
items: [{ kind: "assistant", streaming: true }],
|
||||
}),
|
||||
"streaming assistant counts as a cached live turn",
|
||||
);
|
||||
ok(
|
||||
!hasCachedLiveTurn({ running: true, items: [{ kind: "assistant", streaming: true }] }),
|
||||
"a live turn with no history page behind it is not cached",
|
||||
);
|
||||
ok(
|
||||
duplicateLiveItemIds(
|
||||
[{ kind: "user", id: "h1", text: "ask" }],
|
||||
[{ kind: "user", id: "l1", text: "ask" }, { kind: "assistant", id: "l2", text: "" }],
|
||||
).join(",") === "l1",
|
||||
"a live row the page already carries is dropped",
|
||||
);
|
||||
ok(
|
||||
duplicateLiveItemIds(
|
||||
[{ kind: "user", id: "h1", text: "ask" }],
|
||||
[{ kind: "assistant", id: "l2", text: "" }],
|
||||
).length === 0,
|
||||
"a live tail the page does not carry is kept",
|
||||
);
|
||||
ok(
|
||||
sameSessionPlaceholderItems("a.jsonl", { meta: { sessionPath: "b.jsonl" }, items: [{ kind: "user" }] }) === undefined,
|
||||
"foreign session items are not placeholders",
|
||||
);
|
||||
ok(
|
||||
(sameSessionPlaceholderItems("a.jsonl", { meta: { sessionPath: "a.jsonl" }, items: [{ kind: "user" }] }) ?? []).length === 1,
|
||||
"same-session items stay placeholders",
|
||||
);
|
||||
|
||||
ok(
|
||||
!shouldPreferResidentHistory(false, false),
|
||||
"retry / explicit no-cache hydrates must not serve the resident snapshot",
|
||||
);
|
||||
ok(shouldPreferResidentHistory(false, true), "preserveCachedHistory still allows a resident hit");
|
||||
ok(shouldPreferResidentHistory(false, undefined), "unspecified preserveCachedHistory still allows a resident hit");
|
||||
ok(!shouldPreferResidentHistory(true, true), "reset hydrates never prefer the resident snapshot");
|
||||
|
||||
const liveIdle = {
|
||||
items: [{ kind: "user" }, { kind: "assistant" }, { kind: "user" }],
|
||||
historyRevision: 10,
|
||||
historyDigest: "rev-10",
|
||||
};
|
||||
ok(
|
||||
mode(false, true, false, liveIdle, {
|
||||
items: [{ kind: "user" }],
|
||||
revision: 10,
|
||||
digest: "rev-10",
|
||||
}) === "skip",
|
||||
"idle transcript is not replaced by a shorter same-fingerprint resident snapshot",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, false, liveIdle, {
|
||||
items: [{ kind: "user" }, { kind: "assistant" }, { kind: "user" }, { kind: "assistant" }],
|
||||
revision: 11,
|
||||
digest: "rev-11",
|
||||
}) === "replace",
|
||||
"a newer backend page still replaces the idle transcript",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, false, { items: [] }, {
|
||||
items: [{ kind: "user" }],
|
||||
revision: 10,
|
||||
digest: "rev-10",
|
||||
}) === "replace",
|
||||
"empty idle surface still applies history",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
// Run: tsx src/__tests__/delivery-worktree.test.ts
|
||||
// Run: tsx src/__tests__/isolated-worktree.test.ts
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -20,13 +20,13 @@ function ok(value: unknown, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ndelivery worktree");
|
||||
ok(/DeliveryWorktreeAvailability\(workspaceRoot: string\)/.test(bridge), "bridge exposes non-mutating availability probe");
|
||||
ok(/CreateDeliveryWorktree\(workspaceRoot: string\)/.test(bridge), "bridge exposes isolated workspace creation");
|
||||
ok(/app\.DeliveryWorktreeAvailability\(projectRoot\)/.test(tree), "project menu probes Git before enabling isolation");
|
||||
console.log("\nisolated worktree");
|
||||
ok(/IsolatedWorktreeAvailability\(workspaceRoot: string\)/.test(bridge), "bridge exposes non-mutating availability probe");
|
||||
ok(/CreateIsolatedWorktree\(workspaceRoot: string\)/.test(bridge), "bridge exposes isolated workspace creation");
|
||||
ok(/app\.IsolatedWorktreeAvailability\(projectRoot\)/.test(tree), "project menu probes Git before enabling isolation");
|
||||
ok(/disabled: isolatingProject !== null \|\| isolationAvailability\?\.available === false/.test(tree), "menu disables unavailable or duplicate creation");
|
||||
ok(/onCreateDeliveryWorktree\?\.\(workspaceRoot\)/.test(tree), "project menu delegates isolated workspace creation");
|
||||
ok(/kind: "delivery-worktree"/.test(app) && /enqueueNavigation\(\{ kind: "delivery-worktree"/.test(app), "creation shares the last-click-wins navigation queue");
|
||||
ok(/onCreateIsolatedWorktree\?\.\(workspaceRoot\)/.test(tree), "project menu delegates isolated workspace creation");
|
||||
ok(/kind: "isolated-worktree"/.test(app) && /enqueueNavigation\(\{ kind: "isolated-worktree"/.test(app), "creation shares the last-click-wins navigation queue");
|
||||
ok(/sourceDirty[\s\S]*worktreeCreatedDirty/.test(app), "dirty source checkout receives an explicit warning");
|
||||
ok(/isolatedWorktree && <WorktreeBadge/.test(tabs), "tab strip identifies isolated worktrees");
|
||||
ok(/activeTab\?\.isolatedWorktree && <WorktreeBadge/.test(app), "topic bar identifies isolated worktrees");
|
||||
@@ -34,4 +34,4 @@ ok(/node\.isolatedWorktree && <WorktreeBadge/.test(tree), "project tree identifi
|
||||
ok(/GitBranch/.test(badge) && /#6119/.test(badge), "shared badge preserves the credited #6119 design contribution");
|
||||
|
||||
if (failed) process.exit(1);
|
||||
console.log("delivery worktree tests passed");
|
||||
console.log("isolated worktree tests passed");
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
beginKeyedResourceRequest,
|
||||
emptyKeyedResource,
|
||||
rejectKeyedResourceRequest,
|
||||
resolveKeyedResourceRequest,
|
||||
} from "../lib/keyedResource";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function check(value: boolean, label: string) {
|
||||
if (value) { passed += 1; process.stdout.write(` PASS ${label}\n`); }
|
||||
else { failed += 1; process.stdout.write(` FAIL ${label}\n`); }
|
||||
}
|
||||
|
||||
console.log("\nkeyed stale-while-revalidate resource");
|
||||
let state = beginKeyedResourceRequest(emptyKeyedResource<string>(), "a", 1, 1);
|
||||
state = resolveKeyedResourceRequest(state, "a", 1, "first", 1);
|
||||
state = beginKeyedResourceRequest(state, "a", 2, 2);
|
||||
check(state.data === "first" && state.status === "refreshing", "same-key refresh keeps painted data");
|
||||
const stale = resolveKeyedResourceRequest(state, "a", 1, "stale", 1);
|
||||
check(stale === state, "stale completion is ignored by sequence");
|
||||
state = rejectKeyedResourceRequest(state, "a", 2, "offline");
|
||||
check(state.data === "first" && state.status === "error", "refresh error preserves painted data");
|
||||
state = beginKeyedResourceRequest(state, "b", 3, 3);
|
||||
check(state.data === null && state.status === "refreshing", "new key starts without unrelated data");
|
||||
check(failed === 0, `${passed} resource assertions passed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// MarkdownHistory (worker-driven history rendering): transcript markdown cache
|
||||
// hits avoid re-parsing, revision (content) changes re-parse, and huge
|
||||
// documents mount their blocks progressively via idle callbacks. Uses the
|
||||
// in-process fallback client with a spy — jsdom has no Worker.
|
||||
// documents keep a viewport-driven tail window. Uses the in-process fallback
|
||||
// client with a spy — jsdom has no Worker.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
@@ -48,23 +48,54 @@ globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
let pendingIdle: Array<() => void> = [];
|
||||
Object.defineProperty(dom.window, "requestIdleCallback", {
|
||||
configurable: true,
|
||||
value: (callback: () => void) => {
|
||||
pendingIdle.push(callback);
|
||||
return pendingIdle.length;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window, "cancelIdleCallback", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
const intersectionCallbacks = new Map<Element, IntersectionObserverCallback>();
|
||||
class TestIntersectionObserver {
|
||||
readonly callback: IntersectionObserverCallback;
|
||||
readonly targets = new Set<Element>();
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.targets.add(target);
|
||||
intersectionCallbacks.set(target, this.callback);
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
this.targets.delete(target);
|
||||
intersectionCallbacks.delete(target);
|
||||
}
|
||||
disconnect() {
|
||||
for (const target of this.targets) intersectionCallbacks.delete(target);
|
||||
this.targets.clear();
|
||||
}
|
||||
takeRecords(): IntersectionObserverEntry[] { return []; }
|
||||
readonly root = null;
|
||||
readonly rootMargin = "0px";
|
||||
readonly thresholds = [0];
|
||||
}
|
||||
globalThis.IntersectionObserver = TestIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
dom.window.IntersectionObserver = TestIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
|
||||
function runIdle() {
|
||||
const callbacks = pendingIdle;
|
||||
pendingIdle = [];
|
||||
for (const callback of callbacks) callback();
|
||||
async function intersectSentinel(selector: string, isIntersecting: boolean) {
|
||||
const sentinel = rootEl?.querySelector(selector);
|
||||
if (!sentinel) throw new Error(`missing sentinel ${selector}`);
|
||||
await act(async () => {
|
||||
intersectionCallbacks.get(sentinel)?.([{ isIntersecting, target: sentinel } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function intersectSentinels(selectors: string[], isIntersecting: boolean) {
|
||||
const sentinels = selectors.map((selector) => {
|
||||
const sentinel = rootEl?.querySelector(selector);
|
||||
if (!sentinel) throw new Error(`missing sentinel ${selector}`);
|
||||
return sentinel;
|
||||
});
|
||||
await act(async () => {
|
||||
for (const sentinel of sentinels) {
|
||||
intersectionCallbacks.get(sentinel)?.([{ isIntersecting, target: sentinel } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
const flush = () => act(async () => {
|
||||
@@ -172,7 +203,21 @@ console.log("\nmarkdown history rendering");
|
||||
|
||||
// ── progressive mounting for huge documents ──────────────────────────────────
|
||||
{
|
||||
const text = Array.from({ length: 60 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
|
||||
rootEl.className = "transcript";
|
||||
rootEl.scrollTop = 1_000;
|
||||
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
const top = this.hasAttribute("data-markdown-older-sentinel")
|
||||
? 100
|
||||
: this.hasAttribute("data-markdown-newer-sentinel")
|
||||
? 700
|
||||
: this.hasAttribute("data-markdown-scroll-anchor")
|
||||
? Number(this.getAttribute("data-markdown-scroll-anchor")) < 300 ? 500 : 300
|
||||
: 0;
|
||||
const height = this.hasAttribute("data-markdown-older-sentinel") ? 1 : 0;
|
||||
return { top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top, toJSON: () => ({}) };
|
||||
};
|
||||
const text = Array.from({ length: 420 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
|
||||
const root5 = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root5.render(<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />);
|
||||
@@ -180,20 +225,59 @@ console.log("\nmarkdown history rendering");
|
||||
await flush();
|
||||
const container = rootEl.querySelector(".md[data-markdown-blocks]");
|
||||
ok(container, "huge document renders through blocks");
|
||||
eq(container?.getAttribute("data-markdown-blocks"), "60", "all 60 blocks are in the render model");
|
||||
eq(container?.getAttribute("data-markdown-visible-blocks"), "24", "visible block count exposes the initial idle chunk");
|
||||
eq(container?.getAttribute("data-markdown-blocks"), "420", "all 420 blocks are in the render model");
|
||||
eq(container?.getAttribute("data-markdown-visible-blocks"), "24", "visible block count exposes the initial tail window");
|
||||
const initialCount = container?.children.length ?? 0;
|
||||
eq(initialCount, 24, "first commit mounts only the initial block chunk");
|
||||
for (let i = 0; i < 5 && (rootEl.querySelector(".md[data-markdown-blocks]")?.children.length ?? 0) < 60; i += 1) {
|
||||
await act(async () => {
|
||||
runIdle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.children.length, 60, "idle callbacks mount the remaining blocks");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "60", "visible block count reaches the render model");
|
||||
ok(rootEl.textContent?.includes("Paragraph 59"), "the final block's content mounts");
|
||||
eq(initialCount, 25, "first commit mounts the tail plus one inert viewport sentinel");
|
||||
ok(!rootEl.textContent?.includes("Paragraph 0"), "the cold prefix stays out of the DOM");
|
||||
ok(rootEl.textContent?.includes("Paragraph 419"), "the newest block mounts immediately");
|
||||
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "entering the older edge prepends one bounded page");
|
||||
eq(rootEl.scrollTop, 1_199, "prepending compensates for the old leading block boundary's measured movement");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "a stationary sentinel cannot start a render loop");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", false);
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "216", "leaving and re-entering requests the next page");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", false);
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "216", "the resident block window stays bounded while paging toward the start");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "108", "older paging advances the bounded window start");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "324", "older paging trims cold blocks from the newer edge");
|
||||
ok(rootEl.textContent?.includes("Paragraph 108"), "the requested older page is mounted");
|
||||
ok(!rootEl.textContent?.includes("Paragraph 419"), "the distant tail is evicted after the window reaches its cap");
|
||||
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", false);
|
||||
await intersectSentinels(["[data-markdown-older-sentinel]", "[data-markdown-newer-sentinel]"], true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "12", "simultaneous edge callbacks apply only the first window move");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "228", "an opposite-edge callback cannot overwrite the in-flight window move");
|
||||
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", false);
|
||||
const beforeNewerPage = rootEl.scrollTop;
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "108", "newer paging trims cold blocks from the older edge");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "324", "newer paging advances toward the document tail");
|
||||
eq(rootEl.scrollTop, beforeNewerPage - 200, "trimming above compensates for the old trailing boundary's measured movement");
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", false);
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "204", "a second newer page keeps the resident window bounded");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "420", "newer paging can return to the document tail");
|
||||
ok(rootEl.textContent?.includes("Paragraph 419"), "newer paging restores the document tail");
|
||||
|
||||
const replacement = Array.from({ length: 420 }, (_, i) => `Replacement ${i} with some *content*.`).join("\n\n");
|
||||
await act(async () => {
|
||||
root5.render(<MarkdownHistory text={replacement} entryId="md-history-huge-replacement" fallback={null} />);
|
||||
});
|
||||
await flush();
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "24", "an equal-sized replacement document resets to its own tail");
|
||||
ok(!rootEl.textContent?.includes("Replacement 0"), "the replacement cannot inherit the previous document's expanded prefix");
|
||||
ok(rootEl.textContent?.includes("Replacement 419"), "the replacement's newest block mounts immediately");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "a replacement document re-arms viewport paging");
|
||||
await act(async () => root5.unmount());
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = originalRect;
|
||||
rootEl.className = "";
|
||||
}
|
||||
|
||||
// ── worker failure falls back through onError ────────────────────────────────
|
||||
|
||||
@@ -37,7 +37,7 @@ eq(markdownImageSource("https://cdn.example.com/a.png", false), "https://cdn.exa
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const componentsSource = readFileSync(resolve(testDir, "../components/markdownComponents.tsx"), "utf8");
|
||||
eq(componentsSource.includes("img: ({ src, alt, title })"), true, "the shared components map owns the image element");
|
||||
eq(componentsSource.includes("markdownImageSource(src)"), true, "the shared components map applies backend routing");
|
||||
eq(componentsSource.includes("<MarkdownImage src={src}"), true, "both streaming and worker paths use the async image resolver");
|
||||
|
||||
if (failed > 0) {
|
||||
process.stderr.write(`\n${failed} markdown image proxy assertion(s) failed\n`);
|
||||
|
||||
@@ -54,7 +54,9 @@ class FakeWorker implements MarkdownWorkerLike {
|
||||
this.onmessage?.({ data: { id, error } } as MessageEvent<MarkdownParseResponse>);
|
||||
}
|
||||
crash(): void {
|
||||
this.onerror?.(new ErrorEvent("error"));
|
||||
// Node 24 does not expose the browser ErrorEvent constructor. The client
|
||||
// intentionally treats worker.onerror as a signal and does not inspect it.
|
||||
this.onerror?.({ type: "error" } as ErrorEvent);
|
||||
}
|
||||
terminate(): void {
|
||||
this.terminated += 1;
|
||||
|
||||
@@ -205,6 +205,71 @@ console.log("\nmermaid rendering");
|
||||
eq(streamingCommitTarget("t\n\n$$\nx\n$$\nafter"), "t\n\n$$\nx\n$$\n", "closed display math promotes immediately");
|
||||
eq(streamingCommitTarget("para\n## Next sec"), "para\n", "a partial heading line completes the paragraph before it");
|
||||
eq(streamingCommitTarget("para\n## Done\ntail"), "para\n## Done\n", "a terminated heading promotes itself as a complete block");
|
||||
|
||||
const searchItem = (title: string, url: string) => `- **${title}**\n <${url}>`;
|
||||
const searchDump = [
|
||||
searchItem("新闻本文", "https://example.com/a"),
|
||||
searchItem("Bitcoin (BTC) Price", "https://example.com/b?utm_source=x"),
|
||||
searchItem("KuCoin", "https://example.com/c"),
|
||||
].join("\n");
|
||||
eq(
|
||||
streamingCommitTarget(searchDump),
|
||||
`${searchItem("新闻本文", "https://example.com/a")}\n${searchItem("Bitcoin (BTC) Price", "https://example.com/b?utm_source=x")}\n`,
|
||||
"a later list marker commits prior tight list items, including indented URL continuations",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget(`${searchItem("新闻本文", "https://example.com/a")}\n- **Bit`),
|
||||
`${searchItem("新闻本文", "https://example.com/a")}\n`,
|
||||
"an in-progress list marker still commits the previous completed item",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget(searchItem("新闻本文", "https://example.com/a")),
|
||||
"",
|
||||
"a single list item stays in the tail until the next item or a blank line",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("1. alpha\n2. beta\n3. gamma"),
|
||||
"1. alpha\n2. beta\n",
|
||||
"ordered list markers complete prior items the same way",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("- [ ] one\n- [x] two\n- [ ] three"),
|
||||
"- [ ] one\n- [x] two\n",
|
||||
"task-list markers complete prior items the same way",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("intro paragraph\n- first item\n continued"),
|
||||
"intro paragraph\n",
|
||||
"a list marker completes the paragraph before it without taking the new item",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("- parent\n - child still typing"),
|
||||
"- parent\n",
|
||||
"a nested list marker completes the parent item and leaves the child in the tail",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("not a heading\n---\nstill setext"),
|
||||
"",
|
||||
"a setext underline is not treated as a list marker",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("*not-a-list*\nstill paragraph"),
|
||||
"",
|
||||
"emphasis without a marker space is not a list item",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("t\n\n```\n- not a list\n- also not\n"),
|
||||
"t\n\n```\n- not a list\n- also not\n",
|
||||
"list markers inside an open fence do not create commit boundaries",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("- one\n- two\n\n"),
|
||||
"- one\n- two\n\n",
|
||||
"a blank line after a list still commits the whole list",
|
||||
);
|
||||
const searchHtml = renderToStaticMarkup(<ReactMarkdown remarkPlugins={[]}>{searchDump}</ReactMarkdown>);
|
||||
ok(searchHtml.includes("<ul>") && searchHtml.includes("<li>") && searchHtml.includes("新闻本文"),
|
||||
"the search-list source still renders as a list in the final Markdown tree");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -269,6 +334,25 @@ console.log("\nmermaid rendering");
|
||||
});
|
||||
eq(rootEl.textContent, "complete", "short stream finalization still commits immediately");
|
||||
|
||||
pendingFrame = undefined;
|
||||
const firstSearch = "- **新闻本文**\n <https://example.com/a>\n";
|
||||
const secondSearch = `${firstSearch}- **Bitcoin**\n <https://example.com/b>`;
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text={firstSearch} streaming />);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, "", "a single tight list item stays off the parsed prefix");
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text={secondSearch} streaming />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
});
|
||||
const listFrame = pendingFrame;
|
||||
await act(async () => {
|
||||
listFrame?.(performance.now());
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, firstSearch, "a later list item commits the previous tight item into the parsed prefix");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
@@ -249,6 +249,22 @@ await render({
|
||||
});
|
||||
ok(Boolean(document.querySelector(".reasoning__body")), "manual reasoning expansion survives auto completion");
|
||||
|
||||
await act(async () => {
|
||||
applyReasoningDisplayMode("expanded");
|
||||
});
|
||||
await render({
|
||||
kind: "assistant",
|
||||
id: "a-expanded",
|
||||
text: "",
|
||||
reasoning: "kept thought",
|
||||
streaming: false,
|
||||
reasoningComplete: true,
|
||||
});
|
||||
ok(Boolean(document.querySelector(".reasoning__body")), "expanded mode keeps completed reasoning open");
|
||||
ok(!document.querySelector(".reasoning-summary"), "expanded mode never falls back to a summary");
|
||||
await click(document.querySelector(".reasoning__head"));
|
||||
ok(!document.querySelector(".reasoning__body"), "a manual collapse still wins inside expanded mode");
|
||||
|
||||
await act(async () => {
|
||||
applyReasoningDisplayMode("hidden");
|
||||
});
|
||||
|
||||
@@ -146,6 +146,12 @@ let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }];
|
||||
let holdNextMeta = false;
|
||||
let staleMetaStarted = false;
|
||||
const eventHandlers: Array<(event: WireEvent) => void> = [];
|
||||
const rebuiltHandlers: Array<(tabId?: string, runtimeEpoch?: string) => void> = [];
|
||||
let backendRuntimeEpoch = "runtime-old";
|
||||
let backendPendingPrompt = false;
|
||||
let promptReplayCalls = 0;
|
||||
const resumeRPCGate = deferred<void>();
|
||||
const channelRPCGate = deferred<void>();
|
||||
const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
@@ -155,6 +161,7 @@ const checkpoints: CheckpointMeta[] = [];
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (event: WireEvent) => void);
|
||||
if (name === "runtime:rebuilt") rebuiltHandlers.push(cb as (tabId?: string, runtimeEpoch?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
@@ -162,14 +169,21 @@ window.runtime = {
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => [tabMeta()],
|
||||
ListTabs: async () => {
|
||||
return [tabMeta({
|
||||
runtime: { phase: "ready", epoch: backendRuntimeEpoch },
|
||||
running: backendPendingPrompt,
|
||||
pendingPrompt: backendPendingPrompt,
|
||||
cancellable: backendPendingPrompt,
|
||||
})];
|
||||
},
|
||||
MetaForTab: async () => {
|
||||
if (holdNextMeta) {
|
||||
holdNextMeta = false;
|
||||
staleMetaStarted = true;
|
||||
return staleSessionMeta.promise;
|
||||
}
|
||||
return meta({ canonicalTodos: backendCanonicalTodos });
|
||||
return meta({ canonicalTodos: backendCanonicalTodos, runtime: { phase: "ready", epoch: backendRuntimeEpoch } });
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
@@ -185,6 +199,18 @@ window.go = {
|
||||
historySliceFromMessages(tabID, await staleHistory.promise, req),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
ReplayPendingPromptsForTab: async (tabID: string) => {
|
||||
promptReplayCalls += 1;
|
||||
if (tabID !== "tab-a" || !backendPendingPrompt) return;
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: tabID,
|
||||
runtimeEpoch: backendRuntimeEpoch,
|
||||
ask: { id: `replayed-${backendRuntimeEpoch}`, questions: [{ id: "choice", prompt: "Recovered after hydration", options: [] }] },
|
||||
});
|
||||
}
|
||||
},
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
backendCanonicalTodos = [];
|
||||
@@ -196,6 +222,25 @@ window.go = {
|
||||
},
|
||||
ResumeSessionPageForTab: async () => {
|
||||
backendCanonicalTodos = [{ content: "Restored task", status: "completed" }];
|
||||
const oldEpoch = backendRuntimeEpoch;
|
||||
backendRuntimeEpoch = "runtime-resumed";
|
||||
backendPendingPrompt = true;
|
||||
for (const handler of rebuiltHandlers) handler("tab-a", backendRuntimeEpoch);
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: oldEpoch,
|
||||
ask: { id: "stale-old-epoch", questions: [{ id: "choice", prompt: "Stale", options: [] }] },
|
||||
});
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: backendRuntimeEpoch,
|
||||
ask: { id: "pre-response-resume", questions: [{ id: "choice", prompt: "Before Resume RPC returns", options: [] }] },
|
||||
});
|
||||
}
|
||||
await resumeRPCGate.promise;
|
||||
return {
|
||||
messages: [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }],
|
||||
startTurn: 0,
|
||||
@@ -204,6 +249,34 @@ window.go = {
|
||||
hasOlder: false,
|
||||
};
|
||||
},
|
||||
OpenChannelSessionPageForTab: async () => {
|
||||
const oldEpoch = backendRuntimeEpoch;
|
||||
backendRuntimeEpoch = "runtime-channel";
|
||||
backendPendingPrompt = true;
|
||||
for (const handler of rebuiltHandlers) handler("tab-a", backendRuntimeEpoch);
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: oldEpoch,
|
||||
ask: { id: "stale-channel-old-epoch", questions: [{ id: "choice", prompt: "Stale channel", options: [] }] },
|
||||
});
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: backendRuntimeEpoch,
|
||||
ask: { id: "pre-response-channel", questions: [{ id: "choice", prompt: "Before channel RPC returns", options: [] }] },
|
||||
});
|
||||
}
|
||||
await channelRPCGate.promise;
|
||||
return {
|
||||
messages: [{ role: "user", content: "channel" }, { role: "assistant", content: "waiting" }],
|
||||
startTurn: 0,
|
||||
endTurn: 1,
|
||||
totalTurns: 1,
|
||||
hasOlder: false,
|
||||
};
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
@@ -262,11 +335,40 @@ await act(async () => {
|
||||
|
||||
eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session");
|
||||
|
||||
let resumePromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
await controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
|
||||
resumePromise = controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "pre-response-resume", "Resume accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
|
||||
await act(async () => {
|
||||
resumeRPCGate.resolve();
|
||||
await resumePromise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state");
|
||||
eq(controller?.state.ask?.id, "replayed-runtime-resumed", "Resume RPC ask emitted before return is restored after reset/history hydration");
|
||||
ok(promptReplayCalls > 0, "Resume completion performs a tab-scoped pending-prompt replay");
|
||||
|
||||
backendPendingPrompt = false;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a", runtimeEpoch: backendRuntimeEpoch });
|
||||
await flushPromises();
|
||||
});
|
||||
const replayCallsBeforeChannelOpen = promptReplayCalls;
|
||||
let channelPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
channelPromise = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "pre-response-channel", "channel-open accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
|
||||
await act(async () => {
|
||||
channelRPCGate.resolve();
|
||||
await channelPromise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "replayed-runtime-channel", "channel-open ask emitted before return is restored after reset/history hydration");
|
||||
ok(promptReplayCalls > replayCallsBeforeChannelOpen, "channel-open completion performs a tab-scoped pending-prompt replay");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -274,6 +274,18 @@ eq(replayed.promptArrivedId, "plan-1", "same-id replay keeps the anchor id");
|
||||
eq(activated.promptArrivedAt, undefined, "activation drops the prompt arrival time");
|
||||
}
|
||||
|
||||
// A tab-tagged ask may arrive while its session is in the background. When
|
||||
// the tab metadata also reports pendingPrompt, activation must not erase the
|
||||
// only actionable copy while a scoped backend replay is still in flight.
|
||||
{
|
||||
const backgroundAsk = reducer({ ...initialState }, { type: "event", e: askEvent });
|
||||
const activated = reducer(backgroundAsk, { type: "backend_activation_start", backendPendingPrompt: true });
|
||||
eq(activated.ask?.id, "ask-1", "confirmed background ask survives activation start");
|
||||
eq(activated.pendingPrompt, true, "confirmed background ask keeps the prompt gate");
|
||||
eq(activated.running, true, "confirmed background ask keeps the turn running");
|
||||
eq(activated.promptArrivedId, "ask-1", "confirmed background ask keeps its freshness anchor");
|
||||
}
|
||||
|
||||
// A new user turn drops the anchor so the next turn's prompts re-anchor fresh.
|
||||
{
|
||||
const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent });
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
enqueueProjectTreeArchive,
|
||||
projectTreeTrashingTopics,
|
||||
runProjectTreeArchiveJob,
|
||||
} from "../lib/projectTreeArchive";
|
||||
import {
|
||||
invalidateProjectTreeTopicLoads,
|
||||
projectTreeWithoutTopics,
|
||||
} from "../lib/projectTreeTopic";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function testLatePreArchivePageCannotReinsertTopic() {
|
||||
const sequences: Record<string, number> = { project: 1 };
|
||||
const capturedSequence = sequences.project;
|
||||
const latePage = deferred<ProjectNode[]>();
|
||||
const applied = latePage.promise.then((items) =>
|
||||
sequences.project === capturedSequence ? items : [],
|
||||
);
|
||||
|
||||
invalidateProjectTreeTopicLoads(sequences, ["project"]);
|
||||
latePage.resolve([{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }]);
|
||||
assert.deepEqual(await applied, []);
|
||||
}
|
||||
|
||||
async function testPendingTombstonesFilterEveryIncomingPage() {
|
||||
const incoming: ProjectNode[] = [
|
||||
{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" },
|
||||
{ key: "topic-b", kind: "topic", label: "B", topicId: "topic-b" },
|
||||
{ key: "topic-c", kind: "topic", label: "C", topicId: "topic-c" },
|
||||
];
|
||||
assert.deepEqual(
|
||||
projectTreeWithoutTopics(incoming, new Set(["topic-a", "topic-b"])).map((node) => node.topicId),
|
||||
["topic-c"],
|
||||
);
|
||||
}
|
||||
|
||||
async function testPostCommitRestoreCanWinWhilePendingIndicatorFinishes() {
|
||||
const pending = projectTreeTrashingTopics(new Set(), "topic-a", true);
|
||||
const tombstones = projectTreeTrashingTopics(new Set(), "topic-a", true);
|
||||
const sequences: Record<string, number> = { project: 2 };
|
||||
|
||||
// Starting the post-commit canonical load fences every pre-commit response,
|
||||
// then releases only the tombstone. The visible pending state may remain
|
||||
// until that load finishes without hiding a legitimate later restore.
|
||||
invalidateProjectTreeTopicLoads(sequences, ["project"]);
|
||||
const releasedTombstones = projectTreeTrashingTopics(tombstones, "topic-a", false);
|
||||
const restored = projectTreeWithoutTopics(
|
||||
[{ key: "topic-a", kind: "topic", label: "Restored A", topicId: "topic-a" }],
|
||||
releasedTombstones,
|
||||
);
|
||||
assert.equal(pending.has("topic-a"), true);
|
||||
assert.equal(restored[0]?.topicId, "topic-a");
|
||||
}
|
||||
|
||||
async function testConcurrentArchivesReachBackendSerially() {
|
||||
const firstGate = deferred<void>();
|
||||
const secondGate = deferred<void>();
|
||||
const firstStarted = deferred<void>();
|
||||
const secondStarted = deferred<void>();
|
||||
const calls: string[] = [];
|
||||
let tail = Promise.resolve();
|
||||
|
||||
const first = enqueueProjectTreeArchive(tail, async () => {
|
||||
calls.push("a:start");
|
||||
firstStarted.resolve();
|
||||
await firstGate.promise;
|
||||
calls.push("a:end");
|
||||
});
|
||||
tail = first;
|
||||
const second = enqueueProjectTreeArchive(tail, async () => {
|
||||
calls.push("b:start");
|
||||
secondStarted.resolve();
|
||||
await secondGate.promise;
|
||||
calls.push("b:end");
|
||||
});
|
||||
tail = second;
|
||||
|
||||
await firstStarted.promise;
|
||||
assert.deepEqual(calls, ["a:start"]);
|
||||
firstGate.resolve();
|
||||
await first;
|
||||
await secondStarted.promise;
|
||||
assert.deepEqual(calls, ["a:start", "a:end", "b:start"]);
|
||||
secondGate.resolve();
|
||||
await tail;
|
||||
assert.deepEqual(calls, ["a:start", "a:end", "b:start", "b:end"]);
|
||||
}
|
||||
|
||||
async function testPendingEndsOnlyAfterCanonicalReload() {
|
||||
const reloadGate = deferred<void>();
|
||||
let pending = true;
|
||||
const job = runProjectTreeArchiveJob({
|
||||
archive: async () => {},
|
||||
reload: () => reloadGate.promise,
|
||||
finishPending: () => { pending = false; },
|
||||
recover: async () => {},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(pending, true);
|
||||
reloadGate.resolve();
|
||||
assert.equal(await job, true);
|
||||
assert.equal(pending, false);
|
||||
}
|
||||
|
||||
async function testFailedArchiveRestoresVisibilityBeforeRecoveryReload() {
|
||||
let pending = true;
|
||||
let recoveryObservedPending: boolean | null = null;
|
||||
const job = runProjectTreeArchiveJob({
|
||||
archive: async () => { throw new Error("busy"); },
|
||||
reload: async () => {},
|
||||
finishPending: () => { pending = false; },
|
||||
recover: async () => { recoveryObservedPending = pending; },
|
||||
});
|
||||
|
||||
assert.equal(await job, false);
|
||||
assert.equal(recoveryObservedPending, false);
|
||||
}
|
||||
|
||||
function testProjectTreeWiresEveryRaceGuard() {
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const archiveSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(archiveSource, /invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/);
|
||||
assert.match(source, /projectTreeWithoutTopics\(asArray\(page\.items\), currentArchiveTombstones\(\)\)/);
|
||||
assert.match(archiveSource, /archiveQueueRef\.current\.catch[\s\S]*\.then\(async \(\) =>/);
|
||||
assert.match(archiveSource, /pendingLoads = targets\.map[\s\S]*onReloadStarted[\s\S]*await Promise\.all\(pendingLoads\)/);
|
||||
assert.match(archiveSource, /onReloadStarted: \(\) => releaseArchiveTombstone\(topicId\)/);
|
||||
assert.match(archiveSource, /await refreshRef\.current\(reloadOptions\)[\s\S]*finally \{[\s\S]*endTrashingTopic\(topicId\)/);
|
||||
}
|
||||
|
||||
await testLatePreArchivePageCannotReinsertTopic();
|
||||
await testPendingTombstonesFilterEveryIncomingPage();
|
||||
await testPostCommitRestoreCanWinWhilePendingIndicatorFinishes();
|
||||
await testConcurrentArchivesReachBackendSerially();
|
||||
await testPendingEndsOnlyAfterCanonicalReload();
|
||||
await testFailedArchiveRestoresVisibilityBeforeRecoveryReload();
|
||||
testProjectTreeWiresEveryRaceGuard();
|
||||
console.log("project tree archive race: 7 passed");
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mergeProjectTopicPage, projectTreeShellChildren } from "../components/ProjectTree";
|
||||
|
||||
type Equal = (actual: unknown, expected: unknown, label: string) => void;
|
||||
|
||||
export function runProjectTreePinnedShellRuntimeTests(eq: Equal, projectTreeSource: string) {
|
||||
eq(
|
||||
mergeProjectTopicPage(
|
||||
[
|
||||
{ key: "topic-off-page-pin", kind: "topic", label: "Pinned", topicId: "pin", pinned: true },
|
||||
{ key: "topic-stale", kind: "topic", label: "Stale", topicId: "stale" },
|
||||
],
|
||||
[{ key: "topic-page", kind: "topic", label: "First page", topicId: "page" }],
|
||||
false,
|
||||
).map((node) => node.key),
|
||||
["topic-page", "topic-off-page-pin"],
|
||||
"a bounded first page keeps pinned shells that fall beyond the page limit",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShellChildren(undefined, [
|
||||
{ key: "topic_pin", kind: "topic", label: "Pinned", topicId: "pin", pinned: true },
|
||||
]).map((node) => node.topicId),
|
||||
["pin"],
|
||||
"a cold collapsed tree paints pinned topic shells before any folder page loads",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShellChildren(
|
||||
[
|
||||
{ key: "topic_old", kind: "topic", label: "Old pin", topicId: "old", pinned: true },
|
||||
{ key: "topic_regular", kind: "topic", label: "Regular", topicId: "regular" },
|
||||
],
|
||||
[{ key: "topic_new", kind: "topic", label: "New pin", topicId: "new", pinned: true }],
|
||||
).map((node) => ({ topicId: node.topicId, pinned: Boolean(node.pinned) })),
|
||||
[
|
||||
{ topicId: "old", pinned: false },
|
||||
{ topicId: "regular", pinned: false },
|
||||
{ topicId: "new", pinned: true },
|
||||
],
|
||||
"shell refresh removes stale pins and adds newly pinned collapsed topics",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeSource.includes("children: projectTreeShellChildren(previous?.children, project.children)"),
|
||||
true,
|
||||
"shell refresh preserves loaded folders while reconciling pinned topic shells",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { projectTreeApplyRuntimeTopics } from "../lib/projectTreeRuntime";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
type RuntimeNode = ProjectNode & { runtimeOnly?: boolean };
|
||||
const catalog: ProjectNode[] = [
|
||||
{ key: "project-a", kind: "project", label: "A", root: "/a", children: [
|
||||
{ key: "known", kind: "topic", label: "Known", root: "/a", topicId: "known", children: [] },
|
||||
] },
|
||||
{ key: "project-b", kind: "project", label: "B", root: "/b", children: [] },
|
||||
];
|
||||
const overlaid = projectTreeApplyRuntimeTopics(catalog, [
|
||||
{ scope: "project", workspaceRoot: "/a", node: {
|
||||
key: "known", kind: "topic", label: "Known live", root: "/a", topicId: "known", running: true, status: "thinking", children: [
|
||||
{ key: "known-a", kind: "session", label: "Session A", topicId: "known", sessionPath: "/a/a.jsonl" },
|
||||
{ key: "known-b", kind: "session", label: "Session B", topicId: "known", sessionPath: "/a/b.jsonl" },
|
||||
],
|
||||
} },
|
||||
{ scope: "project", workspaceRoot: "/b", node: {
|
||||
key: "new", kind: "topic", label: "New live", root: "/b", topicId: "new", running: true, status: "streaming", children: [],
|
||||
} },
|
||||
]);
|
||||
const shape = (tree: ProjectNode[]) => tree.map((project) => project.children?.map((topic) => [
|
||||
topic.topicId, topic.running, (topic as RuntimeNode).runtimeOnly,
|
||||
]));
|
||||
assert.deepEqual(shape(overlaid), [[['known', true, undefined]], [['new', true, true]]]);
|
||||
assert.equal(overlaid[0]?.children?.[0]?.children?.length, 2);
|
||||
assert.deepEqual(shape(projectTreeApplyRuntimeTopics(overlaid, [])), [[['known', undefined, undefined]], []]);
|
||||
assert.equal(projectTreeApplyRuntimeTopics(overlaid, [
|
||||
{ scope: "project", workspaceRoot: "/a", node: {
|
||||
key: "known", kind: "topic", label: "Known live", root: "/a", topicId: "known", running: true, children: [],
|
||||
} },
|
||||
], new Set(["known"]))[0]?.children?.length, 0);
|
||||
assert.equal(projectTreeApplyRuntimeTopics(overlaid, [])[0]?.children?.[0]?.children?.length, 0);
|
||||
|
||||
const projects: ProjectNode[] = Array.from({ length: 100 }, (_, index) => ({
|
||||
key: `p-${index}`, kind: "project", label: `P ${index}`, root: `/p/${index}`, children: [],
|
||||
}));
|
||||
const topics = projects.map((project, index) => ({
|
||||
scope: "project", workspaceRoot: project.root, node: {
|
||||
key: `t-${index}`, kind: "topic" as const, label: `T ${index}`, root: project.root,
|
||||
topicId: `t-${index}`, running: true, status: "thinking" as const, children: [],
|
||||
},
|
||||
}));
|
||||
const hundred = projectTreeApplyRuntimeTopics(projects, topics);
|
||||
assert.equal(hundred.reduce((count, project) => count + (project.children?.filter((topic) => topic.running).length ?? 0), 0), 100);
|
||||
console.log(" PASS project tree runtime projection");
|
||||
@@ -3,8 +3,11 @@
|
||||
import {
|
||||
projectTreeFolderDisclosure,
|
||||
mergeProjectTopicPage,
|
||||
projectTreeWithoutTopic,
|
||||
projectTreeShellChildren,
|
||||
projectTreeEventAffectsFolder,
|
||||
projectTreeRevisionIsFresh,
|
||||
projectTreeTopicPageIsFresh,
|
||||
projectTreeShouldApplyShellSnapshot,
|
||||
defaultExpandedProjectTreeKeys,
|
||||
activeSessionAncestorKeys,
|
||||
@@ -12,6 +15,8 @@ import {
|
||||
projectTreeShouldSuppressOpenForRename,
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
topicIsActive,
|
||||
topicStatusLabel,
|
||||
projectTreeTopicArchiveBlocked,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
projectTreeTopicMetaLine,
|
||||
@@ -21,9 +26,16 @@ import {
|
||||
projectTreeTopicHoverCardModel,
|
||||
projectTreeTopicMenuOffersPin,
|
||||
projectTreeDedupedExactTime,
|
||||
projectTreeShellSignature,
|
||||
} from "../components/ProjectTree";
|
||||
import { projectTreeTrashingTopics } from "../lib/projectTreeArchive";
|
||||
import { normalizeProjectTreeRuntimeSnapshot } from "../lib/projectTreeRuntime";
|
||||
import { runProjectTreeSortRuntimeTests } from "./project-tree-sort-runtime.test";
|
||||
import { runProjectTreePinnedShellRuntimeTests } from "./project-tree-pinned-shell-runtime.test";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -40,6 +52,12 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
|
||||
console.log("\nproject tree runtime sessions");
|
||||
|
||||
eq(
|
||||
normalizeProjectTreeRuntimeSnapshot({ revision: 0, topics: null }),
|
||||
{ revision: 0, topics: [] },
|
||||
"runtime bridge normalizes a legacy/null Wails topic array",
|
||||
);
|
||||
|
||||
const noTrashingTopics = new Set<string>();
|
||||
const topicATrashing = projectTreeTrashingTopics(noTrashingTopics, "topic-a", true);
|
||||
const twoTopicsTrashing = projectTreeTrashingTopics(topicATrashing, "topic-b", true);
|
||||
@@ -211,6 +229,34 @@ eq(
|
||||
"running topic keeps runtime status instead of completed-unread attention",
|
||||
);
|
||||
|
||||
const relocatedTopic = { ...completedTopic, sessionPath: "/s/b.jsonl" };
|
||||
const relocatedKey = projectTreeReadActivityKey({ ...completedTopic, sessionPath: "/s/a.jsonl" }) ?? "";
|
||||
eq(
|
||||
projectTreeReadActivityKey(relocatedTopic),
|
||||
relocatedKey,
|
||||
"unread key stays on the logical topic when the representative path changes",
|
||||
);
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(relocatedTopic, { [relocatedKey]: 2000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"marking a topic read survives a later representative-path refresh",
|
||||
);
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, {}, "project", "/repo", "other-topic", undefined, 2000),
|
||||
false,
|
||||
"activity at or before the first-seen baseline is not unread",
|
||||
);
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, {}, "project", "/repo", "other-topic", undefined, 1999),
|
||||
true,
|
||||
"activity newer than the first-seen baseline is unread",
|
||||
);
|
||||
eq(
|
||||
topicIsActive({ ...completedTopic, sessionPath: "/s/a.jsonl" }, "project", "/repo", "topic-complete", "/s/other.jsonl"),
|
||||
true,
|
||||
"logical topic stays active when the representative path is not the open file",
|
||||
);
|
||||
|
||||
for (const status of ["thinking", "streaming", "waiting_confirmation", "background_job"] as const) {
|
||||
eq(
|
||||
projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
|
||||
@@ -219,7 +265,7 @@ for (const status of ["thinking", "streaming", "waiting_confirmation", "backgrou
|
||||
);
|
||||
}
|
||||
|
||||
for (const status of ["paused", "error"] as const) {
|
||||
for (const status of ["paused", "error", "awaiting_delivery"] as const) {
|
||||
eq(
|
||||
projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
|
||||
false,
|
||||
@@ -227,6 +273,17 @@ for (const status of ["paused", "error"] as const) {
|
||||
);
|
||||
}
|
||||
|
||||
eq(
|
||||
topicStatusLabel({ ...completedTopic, status: "awaiting_delivery" }, testT),
|
||||
"projectTree.status.awaitingDelivery",
|
||||
"delivery-check pause uses its own sidebar label, not paused",
|
||||
);
|
||||
eq(
|
||||
topicStatusLabel({ ...completedTopic, status: "paused" }, testT),
|
||||
"projectTree.status.paused",
|
||||
"recovery pause keeps the paused sidebar label",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicArchiveBlocked({ ...completedTopic, running: true }),
|
||||
true,
|
||||
@@ -531,18 +588,18 @@ eq(
|
||||
|
||||
eq(
|
||||
projectTreeTopicHoverCardModel(
|
||||
{ key: "topic_t", kind: "topic", label: "● Busy topic", root: "/repo", topicId: "t", turns: 3, status: "streaming" },
|
||||
{ key: "topic_t", kind: "topic", label: "● Busy topic", preview: "Full first-message preview", root: "/repo", topicId: "t", turns: 3, status: "streaming" },
|
||||
testT,
|
||||
"my-project",
|
||||
),
|
||||
{
|
||||
title: "Busy topic",
|
||||
title: "Full first-message preview",
|
||||
statusLabel: "projectTree.status.streaming",
|
||||
metaLine: "3 turns",
|
||||
exactTime: "",
|
||||
projectLabel: "my-project",
|
||||
},
|
||||
"hover card model strips the running marker and carries turns, status, and project",
|
||||
"hover card model shows the full preview and carries turns, status, and project",
|
||||
);
|
||||
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
@@ -631,6 +688,15 @@ eq(
|
||||
"project tree ignores stale snapshots and pages while accepting the current revision",
|
||||
);
|
||||
|
||||
eq(
|
||||
[
|
||||
projectTreeTopicPageIsFresh({ "project-a": 11, "project-b": 9 }, "project-a", 10),
|
||||
projectTreeTopicPageIsFresh({ "project-a": 11, "project-b": 9 }, "project-b", 10),
|
||||
],
|
||||
[false, true],
|
||||
"a newer revision in one project does not discard another project's slower page",
|
||||
);
|
||||
|
||||
eq(
|
||||
[
|
||||
projectTreeShouldApplyShellSnapshot({ currentRevision: 1, incomingRevision: 0, treeEmpty: true }),
|
||||
@@ -657,6 +723,44 @@ eq(
|
||||
"overlapping keyset pages replace duplicates without changing stable order",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeWithoutTopic(
|
||||
[
|
||||
{
|
||||
key: "p",
|
||||
kind: "project",
|
||||
label: "P",
|
||||
children: [
|
||||
{ key: "topic_archive", kind: "topic", label: "Archive me", topicId: "topic_archive" },
|
||||
{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"topic_archive",
|
||||
).map((node) => (node.children ?? []).map((child) => child.topicId)),
|
||||
[["topic_keep"]],
|
||||
"archiving a topic removes it from loaded children before the catalog reloads",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShellChildren(
|
||||
[{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" }],
|
||||
),
|
||||
[{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" }],
|
||||
"a mutation refresh keeps sibling conversations visible until the replacement page arrives",
|
||||
);
|
||||
|
||||
const projectTreeSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"), "utf8");
|
||||
const projectTreeArchiveSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"), "utf8");
|
||||
eq(projectTreeSource.includes("projectTreeWithoutTopic("), true, "TrashTopic removes the archived row before the shell refresh");
|
||||
eq(
|
||||
projectTreeArchiveSource.includes("reloadTopicKeys"),
|
||||
true,
|
||||
"archive refresh reloads only the affected topic folder after preserving the painted siblings",
|
||||
);
|
||||
runProjectTreePinnedShellRuntimeTests(eq, projectTreeSource);
|
||||
await runProjectTreeSortRuntimeTests(eq, projectTreeSource);
|
||||
|
||||
eq(
|
||||
[
|
||||
projectTreeEventAffectsFolder({ key: "global", kind: "global_folder", label: "Global" }, [""]),
|
||||
@@ -667,5 +771,22 @@ eq(
|
||||
"revision events refresh only affected expanded roots, with an empty roots list as broadcast",
|
||||
);
|
||||
|
||||
const shellWithTopics = (label: string): ProjectNode => ({
|
||||
key: "p1",
|
||||
kind: "project",
|
||||
label,
|
||||
root: "/repo",
|
||||
children: [{ key: "t1", kind: "topic", label: "T1", topicId: "t1" }],
|
||||
});
|
||||
eq(
|
||||
[
|
||||
projectTreeShellSignature([{ key: "p1", kind: "project", label: "P" }]),
|
||||
projectTreeShellSignature([shellWithTopics("P")]),
|
||||
projectTreeShellSignature([{ key: "p1", kind: "project", label: "P" }, { key: "p2", kind: "project", label: "P2" }]),
|
||||
],
|
||||
["p1", "p1", "p1\u001fp2"],
|
||||
"shell signature tracks project arrivals only, so topic page loads cannot re-arm the reload effect",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -6,6 +6,9 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const topic = readFileSync(join(root, "lib/projectTreeTopic.ts"), "utf8");
|
||||
const runtime = readFileSync(join(root, "lib/projectTreeRuntime.ts"), "utf8");
|
||||
const runtimeHook = readFileSync(join(root, "lib/useProjectTreeRuntimeProjection.ts"), "utf8");
|
||||
const bridge = readFileSync(join(root, "lib/bridge.ts"), "utf8");
|
||||
const panel = readFileSync(join(root, "components/ProjectTree.tsx"), "utf8");
|
||||
|
||||
assert.match(topic, /projectTreeShouldApplyShellSnapshot/, "shell race helper exported");
|
||||
@@ -13,15 +16,46 @@ assert.match(topic, /treeEmpty/, "empty-tree shells bypass catalog revision wate
|
||||
assert.match(panel, /projectTreeShouldApplyShellSnapshot/, "ProjectTree uses shell race helper");
|
||||
assert.match(panel, /treeRef\.current\.length === 0/, "v2 event re-fetches shell when tree is empty");
|
||||
assert.match(panel, /void refresh\(\)/, "empty-tree event path calls refresh");
|
||||
assert.match(
|
||||
panel,
|
||||
/projectTreeTopicPageIsFresh\(topicRevisionRef\.current, key, page\.revision\)/,
|
||||
"topic pages compare revisions within their own project",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/projectTreeRevisionIsFresh\(latestRevisionRef\.current, page\.revision\)/,
|
||||
"an unrelated project's revision cannot discard a valid slower topic page",
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/onProjectTreeChangedV2[\s\S]*projectTreeRevisionIsFresh\(latestRevisionRef\.current, event\.revision\)/,
|
||||
"equal-revision overlay events use the shared freshness contract",
|
||||
"equal-revision catalog events use the shared freshness contract",
|
||||
);
|
||||
assert.match(runtime, /onProjectTreeRuntimeChanged/, "runtime projection has a dedicated Wails subscription");
|
||||
assert.match(runtimeHook, /bindProjectTreeRuntime/, "ProjectTree binds the runtime projection after mount");
|
||||
assert.match(runtimeHook, /GetProjectTreeRuntimeSnapshot/, "runtime subscription reconciles with a post-subscribe snapshot");
|
||||
assert.match(bridge, /\?\.reason !== "runtime"/, "current frontend ignores tagged legacy runtime invalidations");
|
||||
assert.doesNotMatch(
|
||||
runtime,
|
||||
/ListProjectTopics/,
|
||||
"runtime events never reload catalog topic pages",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/event\.revision\s*<=\s*latestRevisionRef\.current/,
|
||||
"equal-revision tombstone overlays are not discarded",
|
||||
);
|
||||
assert.doesNotMatch(panel, /setIndexingDone\(/, "topic refresh no longer waits for a global all-directories gate");
|
||||
assert.match(panel, /projectTreeEventAffectsFolder\(project, affected\)/, "directory revisions refresh only affected expanded projects");
|
||||
assert.match(
|
||||
panel,
|
||||
/projectTreeShellSignature/,
|
||||
"debounced reload observes project arrivals through a shell signature",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/\[\s*expanded,\s*loadProjectTopics,\s*query,\s*timeFilter,\s*tree\s*\]/,
|
||||
"debounced reload depends on the shell signature, not tree (topic loads would re-arm it forever)",
|
||||
);
|
||||
|
||||
console.log(" PASS project tree shell race contract");
|
||||
@@ -0,0 +1,34 @@
|
||||
type Equal = (actual: unknown, expected: unknown, label: string) => void;
|
||||
|
||||
export async function runProjectTreeSortRuntimeTests(eq: Equal, projectTreeSource: string) {
|
||||
eq(
|
||||
projectTreeSource.includes("sortMode,")
|
||||
&& projectTreeSource.includes("workbenchSortModeRef.current"),
|
||||
true,
|
||||
"topic page requests carry the selected conversation sort mode",
|
||||
);
|
||||
eq(
|
||||
projectTreeSource.includes("topicLoadSeqRef.current[key] += 1")
|
||||
&& projectTreeSource.includes("void loadProjectTopics(project)"),
|
||||
true,
|
||||
"changing the conversation sort mode invalidates old pages and reloads immediately",
|
||||
);
|
||||
|
||||
const loadSequences: Record<string, number> = { "project-a": 1 };
|
||||
const staleGeneration = loadSequences["project-a"];
|
||||
let resolveStaleLoad: (() => void) | undefined;
|
||||
let staleLoadApplied = false;
|
||||
const staleLoad = new Promise<void>((resolve) => {
|
||||
resolveStaleLoad = resolve;
|
||||
}).then(() => {
|
||||
if (loadSequences["project-a"] === staleGeneration) staleLoadApplied = true;
|
||||
});
|
||||
for (const key in loadSequences) loadSequences[key] += 1;
|
||||
resolveStaleLoad?.();
|
||||
await staleLoad;
|
||||
eq(staleLoadApplied, false, "a delayed old-sort response cannot apply after sort invalidation");
|
||||
|
||||
const currentGeneration = ++loadSequences["project-a"];
|
||||
eq(loadSequences["project-a"] === currentGeneration, true, "the first request for the new sort remains current");
|
||||
eq(projectTreeSource.includes("topicPageStateRef.current = {}"), true, "sort invalidation clears pagination cursors before the new first page loads");
|
||||
}
|
||||
@@ -53,6 +53,9 @@ ok(getReasoningDisplayMode() === "hidden", "explicit new mode wins over every le
|
||||
hydrateReasoningDisplayMode("summary", true);
|
||||
ok(getReasoningDisplayMode() === "summary", "an explicit summary selection remains persisted");
|
||||
|
||||
hydrateReasoningDisplayMode("expanded", true);
|
||||
ok(getReasoningDisplayMode() === "expanded", "an explicit expanded selection remains persisted");
|
||||
|
||||
storage.set("reasonix-reasoning-summary", "0");
|
||||
ok(resolveReasoningDisplayMode("auto", false) === "legacy-collapsed", "resolver applies legacy precedence without hydration");
|
||||
hydrateReasoningDisplayMode("auto", false);
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Run: tsx src/__tests__/reasoning-display.test.ts
|
||||
|
||||
import { displayReasoningText } from "../lib/reasoningDisplay";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nreasoning display");
|
||||
|
||||
eq(
|
||||
displayReasoningText("a\nb\nc", { streaming: false, maxLines: 2 }),
|
||||
"a\nb\nc",
|
||||
"keeps completed reasoning intact",
|
||||
);
|
||||
|
||||
eq(
|
||||
displayReasoningText("a\nb\nc", { streaming: true, maxLines: 2 }),
|
||||
"...\nb\nc",
|
||||
"keeps only the tail lines while streaming",
|
||||
);
|
||||
|
||||
eq(
|
||||
displayReasoningText("abcdef", { streaming: true, maxChars: 3, maxLines: 10 }),
|
||||
"...\ndef",
|
||||
"keeps only the tail characters while streaming",
|
||||
);
|
||||
|
||||
eq(
|
||||
displayReasoningText("abcdef", { streaming: true, truncateStreaming: false, maxChars: 3 }),
|
||||
"abcdef",
|
||||
"can opt out of streaming truncation",
|
||||
);
|
||||
|
||||
eq(
|
||||
displayReasoningText("abcdefgh", { streaming: true, maxChars: 4, maxLines: 10, stableWindowChars: 3 }),
|
||||
"...\ndefgh",
|
||||
"keeps a stable append-only character window between coarse rebases",
|
||||
);
|
||||
|
||||
eq(
|
||||
displayReasoningText("a\nb\nc\nd\ne", { streaming: true, maxChars: 100, maxLines: 2, stableWindowLines: 2 }),
|
||||
"...\nc\nd\ne",
|
||||
"keeps a stable append-only line window between coarse rebases",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,36 @@
|
||||
// Run: tsx src/__tests__/rewind-fork-routing.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dispatchPartialRewindNotice, partialRewindNotice, rewindFailureDetail, rewindOutcome } from "../lib/rewindCommit";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8");
|
||||
|
||||
assert.match(appSource, /const targetTabId = outcome\.tabId \|\| sourceTabId/);
|
||||
assert.match(appSource, /undoTabId: sourceTabId/);
|
||||
assert.match(appSource, /const outcome = await rewindForTabDetailed\(sourceTabId, turn, "conversation"\)/);
|
||||
assert.match(appSource, /sendToTab\(targetTabId, next, submit, original\)/);
|
||||
assert.match(controllerSource, /settleRewindTarget\(result, tab => adoptReturnedTab\(tab, sourceTabId, forkNavigationSeq, "tab\.rewind"\)/);
|
||||
assert.match(controllerSource, /partialNotice = partialRewindNotice\(result\)/);
|
||||
assert.match(controllerSource, /dispatchPartialRewindNotice\(partialNotice, sourceTabId, outcome\.tabId,/);
|
||||
assert.ok(
|
||||
controllerSource.indexOf('await loadSessionDataForTab(sourceTabId, true, "rewind")')
|
||||
< controllerSource.indexOf("dispatchPartialRewindNotice(partialNotice, sourceTabId, outcome.tabId,"),
|
||||
"partial rewind warning must be appended after history reload",
|
||||
);
|
||||
assert.equal(partialRewindNotice({ ok: true }), "");
|
||||
assert.equal(
|
||||
partialRewindNotice({ ok: true, partial: true, conflicts: ["a.txt changed", "b.txt missing"] }),
|
||||
"The conversation was forked, but code could not be fully restored. a.txt changed; b.txt missing",
|
||||
);
|
||||
assert.equal(rewindFailureDetail({ ok: false, conflicts: ["a.txt changed"] }), "a.txt changed");
|
||||
assert.deepEqual(rewindOutcome({ ok: true, tabId: "fork-tab" }), { ok: true, tabId: "fork-tab" });
|
||||
const notices: Array<[string, string]> = [];
|
||||
dispatchPartialRewindNotice("partial", "source", "fork", (tabId, text) => notices.push([tabId, text]));
|
||||
assert.deepEqual(notices, [["source", "partial"], ["fork", "partial"]]);
|
||||
|
||||
console.log("rewind fork routing contract passed");
|
||||
@@ -0,0 +1,265 @@
|
||||
// Run: tsx src/__tests__/running-tab-history-hydration.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
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 flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
const workspaceRoot = `/repo/${id}`;
|
||||
return {
|
||||
id,
|
||||
scope: "project",
|
||||
workspaceRoot,
|
||||
workspaceName: id,
|
||||
workspacePath: workspaceRoot,
|
||||
gitBranch: "main",
|
||||
topicId: `topic-${id}`,
|
||||
topicTitle: id,
|
||||
sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
|
||||
label: `model-${id}`,
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: false,
|
||||
cwd: workspaceRoot,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
sessionPath: tab.sessionPath,
|
||||
sessionRevision: tab.sessionRevision,
|
||||
sessionDigest: tab.sessionDigest,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(content: string): HistoryMessage {
|
||||
return { role: "user", content };
|
||||
}
|
||||
|
||||
console.log("\nrunning tab history hydration");
|
||||
|
||||
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.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
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);
|
||||
|
||||
const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
|
||||
const tabA = tabMeta("tab-a", { active: true });
|
||||
// The session the user clicks into: it has been running for a while, so the
|
||||
// backend reports it running before hydration finishes.
|
||||
const tabR = tabMeta("tab-r");
|
||||
const tabsById = new Map([tabA, tabR].map((tab) => [tab.id, tab]));
|
||||
const runningTabs = new Set<string>(["tab-r"]);
|
||||
let backendActiveId = "tab-a";
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
|
||||
function currentTabs(): TabMeta[] {
|
||||
return Array.from(tabsById.values()).map((tab) => {
|
||||
const running = runningTabs.has(tab.id);
|
||||
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
|
||||
});
|
||||
}
|
||||
|
||||
function historyFor(tabID: string): HistoryMessage[] {
|
||||
if (tabID === "tab-r") return [userMessage("history R 1"), userMessage("history R 2")];
|
||||
if (tabID === "tab-s") return [userMessage("history S")];
|
||||
return [userMessage("cached A")];
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
|
||||
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => currentTabs(),
|
||||
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (tabID: string) => historyFor(tabID),
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
const messages = historyFor(tabID);
|
||||
const turns = messages.filter((message) => message.role === "user").length;
|
||||
return { messages, startTurn: 0, endTurn: turns, totalTurns: turns, hasOlder: false };
|
||||
},
|
||||
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) =>
|
||||
historySliceFromMessages(tabID, historyFor(tabID), req),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
CancelTab: async (tabID: string) => {
|
||||
runningTabs.delete(tabID);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
"initial active tab hydrated",
|
||||
() => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"),
|
||||
);
|
||||
|
||||
// Clicking a session that the backend already reports as running: the tab has
|
||||
// no cached transcript, so hydration is the only thing that can put the
|
||||
// conversation on screen.
|
||||
await act(async () => {
|
||||
void controller?.switchTab("tab-r", { ...tabR, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
await settle();
|
||||
|
||||
eq(controller?.activeTabId, "tab-r", "switching to the running session activates its tab");
|
||||
eq(controller?.state.running, true, "the running session keeps its live status");
|
||||
ok(
|
||||
controller?.state.items.some((item) => item.kind === "user" && item.text === "history R 1") ?? false,
|
||||
"an uncached running session still hydrates its persisted history",
|
||||
);
|
||||
ok(
|
||||
controller?.state.items.some((item) => item.kind === "user" && item.text === "history R 2") ?? false,
|
||||
"the whole history page lands, not just the newest turn",
|
||||
);
|
||||
|
||||
// Second door: a session that is mid-stream when it is opened. Its live text
|
||||
// makes the transcript look cached, which used to skip the history fetch
|
||||
// outright and leave the streaming turn floating over an empty transcript.
|
||||
tabsById.set("tab-s", tabMeta("tab-s"));
|
||||
runningTabs.add("tab-s");
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) {
|
||||
handler({ kind: "turn_started", tabId: "tab-s" } as WireEvent);
|
||||
handler({ kind: "text", tabId: "tab-s", text: "streaming S" } as WireEvent);
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
void controller?.switchTab("tab-s", { ...tabsById.get("tab-s")!, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
await settle();
|
||||
|
||||
eq(controller?.activeTabId, "tab-s", "switching to the streaming session activates its tab");
|
||||
ok(
|
||||
controller?.state.items.some((item) => item.kind === "user" && item.text === "history S") ?? false,
|
||||
"a mid-stream session still fetches and installs its history",
|
||||
);
|
||||
ok(controller?.state.live !== undefined, "installing history leaves the live stream alone");
|
||||
eq(controller?.state.items[0]?.kind, "user", "the persisted page lands in front of the streaming turn");
|
||||
eq(controller?.state.items[controller.state.items.length - 1]?.kind, "assistant", "the streaming turn stays at the tail");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Run: tsx src/__tests__/search-footnotes-render.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { AssistantMessage } from "../components/Message";
|
||||
import { hydrateReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsearch footnote render");
|
||||
|
||||
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, language: "en-US" } });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
hydrateReasoningDisplayMode("hidden", true);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<AssistantMessage
|
||||
item={{
|
||||
kind: "assistant",
|
||||
id: "a1",
|
||||
text: "answer only",
|
||||
reasoning: "",
|
||||
streaming: false,
|
||||
searchSources: [{ title: "新闻本文", url: "https://example.com/a" }],
|
||||
}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const body = document.querySelector(".msg__body");
|
||||
const footnotes = document.querySelector(".msg-search-sources");
|
||||
const title = footnotes?.querySelector("strong");
|
||||
const link = footnotes?.querySelector("a");
|
||||
ok(Boolean(body?.textContent?.includes("answer only")), "answer body keeps the model text");
|
||||
ok(Boolean(footnotes), "footnotes render under the answer");
|
||||
ok(title?.textContent === "新闻本文", "footnote shows the result title");
|
||||
ok(Boolean(link), "footnote title list keeps a rendered link");
|
||||
ok(!(document.querySelector(".msg__body > .md")?.textContent ?? "").includes("新闻本文"), "answer markdown does not include the search title");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`\n${passed} passed\n`);
|
||||
@@ -0,0 +1,70 @@
|
||||
// Run: tsx src/__tests__/search-sources.test.ts
|
||||
|
||||
import {
|
||||
formatSearchFootnotesMarkdown,
|
||||
mergeSearchSources,
|
||||
parseSearchSources,
|
||||
searchSourcesFromHistory,
|
||||
} from "../lib/searchSources";
|
||||
import { historyMessagesToItems, initialState, reducer } from "../lib/useController";
|
||||
import type { HistoryMessage, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsearch footnotes");
|
||||
|
||||
eq(
|
||||
formatSearchFootnotesMarkdown([{ title: "Change Log", url: "https://api-docs.deepseek.com/updates/" }, { title: "No URL" }]),
|
||||
"\n- **Change Log**\n <https://api-docs.deepseek.com/updates/>\n- **No URL**\n",
|
||||
"footnotes reuse the title-and-autolink list",
|
||||
);
|
||||
eq(formatSearchFootnotesMarkdown([{ title: "bad", url: "javascript:alert(1)" }]), "\n- **bad**\n", "unsafe URLs are dropped");
|
||||
eq(parseSearchSources("新闻本文\nhttps://example.com/a\nNo URL").length, 2, "output parser keeps title-only hits");
|
||||
eq(parseSearchSources("新闻本文\nhttps://example.com/a")[0]?.title, "新闻本文", "output parser keeps the title");
|
||||
eq(mergeSearchSources([{ title: "A", url: "https://a.example" }], [{ title: "A", url: "https://a.example" }]).length, 1, "duplicate hits collapse");
|
||||
|
||||
const history = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
content: "answer only",
|
||||
serverSearch: [{
|
||||
id: "s1",
|
||||
query: "bitcoin",
|
||||
results: [{ title: "新闻本文", url: "https://example.com/a" }],
|
||||
}],
|
||||
}] as HistoryMessage[], "h-").items;
|
||||
const answer = history.find((item) => item.kind === "assistant");
|
||||
eq(answer?.kind === "assistant" ? answer.text : "", "answer only", "answer text stays model-only");
|
||||
eq(answer?.kind === "assistant" ? answer.searchSources?.[0]?.title : "", "新闻本文", "history hydrates footnotes on the answer");
|
||||
|
||||
function ev(s: typeof initialState, e: WireEvent) {
|
||||
return reducer(s, { type: "event", e });
|
||||
}
|
||||
|
||||
let live = ev(initialState, { kind: "turn_started" });
|
||||
live = ev(live, {
|
||||
kind: "tool_result",
|
||||
tool: { id: "s1", name: "web_search", readOnly: true, output: "新闻本文\nhttps://example.com/a" },
|
||||
} as WireEvent);
|
||||
live = ev(live, { kind: "text", text: "answer only" });
|
||||
live = ev(live, { kind: "message", text: "answer only" });
|
||||
const liveAnswer = live.items.find((item) => item.kind === "assistant");
|
||||
eq(liveAnswer?.kind === "assistant" ? liveAnswer.text : "", "answer only", "live answer stays model-only");
|
||||
eq(liveAnswer?.kind === "assistant" ? liveAnswer.searchSources?.[0]?.title : "", "新闻本文", "live tool result attaches footnotes to the answer");
|
||||
eq(searchSourcesFromHistory([{ results: [{ title: "A" }] }])[0]?.title, "A", "history helper reads structured hits");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`\n${passed} passed\n`);
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { acceptsRuntimeEventEpoch, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
|
||||
import { acceptsRuntimeEventEpoch, historyMessagesToItems, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
|
||||
import { continueDelivery } from "../lib/deliveryContinue";
|
||||
import {
|
||||
activateGoalAndSubmit,
|
||||
@@ -181,7 +181,7 @@ const readinessNotice = readinessState.items[readinessState.items.length - 1];
|
||||
eq(readinessNotice.kind, "notice", "final readiness appends a notice");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.level, "info", "final readiness uses informational severity");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.variant, "delivery", "final readiness uses the delivery status treatment");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.title, "Delivery checks are not complete", "final readiness uses localized product copy");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.title, "Task completion checks are not complete", "final readiness uses localized product copy");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: verification, change review", "structured requirements produce localized detail");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
|
||||
const readinessUser = readinessState.items.find((it) => it.kind === "user");
|
||||
@@ -189,6 +189,17 @@ eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "fina
|
||||
eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running");
|
||||
eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt");
|
||||
|
||||
const reloadedReadiness = historyMessagesToItems([{
|
||||
role: "notice",
|
||||
content: "Task status needs one more check; continue the remaining work.",
|
||||
code: "final_readiness",
|
||||
level: "info",
|
||||
pending: true,
|
||||
readiness: { attempts: 1, missing: ["verification"] },
|
||||
}], "h").items[0];
|
||||
eq(reloadedReadiness.kind === "notice" && reloadedReadiness.action, "continue_delivery", "reloaded readiness metadata restores the explicit action");
|
||||
eq(reloadedReadiness.kind === "notice" && reloadedReadiness.detail, "Still needed: verification", "reloaded readiness metadata restores structured detail");
|
||||
|
||||
const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, submissionId: "recovery-submit", deliveryRecovery: true });
|
||||
const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done", submissionId: "recovery-submit" } as WireEvent });
|
||||
eq(recovered.items.some((it) => it.kind === "notice" && it.variant === "delivery"), false, "successful explicit recovery removes the stale delivery card");
|
||||
@@ -282,19 +293,14 @@ eq(
|
||||
"collaboration mode changes always reconcile the remembered plan restore intent",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("runtimeTransitionTabsRef.current.has(tabId)"),
|
||||
!appSource.includes("runtimeTransitionTabsRef") && !appSource.includes("pending.tokenMode"),
|
||||
true,
|
||||
"runtime profile transitions reject rapid duplicate switches for one tab",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("delete pending.tokenMode") && appSource.includes("tokenMode: previous"),
|
||||
true,
|
||||
"failed runtime profile transitions roll back the optimistic token mode",
|
||||
"execution-mode switch state is gone from the app shell",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("!state.backendActivationPending &&") && appSource.includes("!runtimeTransitioning"),
|
||||
true,
|
||||
"runtime profile transitions keep submit behind the controller-ready gate",
|
||||
"composer submit stays behind the controller-ready gate",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("activateGoalAndSubmitOnTab({") &&
|
||||
|
||||
@@ -133,7 +133,7 @@ ok(
|
||||
settingsSource.includes('settings.displayMode') &&
|
||||
settingsSource.includes('["standard", "compact"]') &&
|
||||
settingsSource.includes('settings.reasoningDisplay') &&
|
||||
settingsSource.includes('["hidden", "summary", "auto"]') &&
|
||||
settingsSource.includes('["hidden", "summary", "auto", "expanded"]') &&
|
||||
settingsSource.includes('settings.processFold') &&
|
||||
settingsSource.includes('["auto", "expanded"]') &&
|
||||
settingsSource.includes('setProcessFoldPreference(pref)') &&
|
||||
@@ -149,6 +149,7 @@ ok(
|
||||
source.includes('"settings.reasoningDisplay.hidden"') &&
|
||||
source.includes('"settings.reasoningDisplay.summary"') &&
|
||||
source.includes('"settings.reasoningDisplay.auto"') &&
|
||||
source.includes('"settings.reasoningDisplay.expanded"') &&
|
||||
source.includes('"settings.processFold"'),
|
||||
),
|
||||
"conversation-content display group labels are localized in every supported locale",
|
||||
|
||||
@@ -116,6 +116,50 @@ console.log("\nsubagent progress card");
|
||||
hydrateReasoningDisplayMode("summary", true);
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
hydrateReasoningDisplayMode("expanded", true);
|
||||
const running = makeItem("reasoning");
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: running })));
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await flushTimers();
|
||||
if (document.querySelector(".tool__subagent-preview .md")) break;
|
||||
}
|
||||
});
|
||||
ok(!!document.querySelector(".tool__subagent-preview .md"), "expanded mode opens live sub-agent reasoning");
|
||||
|
||||
const completed = makeItem("completed", { durationMs: 42_000 });
|
||||
completed.id = running.id;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: completed })));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(!!document.querySelector(".tool__subagent-preview .md"), "expanded mode keeps completed sub-agent reasoning visible");
|
||||
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".tool__head")?.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(!document.querySelector(".tool__subagent-preview"), "manual card collapse still wins in expanded mode");
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { key: "completed-history", item: completed })));
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await flushTimers();
|
||||
if (document.querySelector(".tool__subagent-preview .md")) break;
|
||||
}
|
||||
});
|
||||
ok(!!document.querySelector(".tool__subagent-preview .md"), "expanded mode opens completed sub-agent reasoning restored from history");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
hydrateReasoningDisplayMode("summary", true);
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
|
||||
@@ -628,7 +628,7 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
const tabCUser = controller?.state.items.find((item) => item.kind === "user" && item.text === "streaming C");
|
||||
eq(tabCUser?.kind === "user" && tabCUser.submissionId, tabCSubmissionId, "Wails receives the same opaque correlation stored on the optimistic user");
|
||||
ok(Boolean(tabCSubmissionId) && tabCSubmissionId !== tabCUser?.id, "opaque submission correlation is distinct from the render item id");
|
||||
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
|
||||
ok(historyCalls.includes("tab-c"), "a running tab with no history page of its own still hydrates one");
|
||||
await act(async () => {
|
||||
submitTabCGate.resolve();
|
||||
await submitTabCGate.promise;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { subjectOf } from "../lib/tools";
|
||||
import { toolGroupKind } from "../components/ToolGroup";
|
||||
import { historyMessagesToItems, isReadOnlyTool } from "../lib/useController";
|
||||
import { historyMessagesToItems, isBatchedReadOnlyTool, isReadOnlyTool } from "../lib/useController";
|
||||
import type { HistoryMessage } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
@@ -40,6 +40,11 @@ eq(
|
||||
"modify",
|
||||
"resolved writer MCP stays in the modify group",
|
||||
);
|
||||
eq(
|
||||
toolGroupKind({ kind: "tool", id: "s1", name: "web_search", args: `{"query":"bitcoin"}`, readOnly: true, status: "done" }),
|
||||
null,
|
||||
"provider web search is not grouped with explore tools",
|
||||
);
|
||||
|
||||
const history = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
@@ -73,10 +78,29 @@ eq(history[1]?.kind === "tool" ? history[1].readOnly : false, true, "resolved re
|
||||
eq(history[2]?.kind === "tool" ? history[2].readOnly : true, false, "resolved writer history restores writer classification");
|
||||
eq(history[2]?.kind === "tool" ? history[2].resolvedName : "", "mcp__db__write", "history keeps resolved MCP target");
|
||||
eq(isReadOnlyTool("read_file"), true, "legacy built-in reader remains read-only");
|
||||
eq(isReadOnlyTool("web_search"), true, "provider web search cards are read-only");
|
||||
eq(isBatchedReadOnlyTool("web_search", true), false, "provider web search stays a standalone card");
|
||||
eq(isBatchedReadOnlyTool("read_file", true), true, "ordinary readers still batch");
|
||||
eq(isReadOnlyTool("grep"), true, "legacy search reader remains read-only");
|
||||
eq(isReadOnlyTool("use_capability"), false, "legacy unresolved proxy remains fail-closed");
|
||||
eq(isReadOnlyTool("unknown_tool"), false, "unknown history tool remains fail-closed");
|
||||
|
||||
const searchHistory = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
content: "answer only",
|
||||
serverSearch: [{
|
||||
id: "s1",
|
||||
query: "bitcoin",
|
||||
results: [{ title: "新闻本文", url: "https://example.com/a" }],
|
||||
}],
|
||||
}] as HistoryMessage[], "h-").items;
|
||||
eq(searchHistory[0]?.kind, "tool", "server search hydrates as a tool card before the answer");
|
||||
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].name : "", "web_search", "server search card uses web_search");
|
||||
eq(searchHistory[0]?.kind === "tool" ? subjectOf("web_search", searchHistory[0].args) : "", "bitcoin", "search card subject is the query");
|
||||
eq(searchHistory[1]?.kind, "assistant", "model text stays a separate assistant item");
|
||||
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].text : "", "answer only", "answer text does not include search results");
|
||||
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].searchSources?.[0]?.title : "", "新闻本文", "answer hydrates search footnotes");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -134,8 +134,9 @@ const checkpoints: CheckpointMeta[] = [];
|
||||
const tabA = tabMeta("tab-a", { active: true });
|
||||
const tabB = tabMeta("tab-b");
|
||||
const tabC = tabMeta("tab-c");
|
||||
const tabR = tabMeta("tab-r", { running: true, cancellable: true });
|
||||
let backendActiveId = "tab-a";
|
||||
const tabsById = new Map([tabA, tabB, tabC].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabR].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
const topicActivationHandlers: Array<(e: TopicActivationEvent) => void> = [];
|
||||
@@ -151,6 +152,7 @@ function emitActivation(event: TopicActivationEvent): void {
|
||||
}
|
||||
|
||||
function historyFor(tabID: string): HistoryMessage[] {
|
||||
if (tabID === "tab-r") return [{ role: "user", content: "帮我查询,现在我链接的 deep" }];
|
||||
return [{ role: "user", content: `history ${tabID}` }];
|
||||
}
|
||||
|
||||
@@ -297,6 +299,55 @@ await act(async () => {
|
||||
});
|
||||
eq(controller?.state.meta?.gitBranch, "feature/x", "tab:meta for a different session binding is discarded");
|
||||
|
||||
// ── switch away from a thinking session and back: keep its transcript ──────
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabR.workspaceRoot, tabR.topicId ?? "");
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-r") ?? "", tabId: "tab-r", phase: "ready" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("running session hydrates on first open", () =>
|
||||
controller?.activeTabId === "tab-r" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "帮我查询,现在我链接的 deep") ?? false),
|
||||
);
|
||||
eq(controller?.state.running, true, "reattached thinking session stays marked running");
|
||||
eq(controller?.state.hydrating, false, "running-session hydrate settles instead of leaving Welcome");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabB.workspaceRoot, tabB.topicId ?? "");
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-b") ?? "", tabId: "tab-b", phase: "ready" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("idle session replaces the thinking surface", () => hasHistory("tab-b") && controller?.state.hydrating === false);
|
||||
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabR.workspaceRoot, tabR.topicId ?? "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-r", "switch-back selects the thinking session");
|
||||
eq(
|
||||
controller?.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history tab-b") ?? false,
|
||||
false,
|
||||
"switch-back does not present the other session as a hydration placeholder",
|
||||
);
|
||||
await act(async () => {
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-r") ?? "", tabId: "tab-r", phase: "ready" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("switch-back restores the thinking transcript", () =>
|
||||
controller?.activeTabId === "tab-r" &&
|
||||
controller.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "帮我查询,现在我链接的 deep") ?? false),
|
||||
);
|
||||
eq(controller?.state.running, true, "switch-back keeps the composer in the live turn");
|
||||
eq(controller?.state.items.some((item) => item.kind === "user" && item.text === "history tab-b") ?? false, false, "switch-back does not keep the other session as the visible transcript");
|
||||
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "switch-back clears the foreign placeholder after live history lands");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import type { ReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
export interface TranscriptHarnessOptions {
|
||||
@@ -18,6 +19,8 @@ export interface TranscriptHarnessOptions {
|
||||
rowHeight?: number;
|
||||
/** Extra localStorage seed values (display mode, fold preference, …). */
|
||||
storage?: Record<string, string>;
|
||||
/** Authoritative reasoning mode to hydrate before Transcript is imported. */
|
||||
reasoningDisplayMode?: ReasoningDisplayMode;
|
||||
}
|
||||
|
||||
export interface TranscriptHarness {
|
||||
@@ -53,6 +56,7 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.WheelEvent = dom.window.WheelEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window) as typeof getComputedStyle;
|
||||
@@ -185,6 +189,12 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
if (options.reasoningDisplayMode) {
|
||||
const preference = await server.ssrLoadModule("/src/lib/reasoningDisplayPreference.ts") as {
|
||||
hydrateReasoningDisplayMode: (mode: unknown, explicit: boolean) => void;
|
||||
};
|
||||
preference.hydrateReasoningDisplayMode(options.reasoningDisplayMode, true);
|
||||
}
|
||||
const { TranscriptTestSurface } = await server.ssrLoadModule("/src/__tests__/transcript-test-surface.tsx");
|
||||
const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx");
|
||||
const TranscriptComponent = TranscriptTestSurface as React.ComponentType<Record<string, unknown>>;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Run: tsx src/__tests__/transcript-layout-recovery.test.ts
|
||||
|
||||
import { initialState, reducer, type Item } from "../lib/useController";
|
||||
import { estimateTranscriptRowSize, type TranscriptRow } from "../lib/transcriptRows";
|
||||
import {
|
||||
chooseTranscriptLayoutAnchor,
|
||||
transcriptAnchorInitialLocation,
|
||||
transcriptViewportIsBlank,
|
||||
} from "../lib/transcriptVirtuosoRecovery";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(condition: unknown, label: string) {
|
||||
if (condition) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript layout recovery");
|
||||
|
||||
const preview: Item = { kind: "assistant", id: "he:answer", text: "preview", reasoning: "", streaming: false };
|
||||
const resolved: Item = { ...preview, text: "resolved ".repeat(2_000) };
|
||||
const patched = reducer(
|
||||
{ ...initialState, items: [preview] },
|
||||
{ type: "history_items_patch", patches: { [preview.id]: resolved } },
|
||||
);
|
||||
check(patched.historyLayoutRevision === 1, "resolved history content invalidates the transcript layout");
|
||||
const ignored = reducer(
|
||||
patched,
|
||||
{ type: "history_items_patch", patches: { missing: resolved } },
|
||||
);
|
||||
check(ignored === patched, "an unrelated content patch does not invalidate the transcript layout");
|
||||
|
||||
const visibleAnchor = chooseTranscriptLayoutAnchor(
|
||||
[
|
||||
{ rowKey: "before", top: -240, bottom: -40 },
|
||||
{ rowKey: "visible", top: 80, bottom: 240 },
|
||||
{ rowKey: "after", top: 260, bottom: 420 },
|
||||
],
|
||||
{ top: 50, bottom: 350 },
|
||||
false,
|
||||
);
|
||||
check(
|
||||
visibleAnchor?.mode === "manual" && visibleAnchor.rowKey === "visible" && visibleAnchor.offset === 30,
|
||||
"manual recovery records the first visible logical row and pixel offset",
|
||||
);
|
||||
|
||||
const nearestAnchor = chooseTranscriptLayoutAnchor(
|
||||
[
|
||||
{ rowKey: "above", top: -400, bottom: -200 },
|
||||
{ rowKey: "below", top: 900, bottom: 1_100 },
|
||||
],
|
||||
{ top: 0, bottom: 500 },
|
||||
false,
|
||||
);
|
||||
check(
|
||||
nearestAnchor?.mode === "manual" && nearestAnchor.rowKey === "above" && nearestAnchor.offset === 0,
|
||||
"blank-viewport recovery snaps the nearest logical row into view",
|
||||
);
|
||||
|
||||
const location = transcriptAnchorInitialLocation(
|
||||
visibleAnchor,
|
||||
new Map([["visible", 7]]),
|
||||
1_000_000,
|
||||
);
|
||||
check(
|
||||
typeof location === "object" && location.index === 1_000_007 && location.align === "start" && location.offset === 30,
|
||||
"manual anchor becomes an absolute Virtuoso restore location",
|
||||
);
|
||||
const tailLocation = transcriptAnchorInitialLocation({ mode: "tail" }, new Map(), 1_000_000);
|
||||
check(
|
||||
typeof tailLocation === "object" && tailLocation.index === "LAST" && tailLocation.align === "end",
|
||||
"tail recovery remains pinned to the last row",
|
||||
);
|
||||
|
||||
check(
|
||||
transcriptViewportIsBlank(
|
||||
[{ rowKey: "above", top: -400, bottom: -200 }, { rowKey: "below", top: 600, bottom: 800 }],
|
||||
{ top: 0, bottom: 500 },
|
||||
true,
|
||||
),
|
||||
"a scrollable viewport with mounted rows only outside its bounds is blank",
|
||||
);
|
||||
check(
|
||||
!transcriptViewportIsBlank([{ rowKey: "visible", top: 100, bottom: 200 }], { top: 0, bottom: 500 }, true),
|
||||
"a row intersecting the viewport is not blank",
|
||||
);
|
||||
|
||||
const shortRow: TranscriptRow = { kind: "answer", key: "a:short", item: preview };
|
||||
const longRow: TranscriptRow = { kind: "answer", key: "a:long", item: resolved };
|
||||
check(
|
||||
estimateTranscriptRowSize(longRow) > estimateTranscriptRowSize(shortRow) * 4,
|
||||
"resolved long-form answers receive a content-aware height estimate",
|
||||
);
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript layout recovery test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript layout recovery tests passed.`);
|
||||
@@ -0,0 +1,134 @@
|
||||
// Run: tsx src/__tests__/transcript-live-split.test.ts
|
||||
|
||||
import type { Item } from "../lib/useController";
|
||||
import {
|
||||
buildTranscriptRows,
|
||||
buildTurnModels,
|
||||
EMPTY_FOLDS,
|
||||
splitTranscriptLiveRows,
|
||||
userRowKey,
|
||||
type BuildRowsOptions,
|
||||
type TranscriptLiveFlags,
|
||||
} from "../lib/transcriptRows";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(condition: unknown, label: string) {
|
||||
if (condition) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript live-turn split");
|
||||
|
||||
const user = (id: string): Item => ({ kind: "user", id, text: `q-${id}` });
|
||||
const assistant = (id: string, over: Partial<Extract<Item, { kind: "assistant" }>> = {}): Item => ({
|
||||
kind: "assistant",
|
||||
id,
|
||||
text: "",
|
||||
reasoning: "",
|
||||
streaming: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const options: BuildRowsOptions = {
|
||||
folds: EMPTY_FOLDS,
|
||||
foldPreference: "auto",
|
||||
hasOlderHistory: false,
|
||||
creationMode: false,
|
||||
turnForUser: (item) => (item.id === "u1" ? 0 : item.id === "u2" ? 1 : undefined),
|
||||
};
|
||||
|
||||
function rowsFor(items: Item[], live?: TranscriptLiveFlags, running = false) {
|
||||
const models = buildTurnModels(items, live, running, false);
|
||||
const rows = buildTranscriptRows(models, options);
|
||||
return { models, rows };
|
||||
}
|
||||
const keys = (rows: readonly { key: string }[]) => rows.map((row) => row.key).join(",");
|
||||
|
||||
// ── Settled transcript: everything is history ────────────────────────────────
|
||||
{
|
||||
const { models, rows } = rowsFor([
|
||||
user("u1"),
|
||||
assistant("a1", { text: "hello", reasoning: "thought", reasoningComplete: true }),
|
||||
]);
|
||||
const split = splitTranscriptLiveRows(models, rows, undefined, false);
|
||||
check(!split.liveActive, "settled transcript has no live turn");
|
||||
check(split.liveRows.length === 0, "settled transcript contributes no live rows");
|
||||
check(split.historyRows.length === rows.length, "settled transcript keeps every row in history");
|
||||
}
|
||||
|
||||
// ── Running turn: rows after the user message move to the live region ────────
|
||||
{
|
||||
const live: TranscriptLiveFlags = { id: "a2", hasAnswerText: false, hasReasoning: true };
|
||||
const { models, rows } = rowsFor([user("u1"), assistant("a2", { streaming: true })], live, true);
|
||||
check(keys(rows) === `${userRowKey("u1")},ph:a2,r:a2`, `running turn rows look as expected (got ${keys(rows)})`);
|
||||
const split = splitTranscriptLiveRows(models, rows, "a2", true);
|
||||
check(split.liveActive, "running turn marks the live region active");
|
||||
check(keys(split.historyRows) === userRowKey("u1"), "the active turn's user message stays in history");
|
||||
check(keys(split.liveRows) === "ph:a2,r:a2", "the active turn's process rows render in the live region");
|
||||
}
|
||||
|
||||
// ── A later user message stays in visual order behind the streaming turn ─────
|
||||
{
|
||||
const live: TranscriptLiveFlags = { id: "a1", hasAnswerText: true, hasReasoning: true };
|
||||
const { models, rows } = rowsFor(
|
||||
[user("u1"), assistant("a1", { streaming: true }), user("u2")],
|
||||
live,
|
||||
true,
|
||||
);
|
||||
const split = splitTranscriptLiveRows(models, rows, "a1", true);
|
||||
check(keys(split.historyRows) === userRowKey("u1"), "only the streaming turn's user row stays in history");
|
||||
check(
|
||||
keys(split.liveRows) === `ph:a1,r:a1,a:a1,${userRowKey("u2")}`,
|
||||
`rows after the live turn keep their order in the live region (got ${keys(split.liveRows)})`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Running with no assistant item yet: status-only live region ──────────────
|
||||
{
|
||||
const { models, rows } = rowsFor([user("u1")], undefined, true);
|
||||
const split = splitTranscriptLiveRows(models, rows, undefined, true);
|
||||
check(split.liveActive, "a fresh turn activates the live region before the first item");
|
||||
check(split.liveRows.length === 0, "a fresh turn has no live rows yet");
|
||||
check(keys(split.historyRows) === userRowKey("u1"), "a fresh turn keeps its user message in history");
|
||||
}
|
||||
|
||||
// ── Lingering live stream without running still splits ───────────────────────
|
||||
{
|
||||
const live: TranscriptLiveFlags = { id: "a1", hasAnswerText: true, hasReasoning: false };
|
||||
const { models, rows } = rowsFor([user("u1"), assistant("a1", { streaming: true })], live, false);
|
||||
const split = splitTranscriptLiveRows(models, rows, "a1", false);
|
||||
check(split.liveActive, "lingering live stream keeps the live region active");
|
||||
check(keys(split.historyRows) === userRowKey("u1"), "lingering live stream keeps the user row in history");
|
||||
check(keys(split.liveRows) === "a:a1", "lingering live answer renders in the live region");
|
||||
}
|
||||
|
||||
// ── Unknown live id without running: no split ────────────────────────────────
|
||||
{
|
||||
const { models, rows } = rowsFor([user("u1"), assistant("a1", { text: "done", reasoningComplete: true })]);
|
||||
const split = splitTranscriptLiveRows(models, rows, "missing", false);
|
||||
check(!split.liveActive && split.liveRows.length === 0, "an unknown live id splits nothing");
|
||||
check(split.historyRows.length === rows.length, "an unknown live id keeps all rows in history");
|
||||
}
|
||||
|
||||
// ── Active prelude turn (no user message): split at the turn's first row ─────
|
||||
{
|
||||
const tool: Item = { kind: "tool", id: "t1", name: "read_file", args: "{}", readOnly: true, status: "running" };
|
||||
const { models, rows } = rowsFor([tool], undefined, true);
|
||||
const split = splitTranscriptLiveRows(models, rows, undefined, true);
|
||||
check(split.liveActive, "a running prelude turn activates the live region");
|
||||
check(split.historyRows.length === 0, "a prelude turn leaves no history rows");
|
||||
check(keys(split.liveRows) === keys(rows), "a prelude turn's rows all render in the live region");
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript live-split test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript live-split tests passed.`);
|
||||
@@ -83,7 +83,7 @@ const warningTurn: Item[] = [
|
||||
id: "n-delivery",
|
||||
level: "info",
|
||||
variant: "delivery",
|
||||
title: "Delivery checks are not complete",
|
||||
title: "Task completion checks are not complete",
|
||||
text: "The response was generated, but verification and review still need to be completed.",
|
||||
detail: "final-answer readiness failed 3 times: missing verification",
|
||||
action: "continue_delivery",
|
||||
@@ -246,7 +246,102 @@ const warningTurn: Item[] = [
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: summaries disabled ─────────────────────────────────────────────
|
||||
// ── Phase 3: expanded reasoning pins its parent process fold ─────────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ reasoningDisplayMode: "expanded" });
|
||||
const container = harness.container;
|
||||
try {
|
||||
const runningItems: Item[] = [
|
||||
{ kind: "user", id: "u-expanded-parent", text: "inspect" },
|
||||
{
|
||||
kind: "assistant",
|
||||
id: "a-expanded-parent",
|
||||
text: "done",
|
||||
reasoning: "completed reasoning should remain visible",
|
||||
streaming: true,
|
||||
reasoningComplete: false,
|
||||
workDurationMs: 1_000,
|
||||
},
|
||||
];
|
||||
await render(harness, runningItems, { running: true });
|
||||
await harness.waitFor(
|
||||
() => Boolean(
|
||||
container.querySelector(".turn-collapse--open")
|
||||
&& container.querySelector(".turn-collapse__reasoning-phase--open .reasoning__stream-text"),
|
||||
),
|
||||
"expanded live reasoning",
|
||||
);
|
||||
ok(Boolean(container.querySelector(".turn-collapse--open")), "expanded reasoning starts with its running parent fold open");
|
||||
ok(
|
||||
container.querySelector(".turn-collapse__reasoning-phase--open .reasoning__stream-text")?.textContent?.includes("completed reasoning should remain visible") ?? false,
|
||||
"expanded running reasoning streams its full body as plain text",
|
||||
);
|
||||
|
||||
const completedItems: Item[] = runningItems.map((item) => item.kind === "assistant"
|
||||
? { ...item, streaming: false, reasoningComplete: true }
|
||||
: item);
|
||||
await render(harness, completedItems, { running: false });
|
||||
await harness.waitFor(
|
||||
() => Boolean(
|
||||
container.querySelector(".turn-collapse--open")
|
||||
&& container.querySelector(".turn-collapse__reasoning-phase--open .md"),
|
||||
),
|
||||
"expanded completed reasoning",
|
||||
);
|
||||
ok(Boolean(container.querySelector(".turn-collapse--open")), "expanded reasoning keeps its parent fold open after completion");
|
||||
ok(Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .md")), "expanded completed reasoning remains visible");
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>(".turn-collapse > .reasoning__head")?.click();
|
||||
});
|
||||
await harness.flush();
|
||||
ok(!container.querySelector(".turn-collapse--open"), "manual parent collapse still wins in expanded reasoning mode");
|
||||
ok(!container.querySelector(".turn-collapse__body"), "manual parent collapse unmounts the expanded reasoning body");
|
||||
|
||||
await render(harness, completedItems, { running: false });
|
||||
ok(!container.querySelector(".turn-collapse--open"), "completed rerenders preserve a manual parent collapse");
|
||||
await harness.settle();
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// A completed reasoning fold restored from history has no live transition to
|
||||
// open it, so the expanded-mode default must pin it on first reconciliation.
|
||||
{
|
||||
const harness = await createTranscriptHarness({ reasoningDisplayMode: "expanded" });
|
||||
const container = harness.container;
|
||||
try {
|
||||
await render(harness, [
|
||||
{ kind: "user", id: "u-expanded-history", text: "inspect" },
|
||||
{
|
||||
kind: "assistant",
|
||||
id: "a-expanded-history",
|
||||
text: "done",
|
||||
reasoning: "restored completed reasoning",
|
||||
streaming: false,
|
||||
reasoningComplete: true,
|
||||
workDurationMs: 1_000,
|
||||
},
|
||||
]);
|
||||
await harness.waitFor(
|
||||
() => Boolean(
|
||||
container.querySelector(".turn-collapse--open")
|
||||
&& container.querySelector(".turn-collapse__reasoning-phase--open .md"),
|
||||
),
|
||||
"expanded reasoning restored from history",
|
||||
);
|
||||
ok(Boolean(container.querySelector(".turn-collapse--open")), "expanded history restores the parent reasoning fold open");
|
||||
ok(Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .md")), "expanded history restores completed reasoning visible");
|
||||
await harness.settle();
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 4: summaries disabled ─────────────────────────────────────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({
|
||||
storage: { "reasonix-process-fold": "expanded", "reasonix-reasoning-summary": "0" },
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// Run: tsx src/__tests__/transcript-recovery-race.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { VirtuosoHandle } from "react-virtuoso";
|
||||
import { useTranscriptVirtuosoRecovery } from "../lib/useTranscriptVirtuosoRecovery";
|
||||
import type { TranscriptRow } from "../lib/transcriptRows";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(condition: unknown, label: string) {
|
||||
if (condition) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript recovery races");
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></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;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.Node = dom.window.Node;
|
||||
|
||||
let nextFrame = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const requestFrame = (callback: FrameRequestCallback) => {
|
||||
const id = nextFrame;
|
||||
nextFrame += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
const cancelFrame = (id: number) => void frames.delete(id);
|
||||
globalThis.requestAnimationFrame = requestFrame;
|
||||
globalThis.cancelAnimationFrame = cancelFrame;
|
||||
dom.window.requestAnimationFrame = requestFrame;
|
||||
dom.window.cancelAnimationFrame = cancelFrame;
|
||||
|
||||
let clockNow = 10_000;
|
||||
let nextTimer = 1;
|
||||
const timers = new Map<number, { dueAt: number; run: () => void }>();
|
||||
const originalDateNow = Date.now;
|
||||
const originalSetTimeout = dom.window.setTimeout;
|
||||
const originalClearTimeout = dom.window.clearTimeout;
|
||||
Date.now = () => clockNow;
|
||||
dom.window.setTimeout = ((handler: TimerHandler, timeout = 0, ...args: unknown[]) => {
|
||||
const id = nextTimer;
|
||||
nextTimer += 1;
|
||||
const run = typeof handler === "function"
|
||||
? () => handler(...args)
|
||||
: () => { throw new Error("string timer handlers are unsupported in this test"); };
|
||||
timers.set(id, { dueAt: clockNow + Math.max(0, timeout), run });
|
||||
return id;
|
||||
}) as typeof dom.window.setTimeout;
|
||||
dom.window.clearTimeout = ((id: number | undefined) => {
|
||||
if (id !== undefined) timers.delete(id);
|
||||
}) as typeof dom.window.clearTimeout;
|
||||
|
||||
async function advanceClock(milliseconds: number) {
|
||||
await act(async () => {
|
||||
const target = clockNow + milliseconds;
|
||||
while (true) {
|
||||
const next = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.dueAt <= target)
|
||||
.sort(([leftID, left], [rightID, right]) => left.dueAt - right.dueAt || leftID - rightID)[0];
|
||||
if (!next) break;
|
||||
const [id, timer] = next;
|
||||
timers.delete(id);
|
||||
clockNow = timer.dueAt;
|
||||
timer.run();
|
||||
}
|
||||
clockNow = target;
|
||||
});
|
||||
}
|
||||
|
||||
async function flushFrames() {
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
await act(async () => pending.forEach(([, callback]) => callback(performance.now())));
|
||||
}
|
||||
|
||||
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
|
||||
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
|
||||
scrollElement.getBoundingClientRect = () => ({ top: 0, bottom: 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: 0, toJSON: () => ({}) });
|
||||
rowElement.getBoundingClientRect = () => ({ top: 200, bottom: 300, height: 100, left: 0, right: 800, width: 800, x: 0, y: 200, toJSON: () => ({}) });
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 100 });
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 500 });
|
||||
|
||||
const item: Item = { kind: "assistant", id: "a", text: "answer", reasoning: "", streaming: false };
|
||||
const rows: TranscriptRow[] = [{ kind: "answer", key: "row-a", item }];
|
||||
const scrollRef = { current: scrollElement };
|
||||
const pinnedRef = { current: false };
|
||||
const readyRef = { current: true };
|
||||
let scrollByCalls = 0;
|
||||
let scrollToIndexCalls = 0;
|
||||
let scrollToBottomCalls = 0;
|
||||
const virtuosoRef = {
|
||||
current: {
|
||||
scrollBy: () => { scrollByCalls += 1; },
|
||||
scrollToIndex: () => { scrollToIndexCalls += 1; },
|
||||
} as unknown as VirtuosoHandle,
|
||||
};
|
||||
let recovery: ReturnType<typeof useTranscriptVirtuosoRecovery> | undefined;
|
||||
|
||||
function Probe({ surfaceKey, revision = 0, hold = false }: { surfaceKey: string; revision?: number; hold?: boolean }) {
|
||||
recovery = useTranscriptVirtuosoRecovery({
|
||||
surfaceKey,
|
||||
historyLayoutRevision: revision,
|
||||
rows,
|
||||
rowIndexByKey: new Map([["row-a", 0]]),
|
||||
scrollRef,
|
||||
pinnedRef,
|
||||
virtuosoRef,
|
||||
readyRef,
|
||||
scrollToBottom: () => { scrollToBottomCalls += 1; },
|
||||
holdRevisionResets: hold,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-a" />));
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-b" />));
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === "surface-b:0", "surface switch cancels the previous blank-viewport watchdog");
|
||||
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === "surface-b:1", "blank viewport schedules a controlled size-tree rebuild");
|
||||
await act(async () => recovery?.handleItemsRendered(1));
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-c" />));
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0, "stale anchor correction cannot scroll the newly selected surface");
|
||||
|
||||
// ── invalidateAnchors: user intent cancels an in-flight restore (#8657/#8688)
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === "surface-c:2", "blank viewport rebuilds the size tree on the current surface");
|
||||
scrollByCalls = 0;
|
||||
scrollToIndexCalls = 0;
|
||||
scrollToBottomCalls = 0;
|
||||
await act(async () => recovery?.invalidateAnchors());
|
||||
await act(async () => recovery?.handleItemsRendered(1));
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0, "invalidated anchor stops the restore correction loop");
|
||||
check(scrollToIndexCalls === 0, "invalidated anchor never re-aims at the stale row");
|
||||
check(scrollToBottomCalls === 1, "a reset without an anchor settles at the bottom");
|
||||
|
||||
// ── Blank-recovery cooldown: revision bump rebuilds, immediate re-blank is blocked
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-c" revision={1} />));
|
||||
await advanceClock(60);
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === "surface-c:3", "layout revision rebuilds the size tree after the batch window");
|
||||
await act(async () => recovery?.handleItemsRendered(1));
|
||||
// Let the in-flight restore converge: place the anchor row at its target
|
||||
// offset so the correction loop settles within two stable frames (real DOMs
|
||||
// converge after each scrollBy; the stubbed rects here do not move unless we
|
||||
// move them, and the wall-clock budget would otherwise keep it alive).
|
||||
rowElement.getBoundingClientRect = () => ({ top: 0, bottom: 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: 0, toJSON: () => ({}) });
|
||||
for (let i = 0; i < 10; i += 1) await flushFrames();
|
||||
rowElement.getBoundingClientRect = () => ({ top: 200, bottom: 300, height: 100, left: 0, right: 800, width: 800, x: 0, y: 200, toJSON: () => ({}) });
|
||||
scrollByCalls = 0;
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === "surface-c:3", "blank recovery within the cooldown window is ignored");
|
||||
check(scrollByCalls === 0, "cooldown-blocked blank check performs no correction");
|
||||
|
||||
// ── User-scroll quiescence: a layout revision must not rebuild mid-scroll
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-d" />));
|
||||
await flushFrames();
|
||||
const keyBeforeIntent = recovery?.resetKey;
|
||||
await act(async () => recovery?.noteUserScrollIntent());
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-d" revision={1} />));
|
||||
await advanceClock(60);
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === keyBeforeIntent, "layout revision does not rebuild the size tree mid-scroll");
|
||||
await advanceClock(350);
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey !== keyBeforeIntent && recovery?.resetKey.startsWith("surface-d:"), "deferred layout rebuild fires once the scroll goes quiet");
|
||||
|
||||
// ── A user scroll gesture aborts the restore that rebuild just started
|
||||
scrollByCalls = 0;
|
||||
scrollToIndexCalls = 0;
|
||||
scrollToBottomCalls = 0;
|
||||
await act(async () => recovery?.handleItemsRendered(1));
|
||||
await flushFrames();
|
||||
check(scrollByCalls > 0 || scrollToIndexCalls > 0, "anchor restore is in flight after the deferred rebuild");
|
||||
await act(async () => recovery?.noteUserScrollIntent());
|
||||
const frozenScrollBy = scrollByCalls;
|
||||
const frozenScrollToIndex = scrollToIndexCalls;
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(scrollByCalls === frozenScrollBy && scrollToIndexCalls === frozenScrollToIndex, "a user scroll gesture aborts the in-flight restore");
|
||||
await advanceClock(350);
|
||||
|
||||
// ── Blank detection is gated while the user scrolls, armed again at idle
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-e" />));
|
||||
await flushFrames();
|
||||
const keySurfaceE = recovery?.resetKey;
|
||||
await act(async () => recovery?.noteUserScrollIntent());
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === keySurfaceE, "blank viewport during active user scrolling does not rebuild");
|
||||
await advanceClock(350);
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey !== keySurfaceE && recovery?.resetKey.startsWith("surface-e:"), "a blank that persists into scroll idle earns a rebuild");
|
||||
|
||||
// ── Restore waits for a slow-mounting anchor row beyond the old 8-frame budget
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-f" />));
|
||||
await flushFrames();
|
||||
const keySurfaceF = recovery?.resetKey;
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey !== keySurfaceF, "rebuild armed for the slow-mount restore");
|
||||
rowElement.remove();
|
||||
scrollByCalls = 0;
|
||||
scrollToIndexCalls = 0;
|
||||
await act(async () => recovery?.handleItemsRendered(1));
|
||||
for (let i = 0; i < 10; i += 1) await flushFrames();
|
||||
check(scrollToIndexCalls > 8, "restore keeps re-aiming past the old 8-frame budget while the anchor row is unmounted");
|
||||
scrollElement.appendChild(rowElement);
|
||||
rowElement.getBoundingClientRect = () => ({ top: 50, bottom: 150, height: 100, left: 0, right: 800, width: 800, x: 0, y: 50, toJSON: () => ({}) });
|
||||
await flushFrames();
|
||||
check(scrollByCalls > 0, "restore corrects once the anchor row mounts");
|
||||
rowElement.getBoundingClientRect = () => ({ top: 0, bottom: 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: 0, toJSON: () => ({}) });
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
const settledScrollBy = scrollByCalls;
|
||||
const settledScrollToIndex = scrollToIndexCalls;
|
||||
await flushFrames();
|
||||
check(scrollByCalls === settledScrollBy && scrollToIndexCalls === settledScrollToIndex, "restore settles on the mounted anchor within the wall-clock budget");
|
||||
|
||||
// ── Streaming hold: revisions defer until the stream ends
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-g" hold={true} />));
|
||||
await flushFrames();
|
||||
const keySurfaceG = recovery?.resetKey;
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-g" revision={1} hold={true} />));
|
||||
await advanceClock(60);
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === keySurfaceG, "layout revision does not rebuild while the turn is streaming");
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-g" revision={1} hold={false} />));
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey !== keySurfaceG && recovery?.resetKey.startsWith("surface-g:"), "the deferred rebuild runs when the stream ends");
|
||||
|
||||
// ── Revision rebuilds coalesce within the min interval
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-h" />));
|
||||
await flushFrames();
|
||||
const keySurfaceH0 = recovery?.resetKey;
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-h" revision={1} />));
|
||||
await advanceClock(60);
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey !== keySurfaceH0 && recovery?.resetKey.startsWith("surface-h:"), "first layout revision rebuilds after the batch window");
|
||||
await act(async () => recovery?.invalidateAnchors()); // simulate the restore completing
|
||||
const keySurfaceH1 = recovery?.resetKey;
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-h" revision={2} />));
|
||||
await advanceClock(60);
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === keySurfaceH1, "a second revision inside the coalescing window does not rebuild again");
|
||||
await advanceClock(620);
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey !== keySurfaceH1, "the coalesced rebuild fires when the interval lapses");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
Date.now = originalDateNow;
|
||||
dom.window.setTimeout = originalSetTimeout;
|
||||
dom.window.clearTimeout = originalClearTimeout;
|
||||
dom.window.close();
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript recovery race test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript recovery race tests passed.`);
|
||||
@@ -135,6 +135,20 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
ok(!rows.some((row) => row.kind === "tool-batch"), "creation mode never emits read-only batches");
|
||||
}
|
||||
|
||||
{
|
||||
const models = buildTurnModels([
|
||||
{ kind: "user", id: "u-search", text: "search" },
|
||||
{ kind: "tool", id: "s1", name: "web_search", args: `{"query":"bitcoin"}`, readOnly: true, status: "done" },
|
||||
{ kind: "tool", id: "r1", name: "read_file", args: "{}", readOnly: true, status: "done" },
|
||||
{ kind: "assistant", id: "a-search", text: "answer only", reasoning: "", streaming: false },
|
||||
]);
|
||||
const rows = buildTranscriptRows(models, rowOptions(EMPTY_FOLDS, "expanded"));
|
||||
const searchRow = rows.find((row) => row.kind === "tool" && "item" in row && row.item.id === "s1");
|
||||
const batch = rows.find((row) => row.kind === "tool-batch");
|
||||
ok(Boolean(searchRow), "provider web search stays a standalone tool card");
|
||||
eq(batch && "items" in batch ? batch.items.map((item) => item.name).join(",") : "", "read_file", "ordinary readers still batch beside the search card");
|
||||
}
|
||||
|
||||
{
|
||||
// A fold whose process items are all filtered out (sub-agent subcalls,
|
||||
// todo_write) renders no header row at all.
|
||||
@@ -160,6 +174,20 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
eq(kinds(rows), "notice,user,answer,turn-actions", "prelude notices render without a synthetic user row");
|
||||
}
|
||||
|
||||
{
|
||||
const models = buildTurnModels([
|
||||
{ kind: "user", id: "u1", text: "请继续完成 PPT 任务" },
|
||||
{ kind: "assistant", id: "a-step", text: "下一步:安装 pptxgenjs 并确认 Pillow。", reasoning: "", streaming: false },
|
||||
{ kind: "notice", id: "auto-guard", level: "info", text: "↪ A tool failed. Use read-only diagnosis as needed, continue unrelated work automatically." },
|
||||
{ kind: "notice", id: "user-steer", level: "info", text: "↪ 改用 Pillow 10 验证" },
|
||||
{ kind: "assistant", id: "a-done", text: "pptxgenjs 已安装成功", reasoning: "", streaming: false },
|
||||
]);
|
||||
const rows = buildTranscriptRows(models, rowOptions(EMPTY_FOLDS));
|
||||
eq(kinds(rows), "user,answer,notice,answer,turn-actions", "host Auto Guard guidance is not a user-side steer row");
|
||||
const notice = rows.find((row) => row.kind === "notice");
|
||||
eq(notice && "item" in notice ? notice.item.text : "", "↪ 改用 Pillow 10 验证", "real user steers stay visible");
|
||||
}
|
||||
|
||||
{
|
||||
const models = buildTurnModels([
|
||||
{ kind: "user", id: "u1", text: "cancelled before output" },
|
||||
@@ -192,6 +220,7 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
], undefined, false, true);
|
||||
const mixedStates = foldSegmentStates(mixed);
|
||||
eq(mixedStates.length, 1, "hidden reasoning keeps a mixed tool process fold");
|
||||
eq(foldSegmentStates(mixed, true)[0]?.keepReasoningExpanded, false, "expanded reasoning mode does not pin tool-only folds");
|
||||
eq(mixed[0]?.segments[0]?.displayItems.filter((item) => item.kind === "assistant").length ?? -1, 0, "hidden reasoning is excluded from fold body items");
|
||||
eq(mixed[0]?.segments[0]?.displayItems.filter((item) => item.kind === "tool").length ?? -1, 1, "hidden reasoning does not hide tools");
|
||||
}
|
||||
@@ -215,6 +244,27 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
eq(reconcileFoldEntries(closed ?? EMPTY_FOLDS, settledStates, "auto", false), null, "steady state reconciles to no change");
|
||||
}
|
||||
|
||||
{
|
||||
// Expanded reasoning pins only reasoning-bearing folds across completion.
|
||||
const running = buildTurnModels(fixture.slice(0, 7), { id: "a2", hasAnswerText: true, hasReasoning: false, reasoningComplete: true }, true);
|
||||
const runningStates = foldSegmentStates(running, true);
|
||||
eq(runningStates[0]?.keepReasoningExpanded, true, "expanded mode marks a reasoning-bearing fold as pinned");
|
||||
const seeded = reconcileFoldEntries(EMPTY_FOLDS, runningStates, "auto", false) ?? EMPTY_FOLDS;
|
||||
|
||||
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
|
||||
const settledPinnedStates = foldSegmentStates(settledModels, true);
|
||||
const completed = reconcileFoldEntries(seeded, settledPinnedStates, "auto", false);
|
||||
ok(completed?.get("a1")?.open === true, "expanded reasoning keeps its parent fold open after completion");
|
||||
|
||||
const manuallyCollapsed = foldMapWithToggle(seeded, "a1", true);
|
||||
const completedCollapsed = reconcileFoldEntries(manuallyCollapsed, settledPinnedStates, "auto", false);
|
||||
ok(completedCollapsed?.get("a1")?.open === false, "manual parent collapse still wins when expanded reasoning completes");
|
||||
|
||||
const settledAutoStates = foldSegmentStates(settledModels, false);
|
||||
const backToAuto = reconcileFoldEntries(completed ?? EMPTY_FOLDS, settledAutoStates, "auto", false);
|
||||
ok(backToAuto?.get("a1")?.open === false, "leaving expanded reasoning re-applies the automatic parent fold policy");
|
||||
}
|
||||
|
||||
{
|
||||
// User override survives completion; a fold with nothing outside never
|
||||
// auto-closes.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Run: node --import tsx src/__tests__/transcript-scroll-release.test.ts
|
||||
// Regression: non-wheel upward scroll must release tail-follow immediately,
|
||||
// not wait 500ms. Covers the fix for native scrollbar drag and middle-button
|
||||
// autoscroll suppression during a bottomRequest window.
|
||||
|
||||
import {
|
||||
isPinnedTranscriptLayoutGrowth,
|
||||
TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
} from "../lib/useTranscriptVirtuosoScroll";
|
||||
INITIAL_TRANSCRIPT_SCROLL_STATE,
|
||||
reduceTranscriptScroll,
|
||||
type TranscriptScrollEvent,
|
||||
type TranscriptScrollState,
|
||||
} from "../lib/transcriptScrollController";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -21,64 +20,86 @@ function check(condition: boolean, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript scroll release");
|
||||
|
||||
// Mock the hook's atBottomStateChange behavior with the fix applied.
|
||||
// The real verification path is the browser bench, but this proves the logic.
|
||||
function mockAtBottomStateChange(
|
||||
atBottom: boolean,
|
||||
bottomRequestActive: boolean,
|
||||
scrollElement: { scrollHeight: number; scrollTop: number; clientHeight: number } | null,
|
||||
) {
|
||||
if (!atBottom && bottomRequestActive) {
|
||||
if (scrollElement) {
|
||||
const distanceFromBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;
|
||||
if (distanceFromBottom > TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX) {
|
||||
return "manual"; // Released immediately by the fix
|
||||
}
|
||||
}
|
||||
return "suppressed"; // Old behavior: early return, no mode change
|
||||
function run(events: readonly TranscriptScrollEvent[], initial = INITIAL_TRANSCRIPT_SCROLL_STATE) {
|
||||
let state: TranscriptScrollState = initial;
|
||||
const commands: string[] = [];
|
||||
for (const event of events) {
|
||||
const next = reduceTranscriptScroll(state, event);
|
||||
state = next.state;
|
||||
commands.push(...next.commands.map((command) => command.type));
|
||||
}
|
||||
return atBottom ? "tail-follow" : "manual";
|
||||
return { state, commands };
|
||||
}
|
||||
|
||||
// Scenario 1: atBottom=false during bottomRequest, but physically at bottom.
|
||||
// Expected: suppressed (Virtuoso is still converging; keep tail intent).
|
||||
const s1 = mockAtBottomStateChange(false, true, { scrollHeight: 10000, scrollTop: 9397, clientHeight: 600 });
|
||||
check(s1 === "suppressed", "physically at bottom during request window: intent preserved");
|
||||
console.log("\ntranscript scroll controller");
|
||||
|
||||
// Scenario 2: atBottom=false during bottomRequest, physically far from bottom.
|
||||
// Expected: manual (native scrollbar drag or middle-button autoscroll moved away).
|
||||
const s2 = mockAtBottomStateChange(false, true, { scrollHeight: 10000, scrollTop: 8500, clientHeight: 600 });
|
||||
check(s2 === "manual", "non-wheel upward scroll during request window: release tail-follow immediately");
|
||||
const streaming = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "TAIL_CONTENT_CHANGED" },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "LAYOUT_HEIGHT_CHANGED" },
|
||||
]);
|
||||
check(streaming.state.mode === "tail-follow", "dynamic atBottom=false does not steal tail ownership");
|
||||
check(streaming.commands.join(",") === "AUTOSCROLL_TO_BOTTOM,AUTOSCROLL_TO_BOTTOM", "tail growth emits only Virtuoso autoscroll commands");
|
||||
|
||||
// Scenario 3: atBottom=false, no active request.
|
||||
// Expected: manual (ordinary path).
|
||||
const s3 = mockAtBottomStateChange(false, false, { scrollHeight: 10000, scrollTop: 5000, clientHeight: 600 });
|
||||
check(s3 === "manual", "upward scroll outside request window: ordinary manual mode");
|
||||
const manual = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "TAIL_CONTENT_CHANGED" },
|
||||
{ type: "VIEWPORT_RESIZED" },
|
||||
]);
|
||||
check(manual.state.mode === "manual", "explicit user intent releases tail-follow");
|
||||
check(manual.commands.length === 0, "manual reading never receives tail commands");
|
||||
|
||||
// Scenario 4: atBottom=true.
|
||||
// Expected: tail-follow (re-engaged).
|
||||
const s4 = mockAtBottomStateChange(true, false, null);
|
||||
check(s4 === "tail-follow", "scrolled back to physical bottom: tail-follow restored");
|
||||
const returned = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
]);
|
||||
check(returned.state.mode === "tail-follow", "reaching the real bottom re-engages tail-follow");
|
||||
|
||||
const layoutGrowth = isPinnedTranscriptLayoutGrowth({
|
||||
pinned: true,
|
||||
previousScrollHeight: 10000,
|
||||
previousScrollTop: 9400,
|
||||
scrollHeight: 10320,
|
||||
scrollTop: 9714,
|
||||
});
|
||||
check(layoutGrowth, "pinned row growth preserves tail-follow through callback reordering");
|
||||
const shortTranscript = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: false },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
]);
|
||||
check(shortTranscript.state.mode === "tail-follow", "non-overflow transcript always stays tail-follow");
|
||||
|
||||
const userScrollDuringGrowth = isPinnedTranscriptLayoutGrowth({
|
||||
pinned: true,
|
||||
previousScrollHeight: 10000,
|
||||
previousScrollTop: 9400,
|
||||
scrollHeight: 10320,
|
||||
scrollTop: 9000,
|
||||
});
|
||||
check(!userScrollDuringGrowth, "upward user movement is not mistaken for pinned row growth");
|
||||
const fold = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_RESIZE_BEGIN" },
|
||||
{ type: "LAYOUT_HEIGHT_CHANGED" },
|
||||
{ type: "USER_RESIZE_END" },
|
||||
]);
|
||||
check(fold.state.mode === "manual", "user fold resize settles in manual mode");
|
||||
check(fold.commands.length === 0, "user fold resize cannot tug the viewport to the tail");
|
||||
|
||||
const selection = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "SELECTION_BEGIN" },
|
||||
{ type: "SCROLL_TO_OFFSET", owner: "selection-edge-scroll", top: 120 },
|
||||
{ type: "LAYOUT_HEIGHT_CHANGED" },
|
||||
{ type: "SELECTION_END" },
|
||||
]);
|
||||
check(selection.state.mode === "manual", "selection returns to manual reading");
|
||||
check(selection.commands.join(",") === "SCROLL_TO_OFFSET", "selection owns only its explicit edge-scroll command");
|
||||
|
||||
const jump = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
{ type: "JUMP_TO_BOTTOM", behavior: "smooth" },
|
||||
]);
|
||||
check(jump.state.mode === "tail-follow", "jump-bottom explicitly owns the tail");
|
||||
check(jump.commands.join(",") === "SCROLL_TO_LAST", "jump-bottom uses Virtuoso scrollToIndex only");
|
||||
|
||||
const restore = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "JUMP_TO_INDEX", index: 42 },
|
||||
{ type: "PROGRAMMATIC_END" },
|
||||
]);
|
||||
check(restore.state.mode === "manual", "question/rewind navigation settles in manual mode");
|
||||
check(restore.commands.join(",") === "SCROLL_TO_INDEX", "navigation emits one indexed Virtuoso command");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -233,7 +233,7 @@ await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientX: 100, clientY: 40 }));
|
||||
});
|
||||
eq(transcriptSelectionStore.getSnapshot().mode, "logical-dragging", "a dead native range still promotes from the frozen anchor during drag");
|
||||
eq(mode, "logical-selecting", "dead-native promotion transfers scroll ownership to logical selection");
|
||||
eq(mode, "selection", "dead-native promotion transfers scroll ownership to logical selection");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0, clientX: 100, clientY: 40 }));
|
||||
});
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
// create no Markdown/ToolCard subtrees),
|
||||
// - prepending an older-history page keeps the reading position (key-anchored
|
||||
// compensation),
|
||||
// - while pinned, streaming growth re-pins to the tail without remounting
|
||||
// history rows,
|
||||
// - the active turn streams in the pinned live region outside the list:
|
||||
// token growth never touches the virtual list, reasoning streams as plain
|
||||
// text, and completion materializes the turn back into the list,
|
||||
// - jump-bottom outranks an in-flight recovery anchor restore,
|
||||
// - mounted history rows trigger lazy full-content resolution,
|
||||
// - the rewind signal scrolls to the rewound-to question's virtual row.
|
||||
|
||||
@@ -77,6 +79,9 @@ function firstTextNode(root: Node): Text | null {
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
el.scrollTop = 2000;
|
||||
// Match a reader leaving the tail (wheel-up). A raw scrollTop write leaves
|
||||
// the pin set, and a later LAST/undershoot path would snap back to bottom.
|
||||
el.dispatchEvent(new WheelEvent("wheel", { deltaY: -40, bubbles: true }));
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
const before = el.scrollTop;
|
||||
@@ -108,7 +113,10 @@ function firstTextNode(root: Node): Text | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tail streaming pin + history row isolation ────────────────────────────────
|
||||
// ── Streaming content lives outside the virtual list ─────────────────────────
|
||||
// The active turn renders in the pinned live region; the virtual list holds
|
||||
// only static rows. Token growth must not touch the list, and completion must
|
||||
// materialize the turn back into it.
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
@@ -117,22 +125,141 @@ function firstTextNode(root: Node): Text | null {
|
||||
{ kind: "user", id: "u-live", text: "stream" },
|
||||
{ kind: "assistant", id: "live-1", text: "", reasoning: "", streaming: true },
|
||||
];
|
||||
const live: LiveStream = { id: "live-1", text: "token", reasoning: "", reasoningComplete: true };
|
||||
const live: LiveStream = { id: "live-1", text: "token", reasoning: "chain", reasoningComplete: false };
|
||||
await harness.render(items, { running: true, live });
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
const historyRow = Array.from(harness.container.querySelectorAll<HTMLElement>(".transcript__row"))
|
||||
.find((row) => row.dataset.rowKey !== "a:live-1") ?? null;
|
||||
const list = () => harness.container.querySelector('[data-testid="virtuoso-item-list"]');
|
||||
const liveRegion = () => harness.container.querySelector<HTMLElement>(".transcript__live-region");
|
||||
ok(liveRegion() != null, "streaming mounts the live region outside the virtual list");
|
||||
ok(liveRegion()?.textContent?.includes("token") ?? false, "the live answer streams inside the live region");
|
||||
ok(!(list()?.textContent?.includes("token") ?? true), "streaming content stays out of the virtual list");
|
||||
ok(liveRegion()?.querySelector(".reasoning__stream-text")?.textContent?.includes("chain") ?? false, "streaming reasoning renders as append-only plain text");
|
||||
ok(liveRegion()?.querySelector(".reasoning__stream-text")?.querySelector(".md") === null, "streaming reasoning mounts no Markdown subtree");
|
||||
|
||||
const beforeDistance = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
ok(Math.abs(beforeDistance) <= 1, "the live transcript starts pinned to the tail");
|
||||
await harness.render(items, { running: true, live: { ...live, text: "token token token token token" } });
|
||||
const historyRow = harness.container.querySelector<HTMLElement>("[data-testid='virtuoso-item-list'] .transcript__row");
|
||||
const historyRowKey = historyRow?.dataset.rowKey;
|
||||
await harness.render(items, { running: true, live: { ...live, text: "token token token token token", reasoning: "chain chain" } });
|
||||
await harness.flush();
|
||||
const distance = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
ok(Math.abs(distance) <= 1, `streaming update re-pins to the tail (bottom distance ${distance})`);
|
||||
const historyRowAfter = historyRow?.dataset.rowKey
|
||||
? Array.from(harness.container.querySelectorAll<HTMLElement>(".transcript__row")).find((row) => row.dataset.rowKey === historyRow.dataset.rowKey) ?? null
|
||||
const historyRowAfter = historyRowKey
|
||||
? harness.container.querySelector(`[data-testid='virtuoso-item-list'] .transcript__row[data-row-key="${historyRowKey}"]`)
|
||||
: null;
|
||||
ok(historyRow !== null && historyRow === historyRowAfter, "streaming tokens never remount history rows");
|
||||
ok(historyRow != null && historyRowKey != null && historyRow === historyRowAfter, "streaming tokens never remount history rows");
|
||||
ok(liveRegion()?.textContent?.includes("token token token token token") ?? false, "the live region follows token growth");
|
||||
|
||||
await harness.render(
|
||||
[
|
||||
...turns(10),
|
||||
{ kind: "user", id: "u-live", text: "stream" },
|
||||
{ kind: "assistant", id: "live-1", text: "token token token token token", reasoning: "chain chain", streaming: false, reasoningComplete: true },
|
||||
],
|
||||
{ running: false },
|
||||
);
|
||||
await harness.waitFor(
|
||||
() => harness.container.querySelector(".transcript__live-region") === null,
|
||||
"the live region to unmount after completion",
|
||||
);
|
||||
let materialized = false;
|
||||
try {
|
||||
// jsdom fires no scroll events for programmatic scrolls, so nudge the
|
||||
// scroller to let Virtuoso move its mounted window to the settled tail.
|
||||
for (let i = 0; i < 12 && !materialized; i += 1) {
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
materialized = list()?.textContent?.includes("token token token token token") ?? false;
|
||||
}
|
||||
} catch {
|
||||
materialized = false;
|
||||
}
|
||||
ok(materialized, "completion materializes the answer into the virtual list");
|
||||
ok(harness.container.querySelector(".reasoning__stream-text") === null, "completed reasoning leaves the plain-text view");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── The live region shows a status row before the first stream item ──────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
await harness.render([{ kind: "user", id: "u-pending", text: "waiting" }], { running: true });
|
||||
const region = harness.container.querySelector<HTMLElement>(".transcript__live-region");
|
||||
ok(region != null, "a fresh turn mounts the live region before the first item");
|
||||
ok(region?.querySelector(".transcript__live-status") != null, "a fresh turn shows the working status row");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expanded reasoning: plain-text stream swaps to formatted Markdown once ───
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100, reasoningDisplayMode: "expanded" });
|
||||
try {
|
||||
const items: Item[] = [
|
||||
{ kind: "user", id: "u-exp", text: "think" },
|
||||
{ kind: "assistant", id: "exp-1", text: "", reasoning: "", streaming: true },
|
||||
];
|
||||
const live: LiveStream = { id: "exp-1", text: "", reasoning: "trace **bold**", reasoningComplete: false };
|
||||
await harness.render(items, { running: true, live });
|
||||
await harness.settle();
|
||||
const streamText = harness.container.querySelector(".reasoning__stream-text");
|
||||
ok(streamText?.textContent?.includes("**bold**") ?? false, "expanded streaming reasoning stays unformatted plain text");
|
||||
await harness.render(
|
||||
[
|
||||
{ kind: "user", id: "u-exp", text: "think" },
|
||||
{ kind: "assistant", id: "exp-1", text: "", reasoning: "trace **bold**", streaming: false, reasoningComplete: true },
|
||||
],
|
||||
{ running: false },
|
||||
);
|
||||
await harness.waitFor(
|
||||
() => harness.container.querySelector(".reasoning__body .md strong") != null,
|
||||
"completed expanded reasoning to render formatted Markdown",
|
||||
);
|
||||
ok(
|
||||
harness.container.querySelector(".reasoning__body .md strong")?.textContent === "bold",
|
||||
"completed expanded reasoning swaps to formatted Markdown",
|
||||
);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Jump-bottom wins over an in-flight recovery restore while streaming ──────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
const items: Item[] = [
|
||||
...turns(20),
|
||||
{ kind: "user", id: "u-live", text: "stream" },
|
||||
{ kind: "assistant", id: "live-1", text: "", reasoning: "", streaming: true },
|
||||
];
|
||||
const live: LiveStream = { id: "live-1", text: "token", reasoning: "", reasoningComplete: true };
|
||||
await harness.render(items, { running: true, live, tabId: "jump-tab", historyLayoutRevision: 0 });
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
el.scrollTop = 0;
|
||||
el.dispatchEvent(new WheelEvent("wheel", { deltaY: -40, bubbles: true }));
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
|
||||
// A lazy-content invalidation lands right before the click: the reset and
|
||||
// its anchor restore are still in flight while the user jumps.
|
||||
await harness.render(items, { running: true, live, tabId: "jump-tab", historyLayoutRevision: 1 });
|
||||
const jump = harness.container.querySelector<HTMLButtonElement>(".transcript__jump-bottom");
|
||||
ok(jump != null, "jump-bottom is available while scrolled up during streaming");
|
||||
jump?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await harness.settle();
|
||||
await harness.waitFor(
|
||||
() => el.scrollHeight - el.clientHeight - el.scrollTop <= 1,
|
||||
"the transcript to reach the tail",
|
||||
);
|
||||
ok(el.dataset.scrollMode === "tail-follow", "jump-bottom restores tail-follow ownership");
|
||||
await harness.settle();
|
||||
const distance = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
ok(distance <= 1, `the tail holds after the recovery restore settles (distance ${distance})`);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
@@ -164,6 +291,27 @@ function firstTextNode(root: Node): Text | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lazy content size invalidation rebuilds the Virtuoso size tree ───────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
const items = turns(20);
|
||||
await harness.render(items, { running: false, tabId: "layout-tab", historyLayoutRevision: 0 });
|
||||
await harness.settle();
|
||||
const before = harness.container.querySelector("[data-testid='virtuoso-item-list']");
|
||||
await harness.render(items, { running: false, tabId: "layout-tab", historyLayoutRevision: 1 });
|
||||
for (let i = 0; i < 5; i += 1) await harness.flush();
|
||||
const after = harness.container.querySelector("[data-testid='virtuoso-item-list']");
|
||||
ok(before != null && after != null && before !== after, "lazy content layout revision rebuilds Virtuoso's cached size tree");
|
||||
const el = harness.scrollElement();
|
||||
const distance = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
ok(Math.abs(distance) <= 1, `tail-follow survives the size-tree rebuild (bottom distance ${distance})`);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-page selection promotes to the logical model ───────────────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
@@ -248,5 +396,96 @@ function firstTextNode(root: Node): Text | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Short transcripts must not clone the first user bubble at the top ────────
|
||||
// Virtuoso alignToBottom uses margin-top:auto to pin short lists to the
|
||||
// composer. Combined with firstItemIndex it also paints a second copy of the
|
||||
// first user row at the scroller top, leaving a large empty band in between.
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 600, rowHeight: 80 });
|
||||
try {
|
||||
await harness.render(
|
||||
[
|
||||
{ kind: "user", id: "u-short", text: "你好" },
|
||||
{ kind: "assistant", id: "a-short", text: "hello", reasoning: "", streaming: false },
|
||||
],
|
||||
{ running: false },
|
||||
);
|
||||
await harness.settle();
|
||||
const users = harness.container.querySelectorAll(".msg--user");
|
||||
ok(users.length === 1, `a one-turn transcript mounts the user bubble once (got ${users.length})`);
|
||||
const list = harness.container.querySelector<HTMLElement>('[data-testid="virtuoso-item-list"]');
|
||||
ok(list != null, "short transcript mounts the Virtuoso item list");
|
||||
ok(list?.style.marginTop !== "auto", `short content is not bottom-shifted (marginTop=${JSON.stringify(list?.style.marginTop ?? null)})`);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── A short interrupted turn has no phantom "bottom" to jump to ─────────────
|
||||
// Once alignToBottom was removed, short content correctly stayed at the top.
|
||||
// An upward wheel intent still unpinned it even though the scroller had no
|
||||
// overflow, exposing a jump-bottom button whose click could not move anywhere.
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 700, rowHeight: 60 });
|
||||
try {
|
||||
const interrupted: Item[] = [
|
||||
{ kind: "user", id: "u-interrupted", text: "inspect the four metrics" },
|
||||
{ kind: "assistant", id: "r1", text: "", reasoning: "checking the first source", streaming: false },
|
||||
{ kind: "tool", id: "t1", name: "read_file", args: "{}", output: "ok", status: "done", readOnly: true },
|
||||
{ kind: "assistant", id: "r2", text: "", reasoning: "checking the second source", streaming: false },
|
||||
{ kind: "tool", id: "t2", name: "read_file", args: "{}", output: "ok", status: "done", readOnly: true },
|
||||
{ kind: "notice", id: "cancelled", level: "info", code: "cancelled_turn_display", text: "This turn was interrupted." },
|
||||
];
|
||||
await harness.render(interrupted, { running: false });
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
ok(el.scrollHeight <= el.clientHeight, `interrupted fixture has no scroll range (${el.scrollHeight}/${el.clientHeight})`);
|
||||
|
||||
el.dispatchEvent(new WheelEvent("wheel", { deltaY: -40, bubbles: true }));
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
|
||||
ok(
|
||||
el.dataset.scrollMode === "tail-follow",
|
||||
"wheel-up on a non-overflowing interrupted turn preserves tail-follow state",
|
||||
);
|
||||
ok(
|
||||
harness.container.querySelector(".transcript__jump-bottom") === null,
|
||||
"wheel-up on a non-overflowing interrupted turn does not expose a dead jump-bottom button",
|
||||
);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Real overflow still exposes a working jump-bottom control ──────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
await harness.render(turns(10), { running: false });
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight - 400);
|
||||
el.dispatchEvent(new WheelEvent("wheel", { deltaY: -40, bubbles: true }));
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
|
||||
const jump = harness.container.querySelector<HTMLButtonElement>(".transcript__jump-bottom");
|
||||
ok(jump != null, "a transcript with real overflow still exposes jump-bottom after wheel-up");
|
||||
jump?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await harness.settle();
|
||||
|
||||
ok(
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= 1,
|
||||
"jump-bottom still reaches the native bottom when overflow exists",
|
||||
);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -3,6 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { TEXT_SIZES } from "../lib/textSize";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -98,6 +99,69 @@ eq(
|
||||
);
|
||||
eq(finalDeclaration(".transcript--empty", "overflow-y"), "auto", "empty transcript can scroll instead of clipping");
|
||||
eq(finalDeclaration(".welcome", "overflow"), "visible", "welcome empty state is not clipped by its own box");
|
||||
ok(
|
||||
/\.md\s*>\s*:where\([^)]*p[^)]*ul[^)]*ol[^)]*\)\s*\{[^}]*content-visibility:\s*auto;[^}]*contain-intrinsic-size:\s*auto 72px;/.test(styles),
|
||||
"non-transcript markdown still culls offscreen blocks with a 72px placeholder",
|
||||
);
|
||||
ok(
|
||||
/\.transcript__row\s+\.md\s*>\s*\*\s*(?:,[^{]*)?\{[^}]*content-visibility:\s*visible;[^}]*contain-intrinsic-size:\s*none;/.test(styles),
|
||||
"virtual transcript rows do not measure markdown through 72px placeholders",
|
||||
);
|
||||
ok(
|
||||
hasDeclaration(".transcript__row .msg", "content-visibility", "visible") &&
|
||||
hasDeclaration(".transcript__row .turn-collapse", "content-visibility", "visible"),
|
||||
"virtual transcript cards stay measurable after the markdown override",
|
||||
);
|
||||
|
||||
function paddingSides(value: string) {
|
||||
const parts = value.trim().split(/\s+/);
|
||||
if (parts.length === 1) return { right: parts[0], left: parts[0] };
|
||||
if (parts.length === 2 || parts.length === 3) return { right: parts[1], left: parts[1] };
|
||||
return { right: parts[1], left: parts[3] };
|
||||
}
|
||||
function isZeroPad(value: string | undefined) {
|
||||
return value === undefined || value === "0" || value === "0px";
|
||||
}
|
||||
for (const block of matchingBlocks(".transcript")) {
|
||||
const shorthand = /(?:^|;)\s*padding\s*:\s*([^;]+)/.exec(block);
|
||||
if (shorthand) {
|
||||
const sides = paddingSides(shorthand[1]);
|
||||
ok(
|
||||
isZeroPad(sides.left) && isZeroPad(sides.right),
|
||||
`Virtuoso scroller padding stays vertical-only (${shorthand[1].trim()})`,
|
||||
);
|
||||
}
|
||||
const padLeft = /(?:^|;)\s*padding-left\s*:\s*([^;]+)/.exec(block);
|
||||
const padRight = /(?:^|;)\s*padding-right\s*:\s*([^;]+)/.exec(block);
|
||||
ok(isZeroPad(padLeft?.[1].trim()), "Virtuoso scroller does not set padding-left");
|
||||
ok(isZeroPad(padRight?.[1].trim()), "Virtuoso scroller does not set padding-right");
|
||||
}
|
||||
ok(hasDeclaration(".transcript", "--transcript-inline-pad", "32px"), "default transcript inline inset is 32px");
|
||||
ok(hasDeclaration(".transcript", "--transcript-inline-pad", "16px"), "narrow viewports tighten the transcript inline inset");
|
||||
eq(finalDeclaration(".transcript__row", "padding-left"), "var(--transcript-inline-pad, 32px)", "virtual rows own the left inset");
|
||||
eq(finalDeclaration(".transcript__row", "padding-right"), "var(--transcript-inline-pad, 32px)", "virtual rows own the right inset");
|
||||
eq(finalDeclaration(".transcript__header", "padding-left"), "var(--transcript-inline-pad, 32px)", "load-older header uses the same inline inset");
|
||||
eq(finalDeclaration(".transcript--empty", "padding"), "16px 32px", "empty transcript keeps its own horizontal inset");
|
||||
|
||||
{
|
||||
const stylesheet = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><html><head><style>${stylesheet}</style></head><body>
|
||||
<div class="transcript__row"><div class="md"><p id="inside">inside</p></div></div>
|
||||
<div class="md"><p id="outside">outside</p></div>
|
||||
</body></html>`,
|
||||
{ pretendToBeVisual: true },
|
||||
);
|
||||
const inside = dom.window.getComputedStyle(dom.window.document.getElementById("inside")!);
|
||||
const outside = dom.window.getComputedStyle(dom.window.document.getElementById("outside")!);
|
||||
// jsdom may not implement content-visibility; treat an empty computed value
|
||||
// as "engine gap" and still require the source contract above.
|
||||
if (inside.contentVisibility || outside.contentVisibility) {
|
||||
eq(inside.contentVisibility, "visible", "computed style keeps transcript markdown measurable");
|
||||
eq(outside.contentVisibility, "auto", "computed style still culls markdown outside the transcript");
|
||||
}
|
||||
dom.window.close();
|
||||
}
|
||||
ok(
|
||||
hasDeclaration(".transcript--empty > .welcome", "margin-block", "auto"),
|
||||
"empty-state auto margins apply only to the welcome content",
|
||||
@@ -175,10 +239,6 @@ ok(
|
||||
);
|
||||
|
||||
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
|
||||
eq(finalDeclaration(".composer-meta__control--profile", "flex"), "0 0 auto", "work mode selector sizes to its localized label");
|
||||
eq(finalDeclaration(".composer-meta__control--profile", "max-width"), "68px", "work mode selector keeps a compact narrow-width bound");
|
||||
eq(finalDeclaration(".composer-profile-trigger__label", "overflow"), "hidden", "work mode selector clips only when space is constrained");
|
||||
eq(finalDeclaration(".composer-profile-trigger__label", "text-overflow"), "ellipsis", "work mode selector shows an ellipsis when constrained");
|
||||
eq(finalDeclaration(".composer-meta__control--intent", "max-width"), "72px", "task method selector keeps its current state visible at narrow widths");
|
||||
eq(finalDeclaration(".composer-task-mode-trigger__value", "text-overflow"), "ellipsis", "task method selector truncates its value only when constrained");
|
||||
eq(finalDeclaration(".composer-meta .modelsw__trigger", "font-weight"), "var(--composer-control-font-weight)", "model selector uses the shared control weight");
|
||||
@@ -195,14 +255,9 @@ eq(finalDeclaration(".composer-modebar--approval", "--composer-modebar-active-bg
|
||||
eq(finalDeclaration('.composer-modebar--approval[data-mode="auto"]', "--composer-modebar-active-fg"), "#fff", "auto approval keeps high-contrast text on its solid fill");
|
||||
eq(finalDeclaration('.composer-modebar--approval[data-mode="yolo"]', "--composer-modebar-active-bg"), "var(--mode-yolo-bg)", "yolo approval restores the solid warning fill");
|
||||
eq(finalDeclaration(".composer-intent-menu", "width"), "min(284px, calc(100vw - 16px))", "task method menu uses the shared menu width");
|
||||
eq(finalDeclaration(".composer-profile-menu", "width"), "min(284px, calc(100vw - 16px))", "work mode menu uses the shared menu width");
|
||||
eq(finalDeclaration(".composer-access-menu__desc", "white-space"), "normal", "menu descriptions can wrap onto a second line");
|
||||
eq(finalDeclaration(".composer-access-menu__desc", "text-overflow"), "clip", "menu descriptions no longer use single-line ellipsis");
|
||||
eq(finalDeclaration(".composer-profile-menu .composer-access-menu__desc", "font-size"), "12px", "work mode summaries use the shared control text size");
|
||||
eq(finalDeclaration(".composer-profile-menu .composer-access-menu__desc", "color"), "var(--fg-dim)", "work mode summaries remain readable as secondary text");
|
||||
eq(finalDeclaration(".composer-profile-menu .composer-access-menu__desc", "white-space"), "nowrap", "work mode summaries stay on one scannable line");
|
||||
eq(finalDeclaration(".composer-task-mode-trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "task method selector uses the shared keyboard focus ring");
|
||||
eq(finalDeclaration(".composer-profile-trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "work mode selector uses the shared keyboard focus ring");
|
||||
eq(finalDeclaration(".composer-meta .modelsw__trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "model and effort selectors use the shared keyboard focus ring");
|
||||
eq(finalDeclaration(":root[data-theme-style] .composer-modebar__item--active:focus-visible", "box-shadow"), "var(--focus-ring)", "active permission options retain keyboard focus feedback");
|
||||
eq(
|
||||
@@ -335,7 +390,7 @@ eq(
|
||||
"collapsed creation tool bodies do not paint hidden tool text",
|
||||
);
|
||||
ok(
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta__control--profile\s*\{[\s\S]*?max-width\s*:\s*126px[\s\S]*?\.composer-meta__control--intent\s*\{[\s\S]*?max-width\s*:\s*128px[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta__control--intent\s*\{[\s\S]*?max-width\s*:\s*128px[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
"composer compact controls activate at the capped theme width",
|
||||
);
|
||||
eq(finalDeclaration(".md-table-scroll", "overflow-x"), "auto", "markdown table wrapper scrolls horizontally");
|
||||
|
||||
@@ -161,7 +161,11 @@ await waitFor("hydration completion", () => controller?.state.hydrating === fals
|
||||
|
||||
ok(controller?.state.running ?? false, "late history keeps the live turn running");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.streaming) ?? false, "late history keeps the live assistant stream");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale durable history") ?? false), "late history cannot replace the live transcript");
|
||||
// The page read before the turn started is this session's history, not a
|
||||
// competing version of it: it goes in front of the live turn instead of
|
||||
// replacing it, so the turn never streams over a blank transcript.
|
||||
ok(controller?.state.items[0]?.kind === "user", "late history lands in front of the live turn");
|
||||
ok(controller?.state.items.at(-1)?.kind === "assistant", "late history leaves the live turn at the tail");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText, type Item } from "../lib/useController";
|
||||
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, type Item } from "../lib/useController";
|
||||
import { shouldReconcileStaleTurn } from "../lib/useStaleTurnWatchdog";
|
||||
import { parseTodos } from "../lib/tools";
|
||||
import { resolveTodoPanelTodos } from "../lib/todoVisibility";
|
||||
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
@@ -105,11 +106,6 @@ console.log("\nuse controller meta");
|
||||
"Reasoning effort cannot change while the current answer is running. Stop it first.",
|
||||
"effort busy guard names the running-answer blocker",
|
||||
);
|
||||
eq(
|
||||
tokenModeSwitchNoticeText("active work is still running; running=true; pending_prompt=true; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
|
||||
"Execution setting cannot change while a prompt is waiting for your response. Handle it first.",
|
||||
"execution-setting busy guard prioritizes the pending prompt blocker",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
|
||||
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
@@ -170,19 +166,6 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
|
||||
"Execution setting cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"execution-setting busy guard is localized",
|
||||
);
|
||||
eq(
|
||||
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
|
||||
"The current session changed while switching execution setting. Try once more.",
|
||||
"execution-setting tab race asks the user to retry",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
localizedBackendNoticeText("Session autosave failed: disk full"),
|
||||
@@ -201,22 +184,22 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
|
||||
"legacy recovery branch notice can be normalized without exposing internal branch id",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
|
||||
"recovery copy notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"Repeated save conflicts were detected, so the current version was saved separately.",
|
||||
"legacy repeated recovery conflict notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"Repeated save conflicts were detected, so the current version was saved separately.",
|
||||
"repeated recovery conflict notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
@@ -249,7 +232,7 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded workspace contention copy", "workspace_lease"),
|
||||
"Another Delivery session is writing to this workspace; this session will continue automatically when it is safe.",
|
||||
"Another session is writing to this workspace; this session will continue automatically when it is safe.",
|
||||
"workspace lease contention uses its stable localized notice code",
|
||||
);
|
||||
eq(
|
||||
@@ -264,7 +247,7 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded recovery copy", "session_recovery_forked"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
|
||||
"session recovery fork localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
@@ -274,7 +257,7 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded depth cap", "session_recovery_depth_cap"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"Repeated save conflicts were detected, so the current version was saved separately.",
|
||||
"session recovery depth-cap localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
@@ -394,6 +377,16 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
true,
|
||||
"equivalent empty canonical todo lists keep meta stable",
|
||||
);
|
||||
eq(
|
||||
sameMeta(meta({ dismissedTodoBatches: ["a"] }), meta({ dismissedTodoBatches: ["a"] })),
|
||||
true,
|
||||
"identical dismissed todo batches keep meta stable",
|
||||
);
|
||||
eq(
|
||||
sameMeta(meta({ dismissedTodoBatches: ["a"] }), meta({ dismissedTodoBatches: ["b"] })),
|
||||
false,
|
||||
"persisted todo dismissal changes invalidate meta equality",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -403,6 +396,10 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
const todos = [{ content: "Keep task state", status: "in_progress" }];
|
||||
const withTodos = metaFromTab(tab(), meta({ canonicalTodos: todos }));
|
||||
eq(withTodos.canonicalTodos, todos, "optimistic tab metadata preserves canonical todos for the same session");
|
||||
const dismissed = metaFromTab(tab({ sessionPath: "/s/a.jsonl" }), meta({ sessionPath: "/s/a.jsonl", dismissedTodoBatches: ["done"] }));
|
||||
eq(dismissed.dismissedTodoBatches?.[0], "done", "optimistic metadata keeps session-sidecar todo dismissals");
|
||||
const remounted = metaFromTab(tab({ sessionPath: "/s/leaf.jsonl" }), meta({ sessionPath: "/s/a.jsonl", dismissedTodoBatches: ["done"] }));
|
||||
eq(remounted.dismissedTodoBatches, undefined, "a session remount waits for the new sidecar dismissals");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -411,8 +408,10 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
const updated = reducer({ ...initialState, meta: before }, { type: "meta", meta: completed });
|
||||
eq(updated.meta?.canonicalTodos?.[0]?.status, "completed", "meta refresh applies canonical todo progress");
|
||||
|
||||
const reset = reducer(updated, { type: "reset" });
|
||||
const withDismissed = reducer(updated, { type: "meta", meta: meta({ canonicalTodos: completed.canonicalTodos, dismissedTodoBatches: ["done"] }) });
|
||||
const reset = reducer(withDismissed, { type: "reset" });
|
||||
eq(reset.meta?.canonicalTodos, undefined, "session reset clears canonical todos from the previous session");
|
||||
eq(reset.meta?.dismissedTodoBatches, undefined, "session reset clears persisted todo dismissals from the previous session");
|
||||
|
||||
const cleared = reducer(reset, { type: "meta", meta: meta({ canonicalTodos: [] }) });
|
||||
eq(cleared.meta?.canonicalTodos?.length, 0, "authoritative empty canonical todos survive meta refresh");
|
||||
@@ -454,7 +453,7 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
});
|
||||
liveState = reducer(liveState, {
|
||||
type: "event",
|
||||
e: { kind: "tool_result", tool: { id: "todo-live", name: "todo_write", readOnly: true, output: "Todos updated" } },
|
||||
e: { kind: "tool_result_preview", tool: { id: "todo-live", name: "todo_write", readOnly: true, output: "Todos updated" } },
|
||||
});
|
||||
const liveTodo = liveState.items.find(
|
||||
(item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool" && item.name === "todo_write",
|
||||
@@ -462,7 +461,16 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
eq(
|
||||
JSON.stringify(resolveTodoPanelTodos(liveState.meta?.canonicalTodos, liveTodo ? parseTodos(liveTodo.args) : undefined)),
|
||||
JSON.stringify(JSON.parse(liveArgs).todos),
|
||||
"panel switches to the live todo_write snapshot after it arrives",
|
||||
"panel switches to the live todo_write snapshot when its result preview arrives",
|
||||
);
|
||||
liveState = reducer(liveState, {
|
||||
type: "event",
|
||||
e: { kind: "tool_result", tool: { id: "todo-live", name: "todo_write", readOnly: true, output: "Todos updated", durationMs: 4 } },
|
||||
});
|
||||
eq(
|
||||
liveState.items.filter((item) => item.kind === "tool" && item.id === "todo-live").length,
|
||||
1,
|
||||
"provider-ordered terminal result upserts the preview instead of duplicating the card",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -474,7 +482,9 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
eq(rendered.live, undefined, "final message closes the live stream before turn_done");
|
||||
eq(shouldReconcileStaleTurn(rendered, 1_000, 31_000), true, "stale completed stream still reconciles missed turn_done");
|
||||
eq(shouldReconcileStaleTurn(rendered, 1_000, 20_000), false, "fresh completed stream waits before reconciling");
|
||||
eq(shouldReconcileStaleTurn({ ...rendered, turnActive: false }, 1_000, 31_000), false, "local pending send before turn_started does not reconcile");
|
||||
const optimistic = reducer(initialState, { type: "user", text: "hello", seq: 0, submissionId: "watchdog-submit" });
|
||||
eq(optimistic.turnActive, false, "optimistic send starts before turn_started arrives");
|
||||
eq(shouldReconcileStaleTurn(optimistic, 0, optimistic.turnStartAt + 30_000), true, "optimistic send reconciles even when turn_started is missed");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -705,5 +715,32 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
eq(s.historyOlderLoading, false, "older history clears loading");
|
||||
}
|
||||
|
||||
// ── Todo-only readiness cards retract once the list shows all complete ──────
|
||||
{
|
||||
const args = JSON.stringify({ todos: [{ content: "Write verification notes", status: "completed" }] });
|
||||
let s = reducer(initialState, { type: "event", e: { kind: "turn_done", outcome: "final_readiness", readiness: { missing: ["todo"], attempts: 1 } } });
|
||||
ok(s.items.some((item) => item.kind === "notice" && item.variant === "delivery"), "todo-only readiness card shows at the gated turn");
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "tw1", name: "todo_write", args, readOnly: true } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "tw1", name: "todo_write", args, readOnly: true, output: "task list updated" } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "turn_done" } });
|
||||
ok(!s.items.some((item) => item.kind === "notice" && item.variant === "delivery"), "an all-complete todo list retracts the stale todo-only card");
|
||||
}
|
||||
{
|
||||
const args = JSON.stringify({ todos: [{ content: "Write verification notes", status: "completed" }] });
|
||||
let s = reducer(initialState, { type: "event", e: { kind: "turn_done", outcome: "final_readiness", readiness: { missing: ["todo", "verification"], attempts: 1 } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "tw2", name: "todo_write", args, readOnly: true } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "tw2", name: "todo_write", args, readOnly: true, output: "task list updated" } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "turn_done" } });
|
||||
ok(s.items.some((item) => item.kind === "notice" && item.variant === "delivery"), "a card listing non-todo gaps survives todo completion");
|
||||
}
|
||||
{
|
||||
const args = JSON.stringify({ todos: [{ content: "Write verification notes", status: "in_progress" }] });
|
||||
let s = reducer(initialState, { type: "event", e: { kind: "turn_done", outcome: "final_readiness", readiness: { missing: ["todo"], attempts: 1 } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "tw3", name: "todo_write", args, readOnly: true } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "tw3", name: "todo_write", args, readOnly: true, output: "task list updated" } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "turn_done" } });
|
||||
ok(s.items.some((item) => item.kind === "notice" && item.variant === "delivery"), "an incomplete todo list keeps the todo-only card");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,228 @@
|
||||
// Run: tsx src/__tests__/use-controller-stale-turn-watchdog.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { HistoryMessage, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
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 flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => { await flushPromises(); });
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
console.log("\nstale turn watchdog");
|
||||
|
||||
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.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const originalNow = Date.now;
|
||||
let fakeNow = 1_000;
|
||||
Date.now = () => fakeNow;
|
||||
let nextTimerID = 1;
|
||||
const watchdogTimers = new Map<number, () => void>();
|
||||
window.setTimeout = ((handler: TimerHandler, delay?: number) => {
|
||||
const id = nextTimerID++;
|
||||
if ((delay ?? 0) >= 29_000 && typeof handler === "function") watchdogTimers.set(id, handler);
|
||||
else globalThis.setTimeout(handler, delay);
|
||||
return id;
|
||||
}) as typeof window.setTimeout;
|
||||
window.clearTimeout = ((id?: number) => {
|
||||
if (id !== undefined) watchdogTimers.delete(id);
|
||||
}) as typeof window.clearTimeout;
|
||||
|
||||
const tabID = "tab-watchdog";
|
||||
const sessionPath = "/repo/sessions/watchdog.jsonl";
|
||||
let backendRunning = false;
|
||||
let revision = 1;
|
||||
let history: HistoryMessage[] = [];
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
const agentEventHandlers: Array<(event: WireEvent) => void> = [];
|
||||
|
||||
function tabMeta(): TabMeta {
|
||||
return {
|
||||
id: tabID,
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
topicId: "topic-watchdog",
|
||||
topicTitle: "Watchdog",
|
||||
sessionPath,
|
||||
sessionRevision: revision,
|
||||
sessionDigest: `digest-${revision}`,
|
||||
label: "model",
|
||||
ready: true,
|
||||
running: backendRunning,
|
||||
cancellable: backendRunning,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
eventChannel: "agent:event",
|
||||
sessionPath,
|
||||
sessionRevision: revision,
|
||||
sessionDigest: `digest-${revision}`,
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name, handler) => {
|
||||
if (name === "agent:event") agentEventHandlers.push(handler as (event: WireEvent) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [tabMeta()];
|
||||
},
|
||||
MetaForTab: async () => meta(),
|
||||
ContextUsageForTab: async () => ({ used: 0, window: 1000, sessionTokens: 0 }),
|
||||
EffortForTab: async () => ({ supported: true, current: "auto", default: "auto", levels: ["auto"] }),
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
HistorySliceForTab: async (_id, request) => {
|
||||
historyCalls += 1;
|
||||
return historySliceFromMessages(tabID, history, request, { revision, digest: `digest-${revision}` });
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SubmitToTabWithID: async () => {
|
||||
backendRunning = true;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("initial session", () => controller?.activeTabId === tabID && historyCalls > 0);
|
||||
|
||||
await act(async () => {
|
||||
for (const handler of agentEventHandlers) {
|
||||
handler({ kind: "turn_started", tabId: tabID });
|
||||
handler({ kind: "turn_done", tabId: tabID });
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
fakeNow += 60_000;
|
||||
await act(async () => {
|
||||
await controller?.send("hello");
|
||||
await flushPromises();
|
||||
});
|
||||
ok(controller?.state.running ?? false, "optimistic submit enters running state without any agent events");
|
||||
ok(controller?.state.turnActive === false, "missing turn_started leaves no live turn evidence");
|
||||
ok(watchdogTimers.size === 1, "optimistic submit arms the stale-turn watchdog");
|
||||
|
||||
const firstTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined;
|
||||
if (firstTimer) {
|
||||
watchdogTimers.delete(firstTimer[0]);
|
||||
fakeNow += 30_000;
|
||||
await act(async () => {
|
||||
firstTimer[1]();
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
ok(listTabsCalls >= 2, "first quiet-period probe reconciles backend runtime state");
|
||||
ok(controller?.state.running ?? false, "a genuinely running backend remains running");
|
||||
ok(watchdogTimers.size === 1, "still-running probe re-arms instead of stopping after one check");
|
||||
|
||||
backendRunning = false;
|
||||
revision = 2;
|
||||
history = [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "recovered answer" },
|
||||
];
|
||||
const secondTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined;
|
||||
if (secondTimer) {
|
||||
watchdogTimers.delete(secondTimer[0]);
|
||||
fakeNow += 30_000;
|
||||
await act(async () => {
|
||||
secondTimer[1]();
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
await waitFor("missed answer hydration", () => controller?.state.items.some(
|
||||
(item) => item.kind === "assistant" && item.text === "recovered answer",
|
||||
) ?? false);
|
||||
ok(controller?.state.running === false, "idle backend settles the spinner without switching tabs");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "recovered answer") ?? false, "watchdog hydrates the persisted missed answer");
|
||||
ok(watchdogTimers.size === 0, "settled turn leaves no watchdog timer behind");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
Date.now = originalNow;
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -247,6 +247,11 @@ function ev(s: typeof initialState, e: WireEvent) {
|
||||
eq(completed.running, false, "turn_done still ends the repaired turn");
|
||||
eq(completed.retry, undefined, "turn_done clears the retry indicator");
|
||||
|
||||
const failed = ev(repaired, { kind: "turn_done", err: "shared window overflow" } as WireEvent);
|
||||
eq(failed.running, false, "terminal context error clears running");
|
||||
eq(failed.pendingPrompt, false, "terminal context error clears pending prompt");
|
||||
eq(failed.messageAction, undefined, "terminal context error restores message actions");
|
||||
|
||||
const staleSnapshotAt = promptEventClock();
|
||||
s = ev(repaired, { kind: "retrying", retryAttempt: 4, retryMax: 10 } as WireEvent);
|
||||
s = reducer(s, {
|
||||
|
||||
@@ -249,7 +249,7 @@ console.log("\nworkspace changes git errors");
|
||||
);
|
||||
await waitFor("turn verification summary", () => document.body.textContent?.includes("Turn verification") === true);
|
||||
const text = document.querySelector(".workspace-completion-summary")?.textContent ?? "";
|
||||
ok(text.includes("Balanced") && text.includes("Partially complete"), "change panel localizes preset and verdict");
|
||||
ok(text.includes("Partially complete"), "change panel localizes the completion verdict");
|
||||
ok(text.includes("1 checks failed") && text.includes("2 checks skipped"), "change panel shows detailed check counts on demand");
|
||||
ok(text.includes("stale checks") && text.includes("Other"), "change panel uses safe labels for known and unknown gaps");
|
||||
ok(text.includes("Turn verification limited"), "change panel explains constrained verification without exposing an internal flag");
|
||||
|
||||
Loaded 100 of 1207 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user