fix: dockview, sprite, auth, and CLI db backup improvements (#1075)

This commit is contained in:
Carlos Florêncio
2026-05-28 16:23:17 +01:00
committed by GitHub
parent 905b9f9727
commit f4be0d279c
42 changed files with 1943 additions and 318 deletions
+95 -20
View File
@@ -1,22 +1,94 @@
---
name: debug-logs
description: Add temporary debug logs (console.log / structured Warn) to investigate issues. Use whenever the user wants to add logs, log statements, console.logs, trace, instrument, or print runtime behaviour to debug a frontend or backend issue. Triggers include "add debug logs", "add some logs", "log this", "trace this", "instrument", "investigate why", "print", "console.log around". Debug logs must be stripped before creating a PR.
description: Add debug logs (temporary console.log / structured Warn, or permanent namespaced loggers) to investigate or instrument runtime behaviour. Use whenever the user wants to add logs, log statements, console.logs, trace, instrument, or print runtime behaviour to debug a frontend or backend issue. Triggers include "add debug logs", "add some logs", "log this", "trace this", "instrument", "investigate why", "print", "console.log around". Temporary debug logs must be stripped before creating a PR; persistent ones (frontend `createDebugLogger`, backend tier-appropriate level) stay.
---
# Debug Logs
Add temporary debug logs to investigate runtime issues. These logs are **never merged** — they must be removed before creating a PR.
Add debug logs to investigate runtime issues or instrument recurring failure surfaces. There are **two distinct flavours** — pick the right one up front:
**Use this skill any time you are about to add a `console.log`, `logger.Warn("[DEBUG] ...`, or similar transient instrumentation.** Even if the user says just "add some logs", "throw a few logs in there", "trace this", or "instrument X", apply these rules.
| Flavour | Use when | Lifetime |
|---|---|---|
| **Temporary** (`console.log` / `logger.Warn("[DEBUG] ...")`) | One-off investigation of a specific bug, with the user iterating in their terminal/browser. | **Strip before commit.** Never merge. |
| **Persistent — frontend** (`createDebugLogger` from `@/lib/debug/log`) | The data flow is one users (or future you) might want to inspect again — store hydration, WS dispatch, layout restore, executor compat, etc. | **Stays in code.** No-op in prod; opt-in via `make start-debug`. |
| **Persistent — backend** (`logger.Debug` / `logger.Info` at appropriate level) | Same idea on the backend — the existing structured logger already supports namespaces and `--debug` filtering. | Stays in code. |
**Use this skill any time you are about to add a `console.log`, `logger.Warn("[DEBUG] ...")`, or similar instrumentation.** Even if the user says just "add some logs", "throw a few logs in there", "trace this", or "instrument X", apply these rules.
If the user says "add logs to debug X", default to **temporary**. If they say "instrument X" or "add logs so future debugging is easy", default to **persistent**. When in doubt, ask.
## Rules
1. **All debug logs are temporary.** Strip them before running `/commit` or `/pr`.
2. Use a consistent, searchable prefix so logs can be found and removed easily (e.g. `[reorder-bug]`, `[WS-DEBUG]`).
1. **Temporary logs are temporary.** Strip them before running `/commit` or `/pr`. Persistent ones stay.
2. Use a consistent, searchable prefix so logs can be found and (for temporary ones) removed easily — e.g. `[reorder-bug]`, `[WS-DEBUG]` for temporary; namespaced like `domain:aspect` for persistent.
3. **Always print every value inline as a string.** Browser DevTools and many terminal viewers collapse arrays/maps/nested objects (`Array(2)`, `{...}`) — the agent must serialise these into the log message itself, not pass them as additional arguments.
4. Prefer **one template literal** per `console.log` call. Use `\n` and indentation to lay out structured data so the user can read it without clicking to expand.
4. Prefer **one template literal** per `console.log` call (temporary) or **flat primitives** as logger args (persistent). Use `\n` and indentation only for multi-line layouts the user must read at a glance.
## Frontend (TypeScript)
## Persistent — Frontend (`createDebugLogger`)
The frontend ships a namespaced debug utility at `apps/web/lib/debug/log.ts`:
```typescript
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
const debug = createDebugLogger("executor-compat");
debug("check", { agent: name, executor_type: type, ok: result, reason });
```
Key properties:
- **No-op in prod** — the factory returns a `() => {}` when `IS_DEBUG` is false, so calls cost nothing. Safe to leave in shipped code.
- **Active when** `NODE_ENV !== "production"` (dev), `NEXT_PUBLIC_KANDEV_DEBUG=true` (build flag), or `window.__KANDEV_DEBUG === true` (runtime, set by `make start-debug`).
- **Logfmt output** — `[namespace] message key1=value key2="value with space" key3={"nested":1}` — flat, grep-friendly, copy-pasteable.
- **Mirrored to the in-app log buffer** (`lib/logger/intercept.ts`) so debug lines also appear in Improve Kandev reports without extra plumbing.
### Conventions
- **Namespace** is `domain:aspect` so devtools console filters can narrow on either part (`executor-compat`, `executor-compat:specs`, `ws:dispatch`, `git-status:store`).
- **Register the namespace** in the cheat-sheet docblock at the top of `lib/debug/log.ts`. Future agents triage by reading that list — an unregistered namespace is invisible.
- **One log per event**, not per render frame. The factory is no-op in prod but allocates a closure per call in dev — still cheap, but per-render-frame logs spam the console and slow devtools.
- **Guard expensive arg computation** with `if (IS_DEBUG) { ... }`. The `debug()` call itself is free, but JS evaluates args first.
### ✅ Correct — flat primitives as a single object arg
```typescript
debug("loaded", {
count: specs.length,
ids: specs.map((s) => s.id).join(",") || "-",
});
// → [executor-compat:specs] loaded count=7 ids=gh_cli,claude-acp,codex-acp,…
```
### ✅ Correct — guard expensive aggregation
```typescript
if (IS_DEBUG) {
debug("compute", {
input: profiles.length,
output: filtered.length,
blocked: profiles.filter((p) => !ok(p)).map((p) => p.id).join(","),
});
}
```
### ❌ Wrong — passing a nested object that won't flatten
```typescript
debug("compute", { profile, specs }); // logfmt JSON.stringifies, hard to read
```
Pre-extract the fields you actually care about (`profile.id`, `specs.length`, ...).
### ❌ Wrong — log per render
```typescript
function MyComponent() {
debug("render", { ... }); // fires on every render — use useEffect or event handler
...
}
```
## Temporary — Frontend (`console.log`)
- **Level:** `console.log` — not `console.debug` (hidden by default) or `console.warn` (noisy).
- **Prefix:** `[area-bug]` / `[AREA-DEBUG]` agreed with the user.
@@ -103,32 +175,35 @@ s.logger.Error("[DEBUG] handleTaskMoved", "task_id", taskID) // ← triggers ale
## Quick Checklist (apply before every debug log you add)
- [ ] Does the prefix match what's agreed (or already in the file) so all logs are greppable?
- [ ] Have you picked the right flavour — **temporary** (`console.log` / `[DEBUG] Warn`, stripped before PR) or **persistent** (`createDebugLogger`, register namespace, stays)?
- [ ] Does the prefix/namespace match what's already in the file (or what you agreed with the user) so all logs are greppable?
- [ ] Is every value a primitive at log time? If not, pre-format with `.map().join()`, `JSON.stringify`, `strings.Join`, or `fmt.Sprintf`.
- [ ] Frontend: `console.log` (not `warn`/`debug`)? Backend: `Warn` (not `Debug`/`Error`)?
- [ ] Is the call site the cheapest possible — one log per event, not per render frame?
- [ ] Temporary frontend: `console.log` (not `warn`/`debug`)? Persistent frontend: `createDebugLogger(...)`? Backend temp: `Warn` (not `Debug`/`Error`)?
- [ ] Is the call site the cheapest possible — one log per event, not per render frame? For persistent logs with expensive arg computation, guarded with `if (IS_DEBUG)`?
- [ ] Persistent: did you register the namespace in the cheat-sheet docblock at the top of `apps/web/lib/debug/log.ts`?
## Workflow
1. **Add debug logs** to the relevant code paths. Do not commit them — keep them as unstaged changes.
2. **Let the user test** the app and report back with console/log output.
3. **Iterate** — add, move, or refine logs as needed based on findings. Still no commits. **If the user reports the values are unreadable (`Array(2)`, `Object`, `{...}`), the previous log violated rule 3; rewrite as a template literal before re-running.**
4. **Fix the issue** once the root cause is identified.
5. **Strip all debug logs** before committing the fix. Only commit the actual fix.
1. **Pick the flavour** (temporary vs persistent — see decision table at top).
2. **Add the logs** to the relevant code paths. For temporary, keep them as unstaged changes. For persistent, treat them as normal code (review, typecheck, commit).
3. **Let the user test** the app and report back with console/log output.
4. **Iterate** — add, move, or refine logs as needed based on findings. **If the user reports the values are unreadable (`Array(2)`, `Object`, `{...}`), the previous log violated rule 3; pre-format the value before re-running.**
5. **Fix the issue** once the root cause is identified.
6. **Strip temporary debug logs** before committing the fix. Commit the actual fix; persistent logs stay.
## Stripping Debug Logs
## Stripping Temporary Debug Logs
When the issue is fixed and the user asks to commit, remove all debug logs first:
When the issue is fixed and the user asks to commit, remove all **temporary** debug logs first. **Do not strip `createDebugLogger` calls or backend `logger.Debug(...)` calls** — those are intentional persistent instrumentation.
```bash
# Find frontend debug logs
# Find temporary frontend debug logs
grep -rn 'console.log("\[WS-DEBUG\]' apps/web/
# Find backend debug logs
# Find temporary backend debug logs
grep -rn '\[DEBUG\]' apps/backend/
# Or use the prefix agreed with the user
grep -rn '\[AREA-DEBUG\]' apps/
```
Verify no debug logs remain in staged files before proceeding with `/commit` or `/pr`.
Verify no temporary debug logs remain in staged files before proceeding with `/commit` or `/pr`. If you accidentally added a persistent logger when the user wanted temporary (or vice versa), convert it before committing.
+1 -27
View File
@@ -121,34 +121,9 @@ else
endif
endif
# Idempotent: wires the pre-commit framework into git's hook system
# (.pre-commit-config.yaml at the repo root drives it). Runs as a
# dependency of `dev` so every fresh clone / worktree picks up the
# format + lint hooks the first time a contributor starts the dev
# server, without anyone having to remember a setup step.
#
# Silent unless pre-commit is missing — in that case it prints a
# one-line note instead of failing, because `make dev` should not
# block on an optional tool. CI still runs the real linters.
#
# Quirks handled:
# - pre-commit refuses to install when core.hooksPath is set, even
# when it's set to the default location (a leftover from a stale
# setup). Detect that exact case and unset it before installing.
# - core.hooksPath is shared between the main repo and every
# worktree, so a single install covers all of them.
.PHONY: doctor
doctor:
@if ! command -v pre-commit >/dev/null 2>&1; then \
echo "⚠ pre-commit not found on PATH — install with 'pip install pre-commit' to enable hook-based format/lint on commit."; \
exit 0; \
fi; \
default_hooks="$$(git rev-parse --git-common-dir)/hooks"; \
current="$$(git config --get core.hooksPath 2>/dev/null || true)"; \
if [ -n "$$current" ] && [ "$$current" = "$$default_hooks" ]; then \
git config --unset core.hooksPath 2>/dev/null || true; \
fi; \
pre-commit install -t pre-commit -t commit-msg --overwrite >/dev/null 2>&1 || true
@scripts/doctor
.PHONY: dev
dev: doctor
@@ -167,7 +142,6 @@ endif
dev-prod-db: export KANDEV_DATABASE_PATH := $(HOME)/.kandev/data/kandev.db
dev-prod-db:
@echo "⚠ dev mode against PRODUCTION db at $(KANDEV_DATABASE_PATH)"
@echo " back it up first; dev binary may run unmigrated schema changes"
@$(MAKE) dev
.PHONY: dev-backend
@@ -97,10 +97,29 @@ func (a *CursorACP) Runtime() *RuntimeConfig {
}
}
func (a *CursorACP) RemoteAuth() *RemoteAuth { return nil }
func (a *CursorACP) RemoteAuth() *RemoteAuth {
return &RemoteAuth{
Methods: []RemoteAuthMethod{
{
Type: "env",
EnvVar: "CURSOR_API_KEY",
SetupHint: "Create an API key at https://cursor.com/dashboard/integrations (Cursor Pro).",
},
},
}
}
// cursor-agent isn't on npm. The official installer drops the binary into
// ~/.local/bin; make sure that dir is on PATH for the rest of the prepare
// script and for future shells on the sprite.
func (a *CursorACP) InstallScript() string {
return "curl https://cursor.com/install -fsS | bash"
return `set -e
tmp="$(mktemp)"
curl -fsS https://cursor.com/install -o "$tmp"
bash "$tmp"
rm -f "$tmp"
export PATH="$HOME/.local/bin:$PATH"
grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc"`
}
func (a *CursorACP) PermissionSettings() map[string]PermissionSetting {
@@ -0,0 +1,35 @@
package agents
import (
"strings"
"testing"
)
func TestCursorACPRemoteAuth(t *testing.T) {
auth := NewCursorACP().RemoteAuth()
if auth == nil {
t.Fatal("RemoteAuth() returned nil; expected env-var auth method")
}
if len(auth.Methods) != 1 {
t.Fatalf("Methods len = %d, want 1", len(auth.Methods))
}
m := auth.Methods[0]
if m.Type != "env" {
t.Errorf("Type = %q, want %q", m.Type, "env")
}
if m.EnvVar != "CURSOR_API_KEY" {
t.Errorf("EnvVar = %q, want %q", m.EnvVar, "CURSOR_API_KEY")
}
}
func TestCursorACPInstallScriptIsNativeInstaller(t *testing.T) {
script := NewCursorACP().InstallScript()
if !strings.Contains(script, "cursor.com/install") {
t.Errorf("InstallScript must reference the cursor.com installer, got: %q", script)
}
// Ensure ~/.local/bin gets onto PATH so the cursor-agent binary is
// discoverable for subsequent prepare-script steps and shells.
if !strings.Contains(script, "$HOME/.local/bin") {
t.Errorf("InstallScript must add $HOME/.local/bin to PATH, got: %q", script)
}
}
@@ -76,7 +76,16 @@ var newACPAgentSpecs = []struct {
inferenceArgv: []string{"cursor-agent", "acp"},
passthroughArgv: []string{"cursor-agent"},
installViaNpm: false,
installScript: "curl https://cursor.com/install -fsS | bash",
// Multi-line installer: pulls the script to a tempfile, executes it,
// then exports + persists PATH so subsequent prepare-script steps see
// cursor-agent. Matches the script in CursorACP.InstallScript().
installScript: `set -e
tmp="$(mktemp)"
curl -fsS https://cursor.com/install -o "$tmp"
bash "$tmp"
rm -f "$tmp"
export PATH="$HOME/.local/bin:$PATH"
grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc"`,
}},
{func() Agent { return NewKimiACP() }, acpAgentSpec{
id: "kimi-acp", displayName: "Kimi", detectBinaries: []string{"kimi"},
@@ -100,7 +100,21 @@ func (a *OpenCodeACP) Runtime() *RuntimeConfig {
}
}
func (a *OpenCodeACP) RemoteAuth() *RemoteAuth { return nil }
func (a *OpenCodeACP) RemoteAuth() *RemoteAuth {
return &RemoteAuth{
Methods: []RemoteAuthMethod{
{
Type: "files",
Label: "Copy auth files",
SourceFiles: map[string][]string{
"darwin": {".local/share/opencode/auth.json"},
"linux": {".local/share/opencode/auth.json"},
},
TargetRelDir: ".local/share/opencode",
},
},
}
}
func (a *OpenCodeACP) InstallScript() string {
return "npm install -g " + opencodeACPPkg
@@ -0,0 +1,30 @@
package agents
import (
"slices"
"testing"
)
func TestOpenCodeACPRemoteAuth(t *testing.T) {
auth := NewOpenCodeACP().RemoteAuth()
if auth == nil {
t.Fatal("RemoteAuth() returned nil; expected files-based auth method")
}
if len(auth.Methods) != 1 {
t.Fatalf("Methods len = %d, want 1", len(auth.Methods))
}
m := auth.Methods[0]
if m.Type != "files" {
t.Errorf("Type = %q, want %q", m.Type, "files")
}
if m.TargetRelDir != ".local/share/opencode" {
t.Errorf("TargetRelDir = %q, want %q", m.TargetRelDir, ".local/share/opencode")
}
want := []string{".local/share/opencode/auth.json"}
for _, os := range []string{"darwin", "linux"} {
got := m.SourceFiles[os]
if !slices.Equal(got, want) {
t.Errorf("SourceFiles[%q] = %v, want %v", os, got, want)
}
}
}
@@ -88,6 +88,7 @@ func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string
spec := Spec{
ID: ag.ID(),
DisplayName: ag.DisplayName(),
Methods: []Method{}, // ensure JSON marshals as [] not null when an agent (e.g. mock) declares no methods
}
fileMethodIndex := 0
@@ -0,0 +1,39 @@
package remoteauth
import (
"encoding/json"
"strings"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
)
// Agents that declare RemoteAuth with no Methods (e.g. mock) must still
// serialize "methods": [] rather than "methods": null — the frontend reads
// spec.methods.length and crashes on null.
func TestBuildCatalog_MockSpecSerializesEmptyMethodsAsArray(t *testing.T) {
mockAgent := agents.NewMockAgent()
cat := BuildCatalogForHost([]agents.Agent{mockAgent}, "linux", "")
var mock *Spec
for i, s := range cat.Specs {
if s.ID == mockAgent.ID() {
mock = &cat.Specs[i]
break
}
}
if mock == nil {
t.Fatal("mock spec missing from catalog")
}
if mock.Methods == nil {
t.Fatal("mock.Methods is nil; should be a non-nil empty slice for stable JSON shape")
}
out, err := json.Marshal(mock)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(out), `"methods":[]`) {
t.Errorf("expected `\"methods\":[]` in JSON, got: %s", out)
}
}
@@ -47,10 +47,22 @@ type commandOutputRunner interface {
}
const (
spritesAgentctlPort = 8765
spritesWorkspacePath = "/workspace"
spritesNamePrefix = "kandev-"
spriteStepTimeout = 120 * time.Second
spritesAgentctlPort = 8765
spritesWorkspacePath = "/workspace"
spritesNamePrefix = "kandev-"
// spriteStepTimeout caps cheap one-shot API calls (create sprite, reconnect,
// network policy). These are pure JSON round-trips; if they exceed 2 minutes
// the API is unhealthy and retrying longer won't help.
spriteStepTimeout = 120 * time.Second
// spriteUploadTimeout caps file uploads from the user's machine to the
// sprite (agentctl ~21 MB, credential files, skill files). Sized for slow
// home connections — a 21 MB push needs ~1.4 Mbit/s to fit in 2 min, which
// many residential uplinks can't sustain.
spriteUploadTimeout = 10 * time.Minute
// spritePrepareTimeout caps the prepare script run inside the sprite,
// which does git clones and npm/curl agent installs over the sprite's
// internet. Five+ agent installs commonly run past 2 minutes.
spritePrepareTimeout = 10 * time.Minute
spriteHealthTimeout = 15 * time.Second
spriteDestroyTimeout = 30 * time.Second
spriteHealthRetryWait = 500 * time.Millisecond
@@ -63,7 +63,7 @@ func (r *SpritesExecutor) uploadCredentials(
return nil
}
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
stepCtx, cancel := context.WithTimeout(ctx, spriteUploadTimeout)
defer cancel()
uploader := &spriteFileUploader{sprite: sprite, runtime: r}
@@ -46,7 +46,7 @@ func (r *SpritesExecutor) uploadAgentctl(ctx context.Context, sprite *sprites.Sp
return fmt.Errorf("failed to read agentctl binary: %w", err)
}
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
stepCtx, cancel := context.WithTimeout(ctx, spriteUploadTimeout)
defer cancel()
r.logger.Debug("uploading agentctl binary", zap.Int("size_bytes", len(data)))
@@ -100,7 +100,7 @@ func (r *SpritesExecutor) uploadSkillFiles(
return fmt.Errorf("unmarshal skill manifest: %w", err)
}
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
stepCtx, cancel := context.WithTimeout(ctx, spriteUploadTimeout)
defer cancel()
projectSkillDir := manifest.ProjectSkillDir
@@ -192,7 +192,7 @@ func (r *SpritesExecutor) runPrepareScript(
return nil
}
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
stepCtx, cancel := context.WithTimeout(ctx, spritePrepareTimeout)
defer cancel()
r.logger.Debug("running prepare script")
@@ -56,7 +56,16 @@ func (s *Service) dispatchWatcherEvent(ctx context.Context, integration string,
s.logger.Warn(fmt.Sprintf("issue task creator not configured, skipping %s task creation", integration))
return
}
// Capture the coordinator pointer locally. issueTaskCreator is set before
// initWatcherCoordinator in SetIssueTaskCreator, so a concurrent bus event
// could otherwise see issueTaskCreator non-nil while watcherCoordinator is
// still nil and crash on the goroutine dispatch below.
coordinator := s.watcherCoordinator
if coordinator == nil {
s.logger.Warn(fmt.Sprintf("watcher coordinator not configured, skipping %s task creation", integration))
return
}
// Detach from cancellation but keep request-scoped values (tracing, etc.):
// the bus delivery context may be cancelled before task creation finishes.
go s.watcherCoordinator.Dispatch(context.WithoutCancel(ctx), src, evt)
go coordinator.Dispatch(context.WithoutCancel(ctx), src, evt)
}
+60
View File
@@ -0,0 +1,60 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { backupProductionDb, isProductionDb } from "./backup";
describe("isProductionDb", () => {
it("returns false for dev-isolated paths under .kandev-dev", () => {
expect(isProductionDb("/repo/.kandev-dev/data/kandev.db")).toBe(false);
expect(isProductionDb("/.kandev-dev/kandev.db")).toBe(false);
});
it("returns true for production and custom paths", () => {
expect(isProductionDb("/home/user/.kandev/data/kandev.db")).toBe(true);
expect(isProductionDb("/tmp/custom.db")).toBe(true);
expect(isProductionDb("/data/kandev.db")).toBe(true);
});
});
describe("backupProductionDb", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-backup-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("returns null when the db does not exist", () => {
const result = backupProductionDb(path.join(tmpDir, "missing.db"));
expect(result).toBeNull();
});
it("creates a backup and keeps only the 5 newest", () => {
const dbPath = path.join(tmpDir, ".kandev", "data", "kandev.db");
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.writeFileSync(dbPath, "original-db-content");
// Create 6 backups with explicit per-iteration timestamps so both the
// filename clock and mtime advance deterministically - no spin loop, no
// dependence on Date.now() resolution.
for (let i = 0; i < 6; i++) {
const stamp = new Date(1_700_000_000_000 + i * 1000);
const result = backupProductionDb(dbPath, tmpDir, stamp);
expect(result).not.toBeNull();
}
const backupDir = path.join(tmpDir, ".kandev", "data", "backups");
const files = fs
.readdirSync(backupDir)
.filter((f) => f.startsWith("dev-prod-db-") && f.endsWith(".db"))
.sort();
expect(files.length).toBe(5);
});
});
+101
View File
@@ -0,0 +1,101 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Prefix for dev-prod-db automatic snapshots. Distinct from the backend's
// "kandev-*" auto-snapshots and "manual-*" user snapshots so the families
// don't interfere with each other's retention policies.
const BACKUP_PREFIX = "dev-prod-db-";
const BACKUP_SUFFIX = ".db";
const MAX_BACKUPS = 5;
/**
* Returns true if the given dbPath points to a non-dev database that should
* be backed up before running dev mode. Dev-isolated databases live under
* <repo>/.kandev-dev/ and are considered disposable.
*/
export function isProductionDb(dbPath: string): boolean {
const normalized = path.normalize(dbPath);
const segments = normalized.split(path.sep).filter(Boolean);
return !segments.includes(".kandev-dev");
}
/**
* Backs up the database at dbPath to <data-dir>/backups/ before dev mode
* runs against it. Keeps only the newest MAX_BACKUPS snapshots.
*
* Returns the path of the created backup, or null if the DB didn't exist
* or no backup was needed.
*
* The optional `homeDir` parameter is exposed for tests so they can redirect
* the backup location without mocking os.homedir(). The optional `now`
* parameter lets tests pass an explicit timestamp so back-to-back calls in
* the same millisecond produce distinct filenames + mtimes without sleeping.
*/
export function backupProductionDb(dbPath: string, homeDir?: string, now?: Date): string | null {
if (!fs.existsSync(dbPath)) {
return null;
}
const root = homeDir ?? os.homedir();
// Backups are always placed under ~/.kandev/data/backups/ (or the
// caller-supplied homeDir in tests), even when dbPath points elsewhere.
// This matches the dev-prod-db default flow; custom KANDEV_DATABASE_PATH
// values are advanced usage where the user is responsible for backup location.
const dataDir = path.join(root, ".kandev", "data");
const backupDir = path.join(dataDir, "backups");
fs.mkdirSync(backupDir, { recursive: true });
const stamp = now ?? new Date();
const ts = stamp.toISOString().replace(/[:.]/g, "");
const name = `${BACKUP_PREFIX}${ts}${BACKUP_SUFFIX}`;
const dest = path.join(backupDir, name);
fs.copyFileSync(dbPath, dest);
// Stamp both atime + mtime so pruning (which sorts by mtime) is deterministic.
fs.utimesSync(dest, stamp, stamp);
pruneBackups(backupDir, MAX_BACKUPS);
return dest;
}
/**
* Keeps only the `keep` newest dev-prod-db backup files in `dir`, deleting
* older ones. Non-matching files are left untouched.
*/
function pruneBackups(dir: string, keep: number): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
const files = entries
.filter((e) => e.isFile() && e.name.startsWith(BACKUP_PREFIX) && e.name.endsWith(BACKUP_SUFFIX))
.map((e) => {
const fullPath = path.join(dir, e.name);
try {
const stat = fs.statSync(fullPath);
return { path: fullPath, mtime: stat.mtime };
} catch {
return null;
}
})
.filter((f): f is { path: string; mtime: Date } => f !== null);
if (files.length <= keep) {
return;
}
files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
for (const f of files.slice(keep)) {
try {
fs.unlinkSync(f.path);
} catch {
// Non-critical: don't fail the launch if one old backup can't be removed.
}
}
}
+18
View File
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { backupProductionDb, isProductionDb } from "./backup";
import { devKandevHome, HEALTH_TIMEOUT_MS_DEV } from "./constants";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
import { isInsideKandevTask } from "./kandev-env";
@@ -24,6 +25,23 @@ export type DevOptions = {
export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise<void> {
const ports = await pickPorts(backendPort, webPort);
const { dbPath, extra } = resolveDevBackendEnv(repoRoot);
if (isProductionDb(dbPath)) {
try {
const backupPath = backupProductionDb(dbPath);
if (backupPath) {
const name = path.basename(backupPath);
console.log(`[kandev] backed up production db → ${name}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Abort rather than continue: the backup exists precisely to protect the
// production db before dev mode touches it. Proceeding on failure would
// remove the safety guarantee that justified introducing this guard.
throw new Error(`failed to back up production db (${message}); aborting dev startup`);
}
}
const backendEnv = buildBackendEnv({ ports, extra });
const webEnv = buildWebEnv({ ports, debug: true });
const logLevel =
+9
View File
@@ -137,6 +137,15 @@ describe("resolveRuntime", () => {
describe("no runtime found", () => {
it("throws an actionable error message mentioning install paths", () => {
// Ensure require.resolve fails so we reach the "no runtime found" path.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ModuleAny = Module as unknown as { _resolveFilename: any };
vi.spyOn(ModuleAny, "_resolveFilename").mockImplementationOnce(() => {
const err = new Error("Cannot find module") as Error & { code: string };
err.code = "MODULE_NOT_FOUND";
throw err;
});
let error: Error | null = null;
try {
resolveRuntime();
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
IconCheck,
IconX,
@@ -17,11 +17,14 @@ import { cn } from "@/lib/utils";
import { stripAnsi } from "@/lib/utils/ansi";
import { isSetupScriptMessage } from "@/hooks/use-processed-messages";
import type { Message } from "@/lib/types/http";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
import type {
PrepareStepInfo,
SessionPrepareState,
} from "@/lib/state/slices/session-runtime/types";
const debug = createDebugLogger("chat:prepare-progress");
function formatStepDuration(startedAt?: string, endedAt?: string): string | null {
if (!startedAt || !endedAt) return null;
const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
@@ -411,6 +414,44 @@ function SessionInfo({ sessionId }: { sessionId: string }) {
);
}
type PrepareSnapshot = {
status: string;
autoExpand: boolean;
expanded: boolean;
visibleSteps: number;
hasPrepareState: boolean;
};
function prepareSnapshotChanged(prev: PrepareSnapshot | null, next: PrepareSnapshot): boolean {
if (!prev) return true;
return (
prev.status !== next.status ||
prev.autoExpand !== next.autoExpand ||
prev.expanded !== next.expanded ||
prev.visibleSteps !== next.visibleSteps ||
prev.hasPrepareState !== next.hasPrepareState
);
}
function logPrepareSnapshotChange(
prev: PrepareSnapshot | null,
next: PrepareSnapshot,
sessionId: string,
rawPrepareStatus: string,
sessionState: string,
) {
debug(prev ? "transition" : "init", {
sessionId,
...next,
prevStatus: prev?.status ?? "-",
prevAutoExpand: prev?.autoExpand ?? null,
prevExpanded: prev?.expanded ?? null,
prevVisibleSteps: prev?.visibleSteps ?? -1,
rawPrepareStatus,
sessionState,
});
}
export function PrepareProgress({ sessionId }: PrepareProgressProps) {
const { status, prepareState } = usePrepareStatus(sessionId);
const session = useAppStore((state) => state.taskSessions.items[sessionId]);
@@ -420,6 +461,31 @@ export function PrepareProgress({ sessionId }: PrepareProgressProps) {
const autoExpand = status !== "completed";
const [expanded, setExpanded] = useExpandedFlag(sessionId, autoExpand);
// Track status/expand transitions over the panel's lifetime — the
// remote-executor bug suspects the panel staying expanded (tall) while the
// env is still "preparing" combined with a Virtuoso initial-scroll race.
const prevSnapshotRef = useRef<PrepareSnapshot | null>(null);
useEffect(() => {
if (!IS_DEBUG) return;
const snapshot: PrepareSnapshot = {
status,
autoExpand,
expanded,
visibleSteps: prepareState ? prepareState.steps.filter(isVisibleStep).length : 0,
hasPrepareState: Boolean(prepareState),
};
const prev = prevSnapshotRef.current;
if (!prepareSnapshotChanged(prev, snapshot)) return;
logPrepareSnapshotChange(
prev,
snapshot,
sessionId,
prepareState?.status ?? "-",
session?.state ?? "-",
);
prevSnapshotRef.current = snapshot;
}, [sessionId, status, autoExpand, expanded, prepareState, session?.state]);
if (!prepareState) return null;
const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch);
@@ -0,0 +1,199 @@
"use client";
import { useEffect } from "react";
import type { AgentProfileOption } from "@/lib/state/slices";
import { getLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import { createDebugLogger } from "@/lib/debug/log";
import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types";
/**
* Agent-profile autopick: the logic that decides which profile to pre-select
* when the task-create dialog opens (or when its inputs change). Lives in its
* own file so `task-create-dialog-effects.ts` stays under the 600-line lint
* cap. Tests in `task-create-dialog-effects.test.ts` exercise both effects
* through the same import surface re-exported from there.
*/
const autopickDebug = createDebugLogger("executor-compat:autopick");
const workflowAutopickDebug = createDebugLogger("executor-compat:workflow-autopick");
/**
* Pure decision function for the agent-profile autopick. Extracted so the
* effect can stay below the 100-line lint cap once the autopick trace logs
* are inlined, and so the same decision can be tested without rendering.
*
* Order: lastId (localStorage) → defId (workspace default) → first
* compatible. Every candidate is filtered against `compatibleAgentProfiles`
* so a previously-used profile that's not wired for the chosen executor
* isn't restored, then immediately fails the executor-compat gate.
*/
export type AutopickDecision =
| { kind: "skip"; reason: string }
| { kind: "defer"; reason: string }
| { kind: "pick"; source: "lastId" | "defId" | "first"; id: string };
export function decideAgentProfileAutopick(input: {
open: boolean;
agentProfileId: string;
workflowAgentProfileId: string;
workflowHasAgent: boolean;
agentProfiles: AgentProfileOption[];
compatibleAgentProfiles: AgentProfileOption[];
authLoaded: boolean;
executorProfileId: string;
hasExecutors: boolean;
defaultAgentProfileId: string | null;
}): AutopickDecision {
if (!input.open) return { kind: "skip", reason: "closed" };
if (input.agentProfileId) return { kind: "skip", reason: "already-set" };
if (input.workflowAgentProfileId) return { kind: "skip", reason: "workflow-locked" };
if (input.workflowHasAgent) return { kind: "skip", reason: "workflow-has-agent" };
if (input.agentProfiles.length === 0) return { kind: "skip", reason: "no-profiles" };
if (!input.authLoaded) return { kind: "defer", reason: "auth-not-loaded" };
// Defer until the executor profile is selected too - useExecutorProfileCompat
// short-circuits to the unfiltered list when `selectedExecutorProfile` is null,
// so without this gate we'd happily restore an incompatible lastId during the
// single render where `authLoaded` is already true but the executor
// auto-select hasn't queued through yet. Only deferrable if there ARE
// executors to pick from - otherwise the executor effect will never fire and
// we'd defer forever.
if (!input.executorProfileId && input.hasExecutors) {
return { kind: "defer", reason: "executor-not-selected" };
}
if (input.compatibleAgentProfiles.length === 0) return { kind: "skip", reason: "no-compatible" };
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
if (lastId && input.compatibleAgentProfiles.some((p) => p.id === lastId)) {
return { kind: "pick", source: "lastId", id: lastId };
}
const defId = input.defaultAgentProfileId;
if (defId && input.compatibleAgentProfiles.some((p) => p.id === defId)) {
return { kind: "pick", source: "defId", id: defId };
}
return { kind: "pick", source: "first", id: input.compatibleAgentProfiles[0].id };
}
export function useWorkflowAgentProfileEffect(
fs: DialogFormState,
workflows: Array<{ id: string; agent_profile_id?: string }>,
agentProfiles: AgentProfileOption[],
compatibleAgentProfiles: AgentProfileOption[],
) {
const { selectedWorkflowId, setAgentProfileId, setWorkflowAgentProfileId } = fs;
useEffect(() => {
if (!selectedWorkflowId) {
setWorkflowAgentProfileId("");
workflowAutopickDebug("no-workflow", { cleared: "workflowAgentProfileId" });
return;
}
const workflow = workflows.find((w) => w.id === selectedWorkflowId);
if (workflow?.agent_profile_id) {
// Always lock the selector when the workflow specifies an agent profile.
// This prevents the race condition where agentProfiles hasn't loaded yet.
setWorkflowAgentProfileId(workflow.agent_profile_id);
// Only set the agentProfileId once the profile is confirmed available.
const profileExists = agentProfiles.some((p) => p.id === workflow.agent_profile_id);
if (profileExists) {
setAgentProfileId(workflow.agent_profile_id);
workflowAutopickDebug("locked", {
workflow: selectedWorkflowId,
set_to: workflow.agent_profile_id,
});
} else {
workflowAutopickDebug("locked-missing", {
workflow: selectedWorkflowId,
missing: workflow.agent_profile_id,
});
}
} else {
setWorkflowAgentProfileId("");
// Restore the user's last-used agent profile when unlocking. Filter
// against `compatibleAgentProfiles` (not the full `agentProfiles` list)
// so an executor-incompatible id from a previous session - including
// stale UUIDs from a wiped DB - is dropped rather than restored.
// useDefaultSelectionsEffect would otherwise see agentProfileId become
// truthy, early-exit on "already-set", and leave the dialog stuck on
// "No compatible agent profiles".
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
const isValidLastId = Boolean(lastId && compatibleAgentProfiles.some((p) => p.id === lastId));
const finalId = isValidLastId && lastId ? lastId : "";
setAgentProfileId(finalId);
workflowAutopickDebug("workflow-no-override", {
last_id: lastId ?? "-",
valid: isValidLastId,
set_to: finalId || "-empty-",
});
}
}, [
selectedWorkflowId,
workflows,
agentProfiles,
compatibleAgentProfiles,
setAgentProfileId,
setWorkflowAgentProfileId,
]);
}
export function useAgentProfileAutopickEffect(
fs: DialogFormState,
open: boolean,
sel: StoreSelections,
workflows: Array<{ id: string; agent_profile_id?: string }>,
) {
const { agentProfiles, compatibleAgentProfiles, authLoaded, executors, workspaceDefaults } = sel;
const {
agentProfileId,
workflowAgentProfileId,
selectedWorkflowId,
executorProfileId,
setAgentProfileId,
} = fs;
useEffect(() => {
// Check synchronously whether the selected workflow has an agent override.
// This avoids a race condition where workflowAgentProfileId state hasn't
// been committed yet by the workflow effect running in the same cycle.
const workflowHasAgent = selectedWorkflowId
? workflows.some((w) => w.id === selectedWorkflowId && w.agent_profile_id)
: false;
const decision = decideAgentProfileAutopick({
open,
agentProfileId,
workflowAgentProfileId,
workflowHasAgent,
agentProfiles,
compatibleAgentProfiles,
authLoaded,
executorProfileId,
hasExecutors: executors.length > 0,
defaultAgentProfileId: workspaceDefaults?.default_agent_profile_id ?? null,
});
autopickDebug(decision.kind, {
reason: decision.kind === "pick" ? decision.source : decision.reason,
pick: decision.kind === "pick" ? decision.id : "-",
current: agentProfileId || "-",
workflow_id: selectedWorkflowId ?? "-",
executor_profile_id: executorProfileId || "-",
agent_count: agentProfiles.length,
compat_count: compatibleAgentProfiles.length,
auth_loaded: authLoaded,
});
if (decision.kind === "pick") {
const id = decision.id;
void Promise.resolve().then(() => setAgentProfileId(id));
}
}, [
open,
agentProfileId,
workflowAgentProfileId,
selectedWorkflowId,
workflows,
agentProfiles,
compatibleAgentProfiles,
authLoaded,
executorProfileId,
executors,
workspaceDefaults,
setAgentProfileId,
]);
}
@@ -1,12 +1,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { renderHook, waitFor } from "@testing-library/react";
import {
useAutoFillTaskNameFromPR,
useBranchAutoSelectEffect,
useDefaultSelectionsEffect,
useWorkflowAgentProfileEffect,
} from "./task-create-dialog-effects";
import type { DialogFormState } from "@/components/task-create-dialog-types";
import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { Workspace } from "@/lib/types/http";
import { STORAGE_KEYS } from "@/lib/settings/constants";
// Minimal fake of DialogFormState - the hook destructures only three fields,
@@ -43,7 +45,9 @@ describe("useWorkflowAgentProfileEffect", () => {
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify("stale-id"));
const fs = makeFs({ selectedWorkflowId: null });
renderHook(() => useWorkflowAgentProfileEffect(fs, [], [makeProfile("real-id")]));
renderHook(() =>
useWorkflowAgentProfileEffect(fs, [], [makeProfile("real-id")], [makeProfile("real-id")]),
);
expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith("");
// No workflow → effect must early-return before touching agentProfileId,
@@ -55,7 +59,14 @@ describe("useWorkflowAgentProfileEffect", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1", agent_profile_id: "real-id" }];
renderHook(() => useWorkflowAgentProfileEffect(fs, workflows, [makeProfile("real-id")]));
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith("real-id");
expect(fs.setAgentProfileId).toHaveBeenCalledWith("real-id");
@@ -65,7 +76,14 @@ describe("useWorkflowAgentProfileEffect", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1", agent_profile_id: "missing-id" }];
renderHook(() => useWorkflowAgentProfileEffect(fs, workflows, [makeProfile("real-id")]));
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
// The lock still applies (otherwise the user could pick an alternate
// profile and submit a workflow-locked task with the wrong agent).
@@ -78,7 +96,14 @@ describe("useWorkflowAgentProfileEffect", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
renderHook(() => useWorkflowAgentProfileEffect(fs, workflows, [makeProfile("real-id")]));
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith("");
expect(fs.setAgentProfileId).toHaveBeenCalledWith("real-id");
@@ -94,16 +119,50 @@ describe("useWorkflowAgentProfileEffect", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
renderHook(() => useWorkflowAgentProfileEffect(fs, workflows, [makeProfile("real-id")]));
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith("stale-id");
});
it("does NOT restore a lastId that exists but is incompatible with the current executor", () => {
// Regression for #1075 (CodeRabbit): if lastId names a profile present in
// `agentProfiles` but absent from `compatibleAgentProfiles` (e.g. user
// switched from a local executor to a remote one without the matching
// credential), workflow-unlock used to restore it anyway, which made
// useDefaultSelectionsEffect early-exit on "already-set" and leave the
// dialog stuck on "No compatible agent profiles".
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify("incompat-id"));
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
const all = [makeProfile("incompat-id"), makeProfile("compat-id")];
const compatible = [makeProfile("compat-id")];
renderHook(() => useWorkflowAgentProfileEffect(fs, workflows, all, compatible));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith("incompat-id");
// Empty hand-off lets useDefaultSelectionsEffect pick a compatible default.
expect(fs.setAgentProfileId).toHaveBeenCalledWith("");
});
it("clears agentProfileId when the workflow has no override and there is no last-used id", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
renderHook(() => useWorkflowAgentProfileEffect(fs, workflows, [makeProfile("real-id")]));
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
// Empty string lets useDefaultSelectionsEffect take over the fallback
// chain (workspace default → first profile).
@@ -111,6 +170,251 @@ describe("useWorkflowAgentProfileEffect", () => {
});
});
type DefaultSelFake = Pick<
DialogFormState,
| "agentProfileId"
| "workflowAgentProfileId"
| "selectedWorkflowId"
| "executorId"
| "executorProfileId"
| "setAgentProfileId"
| "setExecutorId"
| "setExecutorProfileId"
| "noRepository"
| "repositories"
>;
function makeDefaultSelFs(overrides: Partial<DefaultSelFake> = {}): DialogFormState {
return {
agentProfileId: "",
workflowAgentProfileId: "",
selectedWorkflowId: null,
executorId: "exec-1",
executorProfileId: "profile-1",
setAgentProfileId: vi.fn(),
setExecutorId: vi.fn(),
setExecutorProfileId: vi.fn(),
noRepository: false,
// Multi-repo guard effect inside useDefaultSelectionsEffect reads
// fs.repositories when executorProfileId is set. Empty list keeps that
// branch a no-op for these autopick-focused tests.
repositories: [],
...overrides,
} as unknown as DialogFormState;
}
function makeSel(overrides: Partial<StoreSelections> = {}): StoreSelections {
return {
agentProfiles: [],
compatibleAgentProfiles: [],
// Default to loaded=true so the bulk of existing-behavior tests don't
// each have to flip it explicitly. The dedicated deferral test overrides.
authLoaded: true,
executors: [],
workspaceDefaults: null,
...overrides,
};
}
describe("useDefaultSelectionsEffect — executor-aware agent restoration", () => {
it("restores lastId when it is compatible with the current executor", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(cursor.id));
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [cursor],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
});
it("falls back to first compatible when lastId is incompatible with the current executor (regression for sprites + Claude)", async () => {
// Real-world scenario: user last opened the dialog under local executor
// and picked Claude. Now they're switching to Sprites where Claude has
// no creds wired. Before the fix this restored Claude → noCompatibleAgent
// flipped true via the "selected agent not in compatible list" branch →
// dialog showed "No compatible agent profiles" despite Cursor/Codex being
// wired up.
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(claude.id));
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [cursor, codex],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
});
it("prefers the workspace default over the first compatible when lastId is incompatible but defId is compatible", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(claude.id));
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [cursor, codex],
workspaceDefaults: { default_agent_profile_id: codex.id } as unknown as Workspace,
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(codex.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(cursor.id);
});
it("skips defId when it is incompatible and falls back to first compatible", async () => {
const claude = makeProfile("claude");
const gemini = makeProfile("gemini");
const cursor = makeProfile("cursor");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(claude.id));
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, gemini, cursor],
compatibleAgentProfiles: [cursor],
// Workspace default points at an incompatible agent — bypass it and
// fall through to the first compatible one rather than restoring a
// selection that would itself trip the empty-state gate.
workspaceDefaults: { default_agent_profile_id: gemini.id } as unknown as Workspace,
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(gemini.id);
});
it("does not pick anything when no profile is compatible with the executor", async () => {
// The dialog renders the "No compatible agent profiles" empty state in
// this branch — silently picking an incompatible profile would just trip
// the same gate via the "selected agent not in compatible list" check,
// producing a worse UX (selector populated, but submit still blocked).
const claude = makeProfile("claude");
const gemini = makeProfile("gemini");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(claude.id));
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, gemini],
compatibleAgentProfiles: [],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
// Give any deferred setter a chance to fire so we'd catch an unwanted call.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
});
it("does nothing when agentProfileId is already set (user has explicitly picked)", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(cursor.id));
const fs = makeDefaultSelFs({ agentProfileId: claude.id });
const sel = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [cursor],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
});
});
describe("useDefaultSelectionsEffect — auth-spec load race", () => {
it("defers auto-pick while executorProfileId is empty even when authLoaded (regression for race that restored an incompatible lastId)", async () => {
// The other half of the executor-aware autopick race: `authLoaded` flips
// true before `executorProfileId` settles, because the executor
// auto-select runs as its own useEffect via a Promise.resolve().then()
// microtask. During that single render `useExecutorProfileCompat`
// short-circuits to `agentProfiles` (no filter) because
// `selectedExecutorProfile` is null, so without this gate we'd happily
// restore an incompatible lastId, then the executor lands milliseconds
// later, the filter narrows the list, and `noCompatibleAgent` flips true.
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(claude.id));
const fs = makeDefaultSelFs({ executorProfileId: "" });
// Mirror the production shape: specs loaded, no executor picked yet, so
// useExecutorProfileCompat returns agentProfiles unfiltered.
const selBefore = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [claude, cursor],
authLoaded: true,
executors: [{ id: "exec-1" } as unknown as StoreSelections["executors"][number]],
});
const { rerender } = renderHook(
({ sel, fs: fsArg }) => useDefaultSelectionsEffect(fsArg, true, sel, []),
{ initialProps: { sel: selBefore, fs } },
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
// Executor finally lands. Compat filter narrows the list. lastId=claude
// is no longer compatible; effect re-fires and picks the first compatible.
const fsAfter = makeDefaultSelFs({ executorProfileId: "profile-1" });
const selAfter = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [cursor],
authLoaded: true,
executors: [{ id: "exec-1" } as unknown as StoreSelections["executors"][number]],
});
rerender({ sel: selAfter, fs: fsAfter });
await waitFor(() => expect(fsAfter.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fsAfter.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
});
it("defers auto-pick until the remote-auth catalog has loaded (regression for race that restored an incompatible lastId)", async () => {
// Race the bundled effect hit in production: dialog opens, lastId=claude.
// While authLoaded=false, useExecutorProfileCompat short-circuits to
// `compatibleAgentProfiles = agentProfiles` (full list) → effect happily
// restores claude → specs land → filter kicks in → claude drops out of
// compatibleAgentProfiles → noCompatibleAgent flips true via the
// "selected-not-in-compatible" branch → "No compatible agent profiles"
// empty state shows even though Codex / OpenCode / Cursor are right there.
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify(claude.id));
const fs = makeDefaultSelFs();
// Phase 1: specs not yet loaded — compatibleAgentProfiles is the full
// list because the executor-compat hook returns agentProfiles when
// !authLoaded. The effect MUST NOT auto-pick yet.
const selBefore = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [claude, cursor, codex],
authLoaded: false,
});
const { rerender } = renderHook(({ sel }) => useDefaultSelectionsEffect(fs, true, sel, []), {
initialProps: { sel: selBefore },
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
// Phase 2: specs land. compatibleAgentProfiles narrows to the executor's
// actual valid set. Now the effect re-fires and must skip the
// incompatible lastId, landing on the first compatible profile.
const selAfter = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [cursor, codex],
authLoaded: true,
});
rerender({ sel: selAfter });
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
});
});
type BranchFake = Pick<
DialogFormState,
"githubBranch" | "githubBranches" | "useGitHubUrl" | "setGitHubBranch" | "githubPrHeadBranch"
@@ -2,57 +2,30 @@
import { useEffect, useRef } from "react";
import type { Repository, Executor } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils";
import { useToast } from "@/components/toast-provider";
import {
discoverRepositoriesAction,
getLocalRepositoryStatusAction,
} from "@/app/actions/workspaces";
import { getLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import { listWorkflowSteps } from "@/lib/api/domains/workflow-api";
import { fetchRepoBranches, fetchPRInfo } from "@/lib/api/domains/github-api";
import { getLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import type {
DialogFormState,
StoreSelections,
TaskCreateEffectsArgs,
} from "@/components/task-create-dialog-types";
import {
useAgentProfileAutopickEffect,
useWorkflowAgentProfileEffect,
} from "@/components/task-create-dialog-autopick";
export function useWorkflowAgentProfileEffect(
fs: DialogFormState,
workflows: Array<{ id: string; agent_profile_id?: string }>,
agentProfiles: AgentProfileOption[],
) {
const { selectedWorkflowId, setAgentProfileId, setWorkflowAgentProfileId } = fs;
useEffect(() => {
if (!selectedWorkflowId) {
setWorkflowAgentProfileId("");
return;
}
const workflow = workflows.find((w) => w.id === selectedWorkflowId);
if (workflow?.agent_profile_id) {
// Always lock the selector when the workflow specifies an agent profile.
// This prevents the race condition where agentProfiles hasn't loaded yet.
setWorkflowAgentProfileId(workflow.agent_profile_id);
// Only set the agentProfileId once the profile is confirmed available.
const profileExists = agentProfiles.some((p) => p.id === workflow.agent_profile_id);
if (profileExists) {
setAgentProfileId(workflow.agent_profile_id);
}
} else {
setWorkflowAgentProfileId("");
// Restore the user's last-used agent profile when unlocking, but only
// if it still exists. A clean DB mints fresh UUIDs for the same agents,
// so the previous run's id never matches and would otherwise poison
// the dialog into "No compatible agent profiles" because
// useDefaultSelectionsEffect skips when agentProfileId is truthy.
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
const isValidLastId = Boolean(lastId && agentProfiles.some((p) => p.id === lastId));
setAgentProfileId(isValidLastId && lastId ? lastId : "");
}
}, [selectedWorkflowId, workflows, agentProfiles, setAgentProfileId, setWorkflowAgentProfileId]);
}
// Re-export autopick hooks for callers that imported them from this module.
export { useWorkflowAgentProfileEffect };
// Also re-exported for the test file, which expects the symbol to live here.
export { decideAgentProfileAutopick } from "@/components/task-create-dialog-autopick";
export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) {
const { selectedWorkflowId, setFetchedSteps } = fs;
@@ -254,54 +227,9 @@ export function useDefaultSelectionsEffect(
sel: StoreSelections,
workflows: Array<{ id: string; agent_profile_id?: string }>,
) {
const { agentProfiles, executors, workspaceDefaults } = sel;
const {
agentProfileId,
workflowAgentProfileId,
selectedWorkflowId,
executorId,
executorProfileId,
setAgentProfileId,
setExecutorId,
setExecutorProfileId,
noRepository,
} = fs;
useEffect(() => {
// Check synchronously whether the selected workflow has an agent override.
// This avoids a race condition where workflowAgentProfileId state hasn't
// been committed yet by the workflow effect running in the same cycle.
const workflowHasAgent = selectedWorkflowId
? workflows.some((w) => w.id === selectedWorkflowId && w.agent_profile_id)
: false;
if (
!open ||
agentProfileId ||
workflowAgentProfileId ||
workflowHasAgent ||
agentProfiles.length === 0
)
return;
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
if (lastId && agentProfiles.some((p: AgentProfileOption) => p.id === lastId)) {
void Promise.resolve().then(() => setAgentProfileId(lastId));
return;
}
const defId = workspaceDefaults?.default_agent_profile_id ?? null;
if (defId && agentProfiles.some((p: AgentProfileOption) => p.id === defId)) {
void Promise.resolve().then(() => setAgentProfileId(defId));
return;
}
void Promise.resolve().then(() => setAgentProfileId(agentProfiles[0].id));
}, [
open,
agentProfileId,
workflowAgentProfileId,
selectedWorkflowId,
workflows,
agentProfiles,
workspaceDefaults,
setAgentProfileId,
]);
const { executors, workspaceDefaults } = sel;
const { executorId, executorProfileId, setExecutorId, setExecutorProfileId, noRepository } = fs;
useAgentProfileAutopickEffect(fs, open, sel, workflows);
useEffect(() => {
if (!open || executorId || executors.length === 0) return;
@@ -541,15 +469,29 @@ export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) {
export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) {
const { open, workspaceId, workflowId, repositories, repositoriesLoading } = args;
const { agentProfiles, executors, workspaceDefaults, toast, workflows, isLocalExecutor } = args;
const {
agentProfiles,
compatibleAgentProfiles,
authLoaded,
executors,
workspaceDefaults,
toast,
workflows,
isLocalExecutor,
} = args;
useWorkflowStepsEffect(fs, workflowId);
useWorkflowAgentProfileEffect(fs, workflows, agentProfiles);
useWorkflowAgentProfileEffect(fs, workflows, agentProfiles, compatibleAgentProfiles);
useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories);
useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast);
useBranchAutoSelectEffect(fs);
useCurrentLocalBranchEffect(fs, open, workspaceId, repositories);
useResetBranchOnLocalSwitchEffect(fs, isLocalExecutor, args.preserveBranch);
useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }, workflows);
useDefaultSelectionsEffect(
fs,
open,
{ agentProfiles, compatibleAgentProfiles, authLoaded, executors, workspaceDefaults },
workflows,
);
useGitHubUrlBranchesEffect(fs, open);
}
@@ -250,6 +250,8 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
repositories,
repositoriesLoading,
agentProfiles,
compatibleAgentProfiles: computed.compatibleAgentProfiles,
authLoaded: computed.authLoaded,
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
@@ -599,6 +599,8 @@ export function useDialogComputed({
effectiveAgentProfileId,
selectedExecutorProfileName: exec.selectedExecutorProfile?.name ?? null,
noCompatibleAgent: exec.noCompatibleAgent,
compatibleAgentProfiles: exec.compatibleAgentProfiles,
authLoaded: exec.authLoaded,
};
}
@@ -638,6 +640,7 @@ function useExecutorProfileCompat(
return {
selectedExecutorProfile,
compatibleAgentProfiles,
authLoaded,
executorProfileOptions,
noCompatibleAgent,
};
@@ -61,6 +61,26 @@ export type TaskCreateDialogInitialValues = {
export type StoreSelections = {
agentProfiles: AgentProfileOption[];
/**
* Subset of `agentProfiles` that can run on the currently-selected executor
* profile (`useExecutorProfileCompat`). Drives auto-selection in
* `useDefaultSelectionsEffect` so a previously-used profile that's
* incompatible with the current executor (e.g. Claude profile + Sprites)
* doesn't get silently restored and trip the "No compatible" empty state.
*
* Equal to `agentProfiles` until the executor profile + auth-spec catalog
* have loaded — read together with `authLoaded` to know which case applies.
*/
compatibleAgentProfiles: AgentProfileOption[];
/**
* True once the remote-auth catalog has been fetched. Until then,
* `compatibleAgentProfiles` is just `agentProfiles` (no filter applied), so
* auto-pick must wait — otherwise the first render restores a lastId that
* looks valid against the unfiltered list, the specs land milliseconds
* later, and `noCompatibleAgent` flips true via the
* "selected-not-in-compatible" branch.
*/
authLoaded: boolean;
executors: Executor[];
workspaceDefaults: Workspace | null | undefined;
};
@@ -89,6 +109,10 @@ export type DialogComputedValues = {
selectedExecutorProfileName: string | null;
/** True when an executor profile is selected and no agent profile is compatible with it. */
noCompatibleAgent: boolean;
/** Subset of agent profiles that pass the executor's auth-credential check. See `StoreSelections.compatibleAgentProfiles`. */
compatibleAgentProfiles: AgentProfileOption[];
/** True once the remote-auth catalog has been fetched. See `StoreSelections.authLoaded`. */
authLoaded: boolean;
};
export type DialogComputedArgs = {
@@ -117,6 +141,8 @@ export type TaskCreateEffectsArgs = {
repositories: Repository[];
repositoriesLoading: boolean;
agentProfiles: AgentProfileOption[];
compatibleAgentProfiles: AgentProfileOption[];
authLoaded: boolean;
executors: Executor[];
workspaceDefaults: Workspace | null | undefined;
toast: ReturnType<typeof useToast>["toast"];
@@ -400,6 +400,8 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
repositories,
repositoriesLoading,
agentProfiles,
compatibleAgentProfiles: computed.compatibleAgentProfiles,
authLoaded: computed.authLoaded,
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
@@ -16,9 +16,14 @@ import {
getSessionRunningState,
getLastTurnGroupId,
} from "./message-list-shared";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
const FIRST_INDEX_BASE = 100_000;
const debugVirtuoso = createDebugLogger("chat:virtuoso");
const debugScrollParent = createDebugLogger("chat:virtuoso:scrollParent");
const debugFirstIndex = createDebugLogger("chat:virtuoso:firstIndex");
type VirtuosoBodyProps = MessageListProps & {
scrollParent: HTMLDivElement;
isRunning: boolean;
@@ -54,13 +59,34 @@ type IndexState = { keys: string[]; firstItemIndex: number };
function useStableFirstItemIndex(items: RenderItem[]) {
const keys = useMemo(() => items.map(getItemKey), [items]);
const [state, setState] = useState<IndexState>(() => ({
keys,
firstItemIndex: FIRST_INDEX_BASE - keys.length + 1,
}));
const [state, setState] = useState<IndexState>(() => {
const firstItemIndex = FIRST_INDEX_BASE - keys.length + 1;
if (IS_DEBUG) {
debugFirstIndex("init", {
keyCount: keys.length,
firstItemIndex,
firstKey: keys[0] ?? "-",
lastKey: keys[keys.length - 1] ?? "-",
});
}
return { keys, firstItemIndex };
});
if (keys !== state.keys) {
const nextIndex = computeFirstItemIndex(state.keys, state.firstItemIndex, keys);
if (IS_DEBUG) {
debugFirstIndex("transition", {
prevKeyCount: state.keys.length,
nextKeyCount: keys.length,
prevIndex: state.firstItemIndex,
nextIndex,
delta: nextIndex - state.firstItemIndex,
prevFirstKey: state.keys[0] ?? "-",
nextFirstKey: keys[0] ?? "-",
prevLastKey: state.keys[state.keys.length - 1] ?? "-",
nextLastKey: keys[keys.length - 1] ?? "-",
});
}
setState({ keys, firstItemIndex: nextIndex });
return nextIndex;
}
@@ -172,6 +198,24 @@ function VirtuosoBody(props: VirtuosoBodyProps) {
const { virtuosoRef, itemCount, firstItemIndex, handleStartReached, computeItemKey, renderItem } =
useVirtuosoCallbacks(props);
// Captured once on mount — `initialTopMostItemIndex` only takes effect on
// Virtuoso's first render, so logging it here tells us which item Virtuoso
// anchored on for that lifecycle.
const mountSnapshotRef = useRef<{ itemCount: number; firstItemIndex: number } | null>(null);
useEffect(() => {
if (!IS_DEBUG) return;
if (mountSnapshotRef.current) return;
mountSnapshotRef.current = { itemCount, firstItemIndex };
debugVirtuoso("mount", {
itemCount,
firstItemIndex,
initialTopMostItemIndex: itemCount - 1,
hasMore: props.hasMore,
isRunning: props.isRunning,
lastTurnGroupId: props.lastTurnGroupId ?? "-",
});
}, [itemCount, firstItemIndex, props.hasMore, props.isRunning, props.lastTurnGroupId]);
return (
<Virtuoso
ref={virtuosoRef}
@@ -192,20 +236,146 @@ function VirtuosoBody(props: VirtuosoBodyProps) {
);
}
type VirtuosoSnapshot = {
branch: string;
itemCount: number;
messageCount: number;
scrollParentReady: boolean;
};
function virtuosoSnapshotChanged(prev: VirtuosoSnapshot | null, next: VirtuosoSnapshot): boolean {
if (!prev) return true;
return (
prev.branch !== next.branch ||
prev.itemCount !== next.itemCount ||
prev.messageCount !== next.messageCount ||
prev.scrollParentReady !== next.scrollParentReady
);
}
type VirtuosoDebugExtras = {
sessionId: string | null | undefined;
messagesLoading: boolean;
isInitialLoading: boolean;
showLoadingState: boolean;
sessionState: string | null | undefined;
lastItemKey: string;
};
function logVirtuosoSnapshotChange(
prev: VirtuosoSnapshot | null,
next: VirtuosoSnapshot,
extras: VirtuosoDebugExtras,
) {
debugVirtuoso(prev ? "snapshot-change" : "snapshot-init", {
sessionId: extras.sessionId ?? "-",
...next,
prevBranch: prev?.branch ?? "-",
prevItemCount: prev?.itemCount ?? -1,
prevMessageCount: prev?.messageCount ?? -1,
prevScrollParentReady: prev?.scrollParentReady ?? false,
messagesLoading: extras.messagesLoading,
isInitialLoading: extras.isInitialLoading,
showLoadingState: extras.showLoadingState,
sessionState: extras.sessionState ?? "-",
lastItemKey: extras.lastItemKey,
initialTopMostItemIndex: next.itemCount - 1,
});
}
type UseVirtuosoDebugSnapshotArgs = {
items: RenderItem[];
messages: { length: number };
scrollParent: HTMLDivElement | null;
sessionId: string | null | undefined;
messagesLoading: boolean;
isInitialLoading: boolean;
showLoadingState: boolean;
sessionState: string | null | undefined;
};
/** Track which render branch fires and how itemCount/messageCount transition. */
function useVirtuosoDebugSnapshot({
items,
messages,
scrollParent,
sessionId,
messagesLoading,
isInitialLoading,
showLoadingState,
sessionState,
}: UseVirtuosoDebugSnapshotArgs) {
const prevSnapshotRef = useRef<VirtuosoSnapshot | null>(null);
useEffect(() => {
if (!IS_DEBUG) return;
const snapshot: VirtuosoSnapshot = {
branch: isInitialLoading || items.length === 0 ? "fallback" : "virtuoso",
itemCount: items.length,
messageCount: messages.length,
scrollParentReady: Boolean(scrollParent),
};
const prev = prevSnapshotRef.current;
if (!virtuosoSnapshotChanged(prev, snapshot)) return;
const lastItem = items[items.length - 1];
logVirtuosoSnapshotChange(prev, snapshot, {
sessionId,
messagesLoading,
isInitialLoading,
showLoadingState,
sessionState,
lastItemKey: lastItem ? getItemKey(lastItem) : "-",
});
prevSnapshotRef.current = snapshot;
}, [
items,
messages.length,
scrollParent,
sessionId,
messagesLoading,
isInitialLoading,
showLoadingState,
sessionState,
]);
}
/** Defer providing scroll parent to Virtuoso until the element has non-zero size. */
function useVisibleScrollParent() {
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const nodeRef = useRef<HTMLDivElement | null>(null);
const setScrollRef = useCallback((node: HTMLDivElement | null) => {
nodeRef.current = node;
if (node && node.offsetHeight > 0) setScrollParent(node);
if (node && node.offsetHeight > 0) {
if (IS_DEBUG) {
debugScrollParent("ref-callback-ready", {
offsetHeight: node.offsetHeight,
path: "synchronous",
});
}
setScrollParent(node);
} else if (IS_DEBUG) {
debugScrollParent("ref-callback-defer", {
hasNode: Boolean(node),
offsetHeight: node?.offsetHeight ?? null,
reason: !node ? "no-node" : "zero-height",
});
}
}, []);
useEffect(() => {
const node = nodeRef.current;
if (!node || scrollParent) return;
if (IS_DEBUG) {
debugScrollParent("ro-attach", {
initialHeight: node.offsetHeight,
});
}
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.height > 0) {
if (IS_DEBUG) {
debugScrollParent("ro-ready", {
height: entry.contentRect.height,
});
}
setScrollParent(node);
ro.disconnect();
return;
@@ -238,6 +408,19 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess
const isRunning = getSessionRunningState(sessionState);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
// Track which render branch fires and how itemCount/messageCount transition.
// See useVirtuosoDebugSnapshot for details on the remote-executor scroll bug.
useVirtuosoDebugSnapshot({
items,
messages,
scrollParent,
sessionId,
messagesLoading,
isInitialLoading,
showLoadingState,
sessionState,
});
const Header = useCallback(
() => (
<MessageListStatus
@@ -74,6 +74,7 @@ import type { Terminal } from "@/hooks/domains/session/use-terminals";
// Portal system
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
// ---------------------------------------------------------------------------
// PORTAL SLOT — generic dockview component that adopts a persistent portal
@@ -110,14 +111,7 @@ import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host";
* - files — uses `useEnvironmentSessionId()` for stable file tree
* - plan — reads `activeTaskId` from the store
*/
const ENV_SCOPED_COMPONENTS = new Set([
"file-editor",
"browser",
"vscode",
"commit-detail",
"diff-viewer",
"pr-detail",
]);
const ENV_SCOPED_COMPONENTS = ENV_SCOPED_DOCKVIEW_COMPONENTS;
/**
* Every entry in the dockview `components` map uses this wrapper.
@@ -5,10 +5,21 @@ import {
tryRestoreLayout,
} from "./dockview-layout-restore";
import * as localStorage from "@/lib/local-storage";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
const VALID_COMPONENTS = new Set<string>(["chat", "files", "shell", "git", "terminal"]);
const PHANTOM_PANEL_ID = "session:phantom";
const ALIVE_PANEL_ID = "session:alive-1";
const PREVIEW_FILE_EDITOR_PANEL_ID = "preview:file-editor";
const TERMINAL_DEFAULT_PANEL_ID = "terminal-default";
const ENV_SCOPED_PANEL_ID_BY_COMPONENT: Record<string, string> = {
browser: "browser:http://localhost:3000",
"commit-detail": "preview:commit-detail",
"diff-viewer": "preview:file-diff",
"file-editor": PREVIEW_FILE_EDITOR_PANEL_ID,
"pr-detail": "pr-detail",
vscode: "vscode",
};
/**
* Build a minimal valid SerializedDockview-shaped object — matches what
@@ -52,6 +63,34 @@ function buildLayout(opts?: { centerSize?: number; sidebarSize?: number; rightSi
};
}
function makeFakeRestoreApi() {
return {
fromJSON: vi.fn(),
layout: vi.fn(),
width: 1600,
height: 600,
groups: [],
panels: [],
activeGroup: { id: "g-center" },
getPanel: vi.fn(() => null),
onDidActiveGroupChange: vi.fn(() => ({ dispose: vi.fn() })),
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as Parameters<typeof tryRestoreLayout>[0];
}
function restoreGlobalFallbackLayout(
layout: ReturnType<typeof buildLayout>,
components: Set<string>,
) {
window.localStorage.setItem("dockview-layout-v2", JSON.stringify(layout));
const api = makeFakeRestoreApi();
const restored = tryRestoreLayout(api, null, components);
expect(restored).toBe(true);
expect(api.fromJSON).toHaveBeenCalledOnce();
return (api.fromJSON as ReturnType<typeof vi.fn>).mock.calls[0][0];
}
describe("sanitizeLayout - size validation", () => {
it("returns the layout unchanged when all sizes are positive", () => {
const layout = buildLayout();
@@ -274,21 +313,6 @@ describe("tryRestoreLayout - phantom session panel filtering", () => {
vi.restoreAllMocks();
});
function makeFakeApi() {
return {
fromJSON: vi.fn(),
layout: vi.fn(),
width: 1600,
height: 600,
groups: [],
panels: [],
activeGroup: { id: "g-center" },
getPanel: vi.fn(() => null),
onDidActiveGroupChange: vi.fn(() => ({ dispose: vi.fn() })),
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as Parameters<typeof tryRestoreLayout>[0];
}
it("restores the (stripped) layout when every session in the saved env-layout is a phantom", () => {
// Saved layout had a single session panel — a phantom from a previously-
// deleted task. After stripping, no session panels remain. We still
@@ -303,7 +327,7 @@ describe("tryRestoreLayout - phantom session panel filtering", () => {
vi.spyOn(localStorage, "getEnvLayout").mockReturnValue(layout);
vi.spyOn(localStorage, "getEnvMaximizeState").mockReturnValue(null);
const api = makeFakeApi();
const api = makeFakeRestoreApi();
const restored = tryRestoreLayout(api, "env-new", VALID_COMPONENTS, new Set(["phantom"]));
expect(restored).toBe(true);
expect(api.fromJSON).toHaveBeenCalledOnce();
@@ -323,7 +347,7 @@ describe("tryRestoreLayout - phantom session panel filtering", () => {
vi.spyOn(localStorage, "getEnvLayout").mockReturnValue(layout);
vi.spyOn(localStorage, "getEnvMaximizeState").mockReturnValue(null);
const api = makeFakeApi();
const api = makeFakeRestoreApi();
const restored = tryRestoreLayout(api, "env-X", VALID_COMPONENTS, new Set(["phantom"]));
expect(restored).toBe(true);
expect(api.fromJSON).toHaveBeenCalledOnce();
@@ -336,13 +360,61 @@ describe("tryRestoreLayout - phantom session panel filtering", () => {
vi.spyOn(localStorage, "getEnvLayout").mockReturnValue(layout);
vi.spyOn(localStorage, "getEnvMaximizeState").mockReturnValue(null);
const api = makeFakeApi();
const api = makeFakeRestoreApi();
const restored = tryRestoreLayout(api, "env-X", VALID_COMPONENTS, new Set());
expect(restored).toBe(true);
expect(api.fromJSON).toHaveBeenCalledOnce();
});
});
describe("tryRestoreLayout - global fallback env-scoped panel filtering", () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
});
it.each(
[...ENV_SCOPED_DOCKVIEW_COMPONENTS].map((component) => ({
component,
panelId: ENV_SCOPED_PANEL_ID_BY_COMPONENT[component] ?? `${component}:stale`,
})),
)("strips $component panels from the no-env global fallback layout", ({ component, panelId }) => {
const layout = buildLayout();
layout.grid.root.data[1].data.views = [PHANTOM_PANEL_ID, panelId];
layout.grid.root.data[1].data.activeView = panelId;
const panels = layout.panels as Record<string, { id?: string; contentComponent: string }>;
delete panels.chat;
Object.assign(panels, {
[PHANTOM_PANEL_ID]: { id: PHANTOM_PANEL_ID, contentComponent: "chat" },
[panelId]: { id: panelId, contentComponent: component },
});
const restoredLayout = restoreGlobalFallbackLayout(
layout,
new Set([...VALID_COMPONENTS, component]),
);
expect(Object.keys(restoredLayout.panels)).not.toContain(PHANTOM_PANEL_ID);
expect(Object.keys(restoredLayout.panels)).not.toContain(panelId);
});
it("keeps terminal panels in the no-env global fallback layout", () => {
const layout = buildLayout();
layout.grid.root.data[1].data.views = [TERMINAL_DEFAULT_PANEL_ID];
layout.grid.root.data[1].data.activeView = TERMINAL_DEFAULT_PANEL_ID;
const panels = layout.panels as Record<string, { id?: string; contentComponent: string }>;
delete panels.chat;
Object.assign(panels, {
[TERMINAL_DEFAULT_PANEL_ID]: {
id: TERMINAL_DEFAULT_PANEL_ID,
contentComponent: "terminal",
},
});
const restoredLayout = restoreGlobalFallbackLayout(layout, VALID_COMPONENTS);
expect(Object.keys(restoredLayout.panels)).toContain(TERMINAL_DEFAULT_PANEL_ID);
});
});
/**
* Regression: an env-layout could be restored with a session panel whose id
* referred to a previously-deleted task's session. The fix strips session
@@ -4,6 +4,7 @@ import { useDockviewStore } from "@/lib/state/dockview-store";
import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders";
import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health";
import { measureDockviewContainer } from "@/lib/state/dockview-measure";
import { isEnvScopedDockviewComponent } from "@/lib/state/dockview-env-scoped-components";
import type { LayoutState } from "@/lib/state/layout-manager";
import type { AppState } from "@/lib/state/store";
import { getEnvLayout, getEnvMaximizeState, removeEnvMaximizeState } from "@/lib/local-storage";
@@ -15,17 +16,33 @@ const debug = createDebugLogger("dockview:restore");
const LAYOUT_STORAGE_KEY = "dockview-layout-v2";
/* eslint-disable @typescript-eslint/no-explicit-any */
type SanitizeLayoutOptions =
| { stripSessionPanels: true; stripEnvScopedPanels?: boolean; excludeSessionIds?: never }
| {
stripSessionPanels?: false | undefined;
stripEnvScopedPanels?: boolean;
excludeSessionIds?: Set<string>;
};
function describeSanitizeMode(options: {
stripSessionPanels?: boolean;
stripEnvScopedPanels?: boolean;
excludeSessionIds?: Set<string>;
}): string {
if (options.stripSessionPanels && options.stripEnvScopedPanels)
return "stripSessionsAndEnvScoped";
if (options.stripSessionPanels) return "stripAllSessions";
if (options.stripEnvScopedPanels) return "stripEnvScoped";
if (options.excludeSessionIds) return "excludeSpecificSessions";
return "keepAll";
}
function logSanitizeOutcome(
options: { stripSessionPanels?: boolean; excludeSessionIds?: Set<string> },
options: {
stripSessionPanels?: boolean;
stripEnvScopedPanels?: boolean;
excludeSessionIds?: Set<string>;
},
totalPanels: Record<string, any>,
validPanels: Record<string, any>,
invalidIds: Set<string>,
@@ -42,15 +59,46 @@ function logSanitizeOutcome(
strippedPanels: Array.from(invalidIds),
});
}
function shouldKeepSessionPanel(id: string, options: SanitizeLayoutOptions): boolean {
if (options.stripSessionPanels) return false;
if (!options.excludeSessionIds) return true;
// Per-env restore: drop session panels that we know belong to a
// different env (a phantom from a previously-deleted task). Sessions
// we have no mapping for are kept — they may be a still-loading WS
// arrival, and useAutoSessionTab's reconcile will clean them up if
// they turn out to be stale.
const sid = id.slice("session:".length);
return !options.excludeSessionIds.has(sid);
}
function shouldKeepPanel(
id: string,
panel: any,
validComponents: Set<string>,
options: SanitizeLayoutOptions,
): boolean {
const comp = panel.contentComponent;
// Session panels are scoped to a specific environment; when restoring the
// global fallback (no envId yet), they belong to the previous task and
// would leak in as duplicate tabs. Strip them in that case. The session
// check must happen before component-validity, since session panels are
// serialized with contentComponent: "chat" (a valid component) and would
// otherwise short-circuit the strip guard.
if (id.startsWith("session:")) return shouldKeepSessionPanel(id, options);
if (options.stripEnvScopedPanels && isEnvScopedDockviewComponent(comp)) return false;
return !!(comp && validComponents.has(comp));
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any */
export function sanitizeLayout(
layout: any,
validComponents: Set<string>,
options:
| { stripSessionPanels: true; excludeSessionIds?: never }
| { stripSessionPanels?: false | undefined; excludeSessionIds?: Set<string> } = {},
options: SanitizeLayoutOptions = {},
): any {
if (!isLayoutShapeHealthy(layout)) {
debug("sanitizeLayout: layout shape unhealthy, returning null");
@@ -60,32 +108,7 @@ export function sanitizeLayout(
const invalidIds = new Set<string>();
const validPanels: Record<string, any> = {};
for (const [id, panel] of Object.entries(layout.panels)) {
const comp = (panel as any).contentComponent;
// Session panels are scoped to a specific environment; when restoring the
// global fallback (no envId yet), they belong to the previous task and
// would leak in as duplicate tabs. Strip them in that case. The session
// check must happen before component-validity, since session panels are
// serialized with contentComponent: "chat" (a valid component) and would
// otherwise short-circuit the strip guard.
if (id.startsWith("session:")) {
if (options.stripSessionPanels) {
invalidIds.add(id);
} else if (options.excludeSessionIds) {
// Per-env restore: drop session panels that we know belong to a
// different env (a phantom from a previously-deleted task). Sessions
// we have no mapping for are kept — they may be a still-loading WS
// arrival, and useAutoSessionTab's reconcile will clean them up if
// they turn out to be stale.
const sid = id.slice("session:".length);
if (options.excludeSessionIds.has(sid)) {
invalidIds.add(id);
} else {
validPanels[id] = panel;
}
} else {
validPanels[id] = panel;
}
} else if (comp && validComponents.has(comp)) {
if (shouldKeepPanel(id, panel, validComponents, options)) {
validPanels[id] = panel;
} else {
invalidIds.add(id);
@@ -234,6 +257,7 @@ export function tryRestoreLayout(
if (saved) {
const layout = sanitizeLayout(JSON.parse(saved), validComponents, {
stripSessionPanels: true,
stripEnvScopedPanels: true,
});
if (!layout) return false;
api.fromJSON(layout);
@@ -10,12 +10,16 @@ import {
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
setPinnedTarget,
getPinnedTarget,
} from "@/lib/state/layout-manager";
import { setEnvLayout } from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
import { parkUserShell, stopUserShell } from "@/lib/api/domains/user-shell-api";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
import { snapshotColumnWidths, formatWidthsSnapshot } from "@/lib/state/dockview-widths-debug";
import { enforcePinnedTargets, setSashDragging } from "@/lib/state/dockview-pinned-enforce";
const debugWidths = createDebugLogger("dockview:widths");
// v2: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX so the no-env fallback
// also invalidates layouts saved under the previous caps.
@@ -26,67 +30,26 @@ export function markTerminalPanelTerminateClose(panelId: string): void {
terminalTerminateClosePanelIds.add(panelId);
}
/**
* Pinned-column target enforcement.
*
* Dockview's splitview rebalances proportionally on any `api.layout` call,
* which would otherwise grow pinned columns past their initial defaults on
* container expansion and shrink them on container contraction. We treat
* sidebar/right as having a *target width* (stored in `pinned-targets.ts`)
* that is updated only by explicit user actions (drag, initial layout,
* restore from saved); after every layout-change event we force the live
* columns back to their targets via `sv.resizeView`.
*/
// Pinned-column target enforcement and the `sashDragging` flag live in
// `lib/state/dockview-pinned-enforce.ts` so the store can call enforcement
// without importing this component-layer module. The sash mousedown/mouseup
// handlers below toggle the flag via `setSashDragging`.
/** Enforcement-in-progress guard to prevent infinite loops when our own
* `sv.resizeView` triggers `onDidLayoutChange`. */
let enforcing = false;
/** True while the user is actively dragging a `.dv-sash`. We pause target
* enforcement during the drag so the in-progress resize doesn't get
* reverted to the previous target on every intermediate layout change. */
let sashDragging = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function restoreColumnToTarget(sv: any, idx: number, target: number | undefined): void {
if (target === undefined) return;
const cur = sv.getViewSize(idx);
if (Math.abs(cur - target) <= 1) return;
try {
sv.resizeView(idx, target);
} catch {
/* dockview rejects unreachable sizes — ignore */
}
}
function enforcePinnedTargets(api: DockviewReadyEvent["api"]): void {
if (enforcing || sashDragging) return;
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
enforcing = true;
try {
if (store.sidebarVisible) restoreColumnToTarget(sv, 0, getPinnedTarget("sidebar"));
if (store.rightPanelsVisible) {
restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"));
}
} finally {
enforcing = false;
}
}
/** Set the loose runtime cap so the user can drag the column past its target. */
/** Set the loose runtime cap so the user can drag the column past its target.
* Uses `api.width` (dockview's measured grid width) for the cap computation
* instead of `window.innerWidth`, which can briefly read stale during route
* transitions and devtools toggles - a vw=601 read yields cap=301 (=
* vw - VIEWPORT_RESERVE_PX) and squeezes the pinned column down to 301. */
function setLooseConstraints(api: DockviewReadyEvent["api"]): void {
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
const vw = api.width > 0 ? api.width : undefined;
const sb = api.getPanel("sidebar");
if (sb && store.sidebarVisible) {
sb.group.api.setConstraints({
maximumWidth: computeSidebarMaxPx(),
maximumWidth: computeSidebarMaxPx(vw),
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
}
@@ -96,7 +59,7 @@ function setLooseConstraints(api: DockviewReadyEvent["api"]): void {
const group = api.groups.find((g) => g.id === gid);
if (group) {
group.api.setConstraints({
maximumWidth: computeRightMaxPx(),
maximumWidth: computeRightMaxPx(vw),
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
}
@@ -117,22 +80,44 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
// enforced post-hoc via `enforcePinnedTargets`.
setLooseConstraints(api);
const layoutSub = api.onDidLayoutChange(() => enforcePinnedTargets(api));
const layoutSub = api.onDidLayoutChange(() => {
// Skip the reactive enforcement during a programmatic restore - the
// restore path itself calls `enforcePinnedTargets` synchronously inside
// its rAF so the user never sees a transient post-rebalance flicker.
// Without this gate, the in-flight restore (which fires its own
// layout-change events) would be re-entered before its rAF settled.
const s = useDockviewStore.getState();
if (s.isRestoringLayout) return;
enforcePinnedTargets(api, {
sidebarVisible: s.sidebarVisible,
rightPanelsVisible: s.rightPanelsVisible,
maximized: s.preMaximizeLayout !== null,
});
});
if (typeof document === "undefined") {
return () => layoutSub.dispose();
}
// Local mirror of the enforcement-module flag so the mouseup handler can
// short-circuit when no drag was in progress without round-tripping through
// a getter. Both this local and the module flag (via setSashDragging) must
// stay in lockstep.
let dragging = false;
const onMouseDown = (e: MouseEvent): void => {
// Only track primary-button drags. A right/middle mousedown that didn't
// start a drag must not leave `sashDragging` permanently set (cubic P2).
// start a drag must not leave the flag permanently set (cubic P2).
if (e.button !== 0) return;
const t = e.target as HTMLElement | null;
if (t?.closest(".dv-sash")) sashDragging = true;
if (t?.closest(".dv-sash")) {
dragging = true;
setSashDragging(true);
}
};
const onMouseUp = (e: MouseEvent): void => {
if (e.button !== 0 || !sashDragging) return;
sashDragging = false;
if (e.button !== 0 || !dragging) return;
dragging = false;
setSashDragging(false);
// Capture the post-drag width as the new target.
requestAnimationFrame(() => {
const sv = getRootSplitview(api);
@@ -140,6 +125,9 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
const store = useDockviewStore.getState();
if (store.sidebarVisible) setPinnedTarget("sidebar", sv.getViewSize(0));
if (store.rightPanelsVisible) setPinnedTarget("right", sv.getViewSize(sv.length - 1));
if (IS_DEBUG) {
debugWidths(`sash-drag-end ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
}
});
};
document.addEventListener("mousedown", onMouseDown, true);
@@ -149,10 +137,11 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
layoutSub.dispose();
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("mouseup", onMouseUp, true);
// Reset the module-scope flag so an unmount mid-drag (e.g. user navigates
// away while holding a sash) doesn't leave enforcement permanently paused
// for the next mount (claude).
sashDragging = false;
// Reset both flags so an unmount mid-drag (e.g. user navigates away while
// holding a sash) doesn't leave enforcement permanently paused for the
// next mount.
dragging = false;
setSashDragging(false);
};
}
@@ -214,6 +203,12 @@ export function setupContainerResizeSync(api: DockviewReadyEvent["api"]): () =>
const h = parent.clientHeight;
if (w <= 0 || h <= 0) return;
if (w === api.width && h === api.height) return;
if (IS_DEBUG) {
debugWidths(
`container-resize prev=${api.width}x${api.height} next=${w}x${h} ` +
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
api.layout(w, h);
// `enforcePinnedTargets` (wired in `setupSashDragCapToggle`) restores
// sidebar/right to their target widths via `onDidLayoutChange`, so we
+2 -8
View File
@@ -37,6 +37,7 @@ import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel";
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
import { usePortalSlot } from "@/lib/layout/panel-portal-host";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
// ---------------------------------------------------------------------------
// PORTAL SLOT — generic dockview component that adopts a persistent portal
@@ -73,14 +74,7 @@ import { usePortalSlot } from "@/lib/layout/panel-portal-host";
* - files — uses `useEnvironmentSessionId()` for stable file tree
* - plan — reads `activeTaskId` from the store
*/
export const ENV_SCOPED_COMPONENTS = new Set([
"file-editor",
"browser",
"vscode",
"commit-detail",
"diff-viewer",
"pr-detail",
]);
export const ENV_SCOPED_COMPONENTS = ENV_SCOPED_DOCKVIEW_COMPONENTS;
/**
* Every entry in the dockview `components` map uses this wrapper.
@@ -1,14 +1,25 @@
import { useEffect, useState } from "react";
import { listRemoteCredentials, type RemoteAuthSpec } from "@/lib/api/domains/settings-api";
import { createDebugLogger } from "@/lib/debug/log";
const debug = createDebugLogger("executor-compat:specs");
let cached: Promise<RemoteAuthSpec[]> | null = null;
function loadAuthSpecs(): Promise<RemoteAuthSpec[]> {
if (!cached) {
cached = listRemoteCredentials()
.then((res) => res.auth_specs ?? [])
.catch(() => {
.then((res) => {
const specs = res.auth_specs ?? [];
debug("loaded", {
count: specs.length,
ids: specs.map((s) => s.id).join(",") || "-",
});
return specs;
})
.catch((err) => {
cached = null;
debug("load-failed", { error: err instanceof Error ? err.message : String(err) });
return [] as RemoteAuthSpec[];
});
}
+64 -15
View File
@@ -1,5 +1,8 @@
import type { ExecutorProfile } from "@/lib/types/http";
import type { RemoteAuthSpec } from "@/lib/api/domains/settings-api";
import { createDebugLogger } from "@/lib/debug/log";
const debug = createDebugLogger("executor-compat");
const REMOTE_EXECUTOR_TYPES = new Set(["local_docker", "remote_docker", "sprites"]);
@@ -17,36 +20,82 @@ function parseJSON<T>(raw: string | undefined, fallback: T): T {
}
}
/**
* Reasons a compat check resolved the way it did. Surfaced via the
* `[executor-compat]` debug logger so triage of the task-create dialog's
* "No compatible agent profiles" state is greppable.
* executor-local: executor doesn't need per-agent creds → allowed
* no-spec: agent isn't in the remote-auth catalog → blocked
* no-methods: spec exists but declares no methods (e.g. mock) → allowed
* files-match: a non-env method id is listed in remote_credentials → allowed
* env-secret: an env method's secret id is set in remote_auth_secrets → allowed
* no-creds: spec methods exist but neither files nor env are wired → blocked
*/
type CompatReason =
| "executor-local"
| "no-spec"
| "no-methods"
| "files-match"
| "env-secret"
| "no-creds";
function evalAgentCompat(
agent: { agent_name: string },
executorProfile: Pick<ExecutorProfile, "config" | "executor_type">,
authSpecs: RemoteAuthSpec[],
): { ok: boolean; reason: CompatReason } {
if (!executorRequiresAgentCredentials(executorProfile.executor_type)) {
return { ok: true, reason: "executor-local" };
}
const spec = authSpecs.find((s) => s.id === agent.agent_name);
if (!spec) return { ok: false, reason: "no-spec" };
const methods = spec.methods ?? [];
if (methods.length === 0) return { ok: true, reason: "no-methods" };
const credentials = new Set(parseJSON<string[]>(executorProfile.config?.remote_credentials, []));
if (methods.some((m) => m.type !== "env" && credentials.has(m.method_id))) {
return { ok: true, reason: "files-match" };
}
const secrets = parseJSON<Record<string, string | null>>(
executorProfile.config?.remote_auth_secrets,
{},
);
if (methods.some((m) => m.type === "env" && secrets[m.method_id])) {
return { ok: true, reason: "env-secret" };
}
return { ok: false, reason: "no-creds" };
}
/**
* For local/worktree executors, agents need no extra credentials → always supported.
* For remote executors (Docker/Sprites), the executor profile must carry either:
* - a non-env auth method ID for the agent's spec in `remote_credentials`, or
* - a non-null secret keyed by an env method ID in `remote_auth_secrets`.
* Agents without a remote-auth spec (Copilot/Amp/OpenCode/TUI) cannot
* Agents without a remote-auth spec (Copilot/Amp/TUI) cannot
* carry credentials on remote executors → blocked. A spec with zero methods
* means "no credentials needed" (mock-agent for tests) → allowed.
*
* Spec IDs are registry-type strings ("claude-acp", "codex-acp", …) which the
* frontend exposes as `AgentProfileOption.agent_name`. `agent_id` is a DB UUID
* and is unrelated to the catalog.
*
* Per-call decisions are logged to the `[executor-compat]` debug namespace so
* "agent missing from dialog" reports can be diagnosed by enabling debug logs
* and reading the reason (no-spec / no-creds / …).
*/
export function isAgentConfiguredOnExecutor(
agent: { agent_name: string },
executorProfile: Pick<ExecutorProfile, "config" | "executor_type">,
authSpecs: RemoteAuthSpec[],
): boolean {
if (!executorRequiresAgentCredentials(executorProfile.executor_type)) return true;
const spec = authSpecs.find((s) => s.id === agent.agent_name);
if (!spec) return false;
if (spec.methods.length === 0) return true;
const credentials = new Set(parseJSON<string[]>(executorProfile.config?.remote_credentials, []));
if (spec.methods.some((m) => m.type !== "env" && credentials.has(m.method_id))) return true;
const secrets = parseJSON<Record<string, string | null>>(
executorProfile.config?.remote_auth_secrets,
{},
);
return spec.methods.some((m) => m.type === "env" && secrets[m.method_id]);
const result = evalAgentCompat(agent, executorProfile, authSpecs);
debug("check", {
agent: agent.agent_name,
executor_type: executorProfile.executor_type ?? "-",
spec_count: authSpecs.length,
ok: result.ok,
reason: result.reason,
});
return result.ok;
}
+60
View File
@@ -46,6 +46,66 @@
* [file-browser:load] tree loader — init / ready-flip / start / retry / gave-up
* [file-browser:changes] session.workspace.file.changes events + folder refresh
*
* Task-create dialog (bug: "No compatible agent profiles for <executor>")
* [executor-compat:specs] remote-auth catalog fetch (count + agent ids)
* [executor-compat] per-agent compat decision: ok + reason
* (no-spec / no-creds / files-match / env-secret / …)
* [executor-compat:autopick]
* useDefaultSelectionsEffect decision per fire:
* skip/defer/pick + reason + which profile was set.
* Triage path when "No compatible" lingers after
* specs land — diff the decision sequence against
* the catalog log to localise the culprit.
* [executor-compat:workflow-autopick]
* useWorkflowAgentProfileEffect decision per fire:
* no-workflow / locked / locked-missing /
* workflow-no-override. The last branch restores
* localStorage `lastId` against the unfiltered
* `agentProfiles` — set_to of an executor-incompatible
* id is the smoking gun for that race.
*
* Dockview column widths (bug: sidebar/center/right widths wrong during
* env-prepare and on first task switch with cleared localStorage)
* [dockview:widths] Per-event width-pipeline snapshots:
* build-default-{entry,done} first paint / reset
* env-switch-{resize,resize-col,done}
* cross-task switch
* container-resize DOM ResizeObserver fired
* sash-drag-end user-released sash
* store-sync live widths → store pinnedWidths
* enforce-restore target rewind via resizeView
* Snapshot format (formatWidthsSnapshot):
* L=240 C=842 R=320 cols=3 api=1402x900 tgt=L240/R320
* `tgt=` is the pinned-targets map (drives the
* enforcement loop); mismatch with L/R is the
* smoking gun for a stale-target bug.
*
* Chat panel rendering (bug: remote-executor agent reply persisted but UI
* doesn't render it until the user refreshes the page)
* [chat:virtuoso] VirtuosoMessageList render-branch snapshots
* (fallback vs virtuoso) and VirtuosoBody mount —
* captures itemCount / firstItemIndex /
* initialTopMostItemIndex at the moment Virtuoso
* first anchors its scroll. If itemCount at
* `mount` is < the final item count, Virtuoso
* anchored on an earlier item and the new last
* item lands below the fold.
* [chat:virtuoso:scrollParent] `useVisibleScrollParent` lifecycle —
* ref-callback-ready / ref-callback-defer /
* ro-attach / ro-ready. A long delay between
* items growing and `ro-ready` firing is the
* smoking gun for the mount-too-early race.
* [chat:virtuoso:firstIndex] `useStableFirstItemIndex` transitions —
* init + key-list deltas. A non-monotonic
* `delta` between two transitions means
* Virtuoso saw the keyspace shift in a way
* that throws off scroll anchoring.
* [chat:prepare-progress] PrepareProgress status / autoExpand / expanded
* transitions per session. Status stuck on
* "preparing" with `expanded=true` while
* Virtuoso is mounted explains the
* agent-reply-pushed-below-fold scenario.
*
* Other
* [ws:connection] WS hook mount + status transitions
* [dockview:*] layout restore / save / env-switch / session-tabs / task-select
@@ -0,0 +1,20 @@
/**
* Dockview components whose panel content is tied to one task environment.
*
* These panels carry env-specific runtime state such as file paths, iframes,
* editor buffers, git history, or PR metadata. They may persist inside a
* saved per-env layout, but must not be restored from a no-env/global fallback
* while a newly selected task is still preparing its environment.
*/
export const ENV_SCOPED_DOCKVIEW_COMPONENTS = new Set([
"file-editor",
"browser",
"vscode",
"commit-detail",
"diff-viewer",
"pr-detail",
]);
export function isEnvScopedDockviewComponent(component: string | null | undefined): boolean {
return !!component && ENV_SCOPED_DOCKVIEW_COMPONENTS.has(component);
}
@@ -31,6 +31,10 @@ vi.mock("./layout-manager", () => {
layoutStructuresMatch: vi.fn(() => true),
getRootSplitview: vi.fn(),
getPinnedWidth: vi.fn(() => 350),
// Used by applyPinnedColumnSizes and (indirectly) by the
// formatWidthsSnapshot debug log it now emits.
setPinnedTarget: vi.fn(),
getPinnedTarget: vi.fn(() => undefined),
};
});
+16 -8
View File
@@ -20,9 +20,12 @@ import {
setPinnedTarget,
} from "./layout-manager";
import type { LayoutState, LayoutGroupIds } from "./layout-manager";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "./dockview-env-scoped-components";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
import { snapshotColumnWidths, formatWidthsSnapshot } from "./dockview-widths-debug";
const debug = createDebugLogger("dockview:env-switch");
const debugWidths = createDebugLogger("dockview:widths");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function snapshotGridShape(node: any, depth = 0): unknown {
@@ -45,14 +48,7 @@ function snapshotGridShape(node: any, depth = 0): unknown {
return null;
}
const EPHEMERAL_COMPONENTS = new Set([
"file-editor",
"browser",
"vscode",
"commit-detail",
"diff-viewer",
"pr-detail",
]);
const EPHEMERAL_COMPONENTS = ENV_SCOPED_DOCKVIEW_COMPONENTS;
/** Fetch the saved layout for an env, dropping it if its shape is corrupted. */
function getHealthyEnvLayout(envId: string): object | null {
@@ -378,6 +374,15 @@ function applyPinnedColumnSizes(
const savedSizes = saved ? extractSavedColumnSizes(saved) : null;
const liveLayout = fromDockviewApi(api);
if (IS_DEBUG) {
const savedStr = savedSizes
? savedSizes.map((n) => (Number.isFinite(n) ? String(Math.round(n)) : "-")).join(",")
: "-";
debugWidths(
`env-switch-resize totalWidth=${totalWidth} savedSizes=${savedStr} ` +
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
for (let i = 0; i < liveLayout.columns.length && i < sv.length; i++) {
const col = liveLayout.columns[i];
if (col.id !== "sidebar" && col.id !== "right") continue;
@@ -388,6 +393,9 @@ function applyPinnedColumnSizes(
// Update the pinned-target so enforcement keeps the new env's width
// through subsequent rebalances.
setPinnedTarget(col.id, target);
if (IS_DEBUG) {
debugWidths(`env-switch-resize-col col=${col.id} idx=${i} target=${Math.round(target)}`);
}
} catch {
/* dockview rejects out-of-range sizes — ignore */
}
@@ -0,0 +1,107 @@
/**
* Pinned-column target enforcement primitives.
*
* Lives in the state layer (next to `dockview-store`) so the store can
* call enforcement without crossing into the component layer. Sash-drag
* handlers in `components/task/dockview-layout-setup.ts` toggle the
* `sashDragging` flag via `setSashDragging` and consume `enforcePinnedTargets`
* for the reactive `onDidLayoutChange` callback - both directions now
* import from this module, breaking the previous
* state-imports-component-imports-state cycle.
*
* Dockview's splitview rebalances proportionally on any `api.layout` call,
* which would otherwise grow pinned columns past their initial defaults on
* container expansion and shrink them on container contraction. We treat
* sidebar/right as having a *target width* (stored in
* `layout-manager/pinned-targets.ts`) that is updated only by explicit user
* actions (drag, initial layout, restore from saved); after every
* layout-change event we force the live columns back to their targets via
* `sv.resizeView`.
*/
import type { DockviewApi } from "dockview-react";
import { getRootSplitview, getPinnedTarget } from "./layout-manager";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
const debugWidths = createDebugLogger("dockview:widths");
/** Enforcement-in-progress guard to prevent infinite loops when our own
* `sv.resizeView` triggers `onDidLayoutChange`. */
let enforcing = false;
/** True while the user is actively dragging a `.dv-sash`. We pause target
* enforcement during the drag so the in-progress resize doesn't get
* reverted to the previous target on every intermediate layout change. */
let sashDragging = false;
/** Setter for the sash-drag flag. Sash mousedown/mouseup handlers in
* `components/task/dockview-layout-setup.ts` call this to gate enforcement
* during user drags. Reading from outside isn't useful because the value
* flips synchronously with the drag - call sites that need the state
* check it indirectly via `enforcePinnedTargets`'s short-circuit. */
export function setSashDragging(v: boolean): void {
sashDragging = v;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function restoreColumnToTarget(sv: any, idx: number, target: number | undefined): void {
if (target === undefined) return;
const cur = sv.getViewSize(idx);
if (Math.abs(cur - target) <= 1) return;
if (IS_DEBUG) {
debugWidths(`enforce-restore idx=${idx} cur=${Math.round(cur)} target=${Math.round(target)}`);
}
try {
sv.resizeView(idx, target);
} catch {
/* dockview rejects unreachable sizes — ignore */
}
}
/** Visibility/maximize state needed by `enforcePinnedTargets`. Passed in by
* callers (instead of read via `useDockviewStore.getState()`) so this module
* doesn't depend on the store and the import graph stays one-way. */
export type EnforcePinnedTargetsCtx = {
sidebarVisible: boolean;
rightPanelsVisible: boolean;
/** Non-null when we are in (or restoring) a maximized-group state; skip
* enforcement so we don't fight the 2-column maximize overlay. */
maximized: boolean;
};
/**
* Snap the pinned columns back to their recorded targets.
*
* Two call modes:
* - Reactive: wired in `setupSashDragCapToggle` via `onDidLayoutChange`.
* The subscription pre-checks `isRestoringLayout` and skips during
* restore so this function does not fight an in-progress programmatic
* layout.
* - Proactive: called synchronously by programmatic layout paths
* (build-default, env-switch, preset/custom-layout apply, sidebar/right
* toggle, maximize/exit) *inside* the post-`api.layout` rAF, before
* `isRestoringLayout` flips false. Dockview's proportional rebalance
* after `api.layout` can grow pinned columns up to their loose
* `setConstraints.maximumWidth`; without this synchronous snap the
* correction would only happen on the next user-triggered layout-change
* event, producing a visible jerk after env prep settles.
*
* The `enforcing` re-entry guard remains so the `sv.resizeView` call we emit
* does not feed back through the layout-change subscription. `sashDragging`
* still gates the whole thing - mid-drag, we want dockview to follow the
* user's mouse, not snap to the previous target.
*/
export function enforcePinnedTargets(api: DockviewApi, ctx: EnforcePinnedTargetsCtx): void {
if (enforcing || sashDragging) return;
if (api.hasMaximizedGroup() || ctx.maximized) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
enforcing = true;
try {
if (ctx.sidebarVisible) restoreColumnToTarget(sv, 0, getPinnedTarget("sidebar"));
if (ctx.rightPanelsVisible) {
restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"));
}
} finally {
enforcing = false;
}
}
+54
View File
@@ -27,6 +27,7 @@ import {
} from "./layout-manager";
import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager";
import { performEnvSwitch } from "./dockview-env-switch";
import { enforcePinnedTargets } from "./dockview-pinned-enforce";
import {
injectIntentPanels,
applyActivePanelOverrides,
@@ -43,9 +44,11 @@ import { preserveChatScrollDuringLayout } from "./dockview-scroll-preserve";
import { measureDockviewContainer } from "./dockview-measure";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
import { snapshotColumnWidths, formatWidthsSnapshot } from "./dockview-widths-debug";
const debugSwitch = createDebugLogger("dockview:store-switch");
const debugSave = createDebugLogger("dockview:save");
const debugWidths = createDebugLogger("dockview:widths");
const RIGHT_PANEL_IDS = new Set(["changes", "files", TERMINAL_DEFAULT_ID]);
@@ -232,6 +235,12 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
}
}
if (updates.size > 0) {
if (IS_DEBUG) {
const pairs = Array.from(updates.entries())
.map(([k, v]) => `${k}=${Math.round(v)}`)
.join(",");
debugWidths(`store-sync ${pairs} ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
}
set((prev) => {
const m = new Map(prev.pinnedWidths);
for (const [k, v] of updates) m.set(k, v);
@@ -252,6 +261,26 @@ function captureLiveWidths(api: DockviewApi, set: StoreSet): Map<string, number>
return useDockviewStore.getState().pinnedWidths;
}
/**
* Snap pinned columns to their targets using the current store state.
*
* Called inside every programmatic layout path's post-`api.layout` rAF
* before flipping `isRestoringLayout` false - dockview's proportional
* rebalance can grow pinned columns up to their loose `setConstraints` max,
* and the reactive enforcement (wired via `onDidLayoutChange`) is gated by
* `isRestoringLayout`, so without this synchronous call the correction
* would only land on the next user-triggered layout-change event,
* producing a visible jerk once env prep settles.
*/
function enforceFromStore(api: DockviewApi, get: StoreGet): void {
const s = get();
enforcePinnedTargets(api, {
sidebarVisible: s.sidebarVisible,
rightPanelsVisible: s.rightPanelsVisible,
maximized: s.preMaximizeLayout !== null,
});
}
function applyLayoutAndSet(
api: DockviewApi,
state: LayoutState,
@@ -303,6 +332,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
applyLayoutAndSet(api, withoutSidebar, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -316,6 +346,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
applyLayoutAndSet(api, withSidebar, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -340,6 +371,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
applyLayoutAndSet(api, withoutRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -355,6 +387,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
applyLayoutAndSet(api, withRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -402,6 +435,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
});
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -432,6 +466,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
set({ sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -593,7 +628,14 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout(get().defaultPreset),
});
set(ids);
enforceFromStore(api, get);
set({ isRestoringLayout: false });
if (IS_DEBUG) {
debugWidths(
`env-switch-done old=${effectiveOld ?? "-"} new=${newEnvId} ` +
`${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
panelPortalManager.reconcile(new Set(api.panels.map((p) => p.id)));
} catch {
set({ isRestoringLayout: false });
@@ -664,6 +706,7 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) {
applyLayoutAndSet(api, preMaximizeLayout, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -683,6 +726,13 @@ function performBuildDefault(
// Capture dimensions before layout change — api.width can become stale
// after fromJSON inside applyLayout
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
if (IS_DEBUG) {
debugWidths(
`build-default-entry intent=${intentName ?? "-"} ` +
`measured=${safeWidth}x${safeHeight} ` +
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
set({ isRestoringLayout: true, pinnedWidths: freshPinned });
const basePreset = intent?.preset as BuiltInPreset | undefined;
@@ -710,7 +760,11 @@ function performBuildDefault(
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
if (IS_DEBUG) {
debugWidths(`build-default-done ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
}
set({ isRestoringLayout: false });
});
}
@@ -0,0 +1,61 @@
/**
* Width snapshots for the dockview pinned-columns pipeline.
*
* Used by the `[dockview:widths]` debug namespace to trace where the
* sidebar / center / right pixel widths come from. The pipeline has many
* entry points (env-switch fast path, slow path, default build, container
* ResizeObserver, enforcement loop, sash drag) and three sources of truth
* (splitview live sizes, store `pinnedWidths`, `pinned-targets` map), so a
* shared single-line format makes the console output easy to diff across
* events when triaging "wrong width on env-prepare / fresh-load" bugs.
*/
import type { DockviewApi } from "dockview-react";
import { getRootSplitview, getPinnedTarget } from "./layout-manager";
export type ColumnWidthsSnapshot = {
/** sidebar live width — splitview idx 0 (only meaningful when sidebar is visible). */
left: number | null;
/** Live width of the first column between sidebar and right. Null when fewer
* than 3 columns (we can't distinguish center from sidebar/right in a
* 2-column layout without inspecting the layout model). */
center: number | null;
/** right column live width — splitview last index (only meaningful when
* rightPanelsVisible is true and >=2 columns exist). */
right: number | null;
/** Number of root splitview children (== column count). */
columns: number;
/** Dockview's internal grid width — diverges from the DOM container width
* after fromJSON with a stale recorded `grid.width`. Logging both lets us
* spot drift. */
apiW: number;
apiH: number;
};
export function snapshotColumnWidths(api: DockviewApi): ColumnWidthsSnapshot {
const apiW = api.width;
const apiH = api.height;
const sv = getRootSplitview(api);
if (!sv || sv.length === 0) {
return { left: null, center: null, right: null, columns: 0, apiW, apiH };
}
const len = sv.length;
const left = sv.getViewSize(0);
const right = len >= 2 ? sv.getViewSize(len - 1) : null;
const center = len >= 3 ? sv.getViewSize(1) : null;
return { left, center, right, columns: len, apiW, apiH };
}
/** Format a width snapshot as a single string so devtools renders it inline
* rather than collapsing object args to "Object". Includes the live pinned
* targets so a "target mismatches live" bug is visible at a glance. */
export function formatWidthsSnapshot(snap: ColumnWidthsSnapshot): string {
const round = (n: number | null): string => (n === null ? "-" : String(Math.round(n)));
const tgtL = getPinnedTarget("sidebar");
const tgtR = getPinnedTarget("right");
const tL = tgtL === undefined ? "-" : String(Math.round(tgtL));
const tR = tgtR === undefined ? "-" : String(Math.round(tgtR));
return (
`L=${round(snap.left)} C=${round(snap.center)} R=${round(snap.right)} ` +
`cols=${snap.columns} api=${snap.apiW}x${snap.apiH} tgt=L${tL}/R${tR}`
);
}
+14 -4
View File
@@ -89,8 +89,8 @@ export function applyLayout(
// This avoids the "lock to current" ratchet bug where transient container
// shrinks would permanently pin the sidebar at the smaller size.
const sv = getRootSplitview(api);
configureSidebarPinned(api, state, sv);
configureRightPinned(api, state, sv);
configureSidebarPinned(api, state, sv, w);
configureRightPinned(api, state, sv, w);
return resolveGroupIds(api);
}
@@ -100,14 +100,21 @@ function configureSidebarPinned(
state: LayoutState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sv: any,
viewportWidth: number,
): void {
const sidebarCol = state.columns.find((c) => c.id === "sidebar");
const sb = api.getPanel("sidebar");
if (!sb) return;
sb.group.locked = SIDEBAR_LOCK;
sb.group.header.hidden = false;
// Use the measured dockview width (passed via `applyLayout`'s `totalWidth`)
// rather than letting `computePinnedMaxPxFor` fall back to `window.innerWidth`.
// The window-derived path is racy during a layout toggle: a stale
// `innerWidth` of ~601 produces cap=301 (= 601 - VIEWPORT_RESERVE_PX), and
// setConstraints({max:301}) then squeezes a 430px sidebar down to 301 —
// reproducible as the rare `pane-resize-sidebar.spec.ts:41` failure in CI.
sb.group.api.setConstraints({
maximumWidth: sidebarCol?.maxWidth ?? computePinnedMaxPxFor("sidebar"),
maximumWidth: sidebarCol?.maxWidth ?? computePinnedMaxPxFor("sidebar", viewportWidth),
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
if (!sidebarCol) return;
@@ -120,11 +127,14 @@ function configureRightPinned(
state: LayoutState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sv: any,
viewportWidth: number,
): void {
for (let i = 0; i < state.columns.length; i++) {
const col = state.columns[i];
if (col.id === "sidebar" || !col.pinned) continue;
const cap = col.maxWidth ?? computePinnedMaxPxFor(col.id);
// Same rationale as `configureSidebarPinned`: derive the cap from the
// measured dockview width, not `window.innerWidth`.
const cap = col.maxWidth ?? computePinnedMaxPxFor(col.id, viewportWidth);
applyConstraintsToAllPanelGroups(api, col, cap);
if (col.id !== "right") continue;
const live = sv?.getViewSize?.(i);
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# doctor — idempotently wire up pre-commit framework into git's hook system.
# (.pre-commit-config.yaml at the repo root drives it.)
#
# Silent on success. If pre-commit is missing, prints a one-line note and
# exits 0 so callers (e.g. `make dev`) don't block on an optional tool.
# CI still runs the real linters.
#
# Quirks handled:
# - pre-commit refuses to install when core.hooksPath is set, even when
# it's set to the default location (a leftover from a stale setup).
# Detect that exact case and unset it before installing.
# - core.hooksPath is shared between the main repo and every worktree,
# so a single install covers all of them.
set -euo pipefail
if ! command -v pre-commit >/dev/null 2>&1; then
echo "⚠ pre-commit not found on PATH — install with 'pip install pre-commit' to enable hook-based format/lint on commit."
exit 0
fi
default_hooks="$(git rev-parse --git-common-dir)/hooks"
current="$(git config --get core.hooksPath 2>/dev/null || true)"
if [ -n "$current" ] && [ "$current" = "$default_hooks" ]; then
git config --unset core.hooksPath 2>/dev/null || true
fi
pre-commit install -t pre-commit -t commit-msg --overwrite >/dev/null 2>&1 || true