fix(web): preserve dockview state across task and plan-mode switches (#855)

This commit is contained in:
Carlos Florêncio
2026-05-09 11:59:16 +01:00
committed by GitHub
parent 3b26601b77
commit 40c89cd380
15 changed files with 1549 additions and 256 deletions
+56 -20
View File
@@ -1,40 +1,59 @@
---
name: debug-logs
description: Add temporary debug logs to investigate issues. Use when the user needs to trace runtime behavior in the frontend or backend. Debug logs must be stripped before creating a PR.
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.
---
# Debug Logs
Add temporary debug logs to investigate runtime issues. These logs are **never merged** — they must be removed before creating a PR.
**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.
## 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.
3. Log object fields **inline** — do not log raw objects (they render as `Object` in the browser console and require expanding).
2. Use a consistent, searchable prefix so logs can be found and removed easily (e.g. `[reorder-bug]`, `[WS-DEBUG]`).
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.
## Frontend (TypeScript)
- **Level:** `console.log` — not `console.debug` (hidden by default) or `console.warn` (noisy).
- **Prefix:** `[WS-DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user).
- **Format:** Log fields inline as key-value pairs so they are visible without expanding.
- **Prefix:** `[area-bug]` / `[AREA-DEBUG]` agreed with the user.
- **Format:** A single template-literal string. Inline every field. Pre-format arrays/objects with `.map(...).join(...)` or `JSON.stringify(...)` before interpolating.
### ✅ Correct
### ✅ Correct — template literal, every value inlined
```typescript
console.log("[WS-DEBUG] subscribeSession", {
sessionId,
refCount: current + 1,
sentMessage: shouldSend,
});
console.log(
`[reorder-bug] sidebar:render sort=${sort.key}:${sort.direction} active=${activeId ?? "-"}\n` +
` inputOrder:\n ${inputs.map((t) => `${t.id}|${t.title}|state=${t.state}`).join("\n ")}\n` +
` outputOrder:\n ${outputs.map((t) => t.id).join(", ")}`,
);
```
Console output:
```
[WS-DEBUG] subscribeSession {sessionId: 'abc', refCount: 2, sentMessage: true}
Renders as readable plain text in the console — no clicking required, copy-pasteable, diff-friendly.
### ✅ Acceptable — flat object of primitives only
When every value is a primitive (string/number/bool/null), an object literal is fine:
```typescript
console.log("[WS-DEBUG] subscribeSession", { sessionId, refCount: current + 1, sent: shouldSend });
```
### ❌ Wrong — raw object renders as `Object`
Renders as: `[WS-DEBUG] subscribeSession {sessionId: 'abc', refCount: 2, sent: true}`.
### ❌ Wrong — array/nested object collapses
```typescript
console.log("[reorder-bug] render", { tasks: tasks.map(toCompact), groups });
// Output: [reorder-bug] render {tasks: Array(2), groups: {...}} ← unreadable
```
Fix: pre-stringify (`tasks.map(...).join("\n ")`) and embed in a template literal.
### ❌ Wrong — raw object passed as second arg
```typescript
console.log("[WS-DEBUG] subscribeSession", session);
@@ -44,17 +63,18 @@ console.log("[WS-DEBUG] subscribeSession", session);
### ❌ Wrong — wrong log level
```typescript
console.warn("[WS-DEBUG] subscribeSession", { sessionId }); // ← use console.log
console.debug("[WS-DEBUG] subscribeSession", { sessionId }); // ← hidden by default
console.warn("[WS-DEBUG] ...", { sessionId }); // ← use console.log
console.debug("[WS-DEBUG] ...", { sessionId }); // ← hidden by default
```
## Backend (Go)
- **Level:** `WARN` — stands out from normal `DEBUG`/`INFO` output without being an error.
- **Prefix:** `[DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user).
- **Method:** Use the structured logger: `s.logger.Warn("[DEBUG] description", "key", value, ...)`.
- **Method:** Use the structured logger: `s.logger.Warn("[DEBUG] description", "key", value, ...)`. Slog renders each key-value pair inline, so primitives are fine as-is.
- **For slices/maps/structs**, pre-format with `fmt.Sprintf` / `strings.Join` so the value lands on the log line as readable text instead of `[]string{...}`-style verbose output.
### ✅ Correct
### ✅ Correct — primitives as structured fields
```go
s.logger.Warn("[DEBUG] handleTaskMoved entering",
@@ -65,6 +85,15 @@ s.logger.Warn("[DEBUG] handleTaskMoved entering",
)
```
### ✅ Correct — pre-format collections inline
```go
s.logger.Warn("[DEBUG] panel order",
"task_id", taskID,
"panels", strings.Join(panelIDs, ","),
)
```
### ❌ Wrong — wrong level
```go
@@ -72,11 +101,18 @@ s.logger.Debug("[DEBUG] handleTaskMoved", "task_id", taskID) // ← lost in nois
s.logger.Error("[DEBUG] handleTaskMoved", "task_id", taskID) // ← triggers alerts
```
## 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?
- [ ] 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?
## 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.
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.
+111
View File
@@ -25,6 +25,9 @@ var scenarioRegistry = map[string]func(e *emitter){
"diff-expansion-setup": scenarioDiffExpansionSetup,
"diff-update-setup": scenarioDiffUpdateSetup,
"diff-update-modify": scenarioDiffUpdateModify,
"diff-update-streaming": scenarioDiffUpdateStreaming,
"multi-file-setup": scenarioMultiFileSetup,
"multi-file-modify": scenarioMultiFileModify,
"untracked-file-setup": scenarioUntrackedFileSetup,
"untracked-file-modify": scenarioUntrackedFileModify,
"clarification": scenarioClarification,
@@ -387,6 +390,114 @@ func scenarioDiffUpdateModify(e *emitter) {
e.text("diff-update-modify complete: diff_update_test.txt now has SECOND_MODIFICATION")
}
// scenarioDiffUpdateStreaming modifies the file mid-turn, emitting text both
// before and after the write so the agent's turn stays active for several
// seconds. Used to assert that open file editor / diff panels auto-update
// while the agent is still streaming.
func scenarioDiffUpdateStreaming(e *emitter) {
fixedDelay(50)
e.text("diff-update-streaming: starting work")
fixedDelay(1000)
filePath := "diff_update_test.txt"
modifiedContent := "line 1: SECOND_MODIFICATION\nline 2: unchanged\nline 3: ALSO_CHANGED\n"
if err := os.WriteFile(filePath, []byte(modifiedContent), 0o644); err != nil {
e.text("diff-update-streaming: write failed: " + err.Error())
return
}
fixedDelay(500)
e.text("diff-update-streaming: file written, continuing")
// Long tail keeps the turn active long enough for polling+sync to fire.
fixedDelay(6000)
e.text("diff-update-streaming complete")
}
// scenarioMultiFileSetup commits three tracked files, then modifies all three
// so each shows up in git status with FIRST_MODIFICATION. Used to test the
// case where the user has multiple file editors / diff panels open at once.
func scenarioMultiFileSetup(e *emitter) {
fixedDelay(50)
wd, err := os.Getwd()
if err != nil {
e.text("multi-file-setup: getwd failed: " + err.Error())
return
}
runGitCmd := makeGitRunner(wd)
files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"}
// Cleanup any leftovers from a prior run.
for _, f := range files {
_ = runGitCmd("rm", "--force", f)
}
_ = runGitCmd("commit", "-m", "cleanup multi-file fixtures")
// Commit pristine versions.
for _, f := range files {
original := fmt.Sprintf("%s line 1: original\n%s line 2: unchanged\n%s line 3: original\n", f, f, f)
if err := os.WriteFile(f, []byte(original), 0o644); err != nil {
e.text("multi-file-setup: write failed: " + err.Error())
return
}
if err := runGitCmd("add", f); err != nil {
e.text("multi-file-setup: git add failed for " + f)
return
}
}
if err := runGitCmd("commit", "-m", "add multi-file fixtures"); err != nil {
e.text("multi-file-setup: git commit failed")
return
}
// Modify all three so each appears in git status with FIRST_MODIFICATION.
for _, f := range files {
modified := fmt.Sprintf("%s line 1: FIRST_MODIFICATION\n%s line 2: unchanged\n%s line 3: original\n", f, f, f)
if err := os.WriteFile(f, []byte(modified), 0o644); err != nil {
e.text("multi-file-setup: write modified failed: " + err.Error())
return
}
}
fixedDelay(100)
e.text("multi-file-setup complete: 3 files have FIRST_MODIFICATION")
}
// scenarioMultiFileModify modifies all three multi-file fixtures within a
// single turn, with a long trailing delay so the panels must auto-update
// while the agent is still streaming. Reproduces the user's reported case
// where multiple open editor / diff panels go stale on a real edit.
func scenarioMultiFileModify(e *emitter) {
fixedDelay(50)
e.text("multi-file-modify: starting work")
fixedDelay(800)
files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"}
for i, f := range files {
modified := fmt.Sprintf(
"%s line 1: SECOND_MODIFICATION\n%s line 2: unchanged\n%s line 3: ALSO_CHANGED_%d\n",
f, f, f, i,
)
if err := os.WriteFile(f, []byte(modified), 0o644); err != nil {
e.text("multi-file-modify: write failed: " + err.Error())
return
}
// Stagger the writes slightly to mimic a real agent doing one tool
// call per file rather than all writes in a single instant.
fixedDelay(250)
}
e.text("multi-file-modify: writes done, continuing")
// Long tail keeps the turn active so we can assert mid-turn updates.
fixedDelay(5000)
e.text("multi-file-modify complete")
}
// scenarioUntrackedFileSetup creates a new untracked file.
func scenarioUntrackedFileSetup(e *emitter) {
fixedDelay(50)
@@ -31,6 +31,13 @@ import type { BuiltInPreset } from "@/lib/state/layout-manager/presets";
const EMPTY_CONTEXT_FILES: ContextFile[] = [];
const PLAN_CONTEXT_PATH = "plan:context";
// Tracks sessions for which the plan layout has already been auto-applied.
// Module-scoped so it survives TaskChatPanel remounts (dockview's fromJSON
// tears down and rebuilds the portal-hosted panel, which would otherwise
// reset a component-local ref and cause the plan preset to be re-applied —
// clobbering the just-restored saved layout on env switch).
const autoAppliedPlanSessions = new Set<string>();
export type CommentsState = {
planComments: PlanComment[];
pendingCommentsByFile: Record<string, DiffComment[]>;
@@ -82,21 +89,20 @@ function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) {
setPlanMode,
addContextFile,
} = opts;
const autoAppliedPlanSessionRef = useRef<string | null>(null);
useEffect(() => {
if (!resolvedSessionId || !taskId) return;
// Reset the guard when plan mode is disabled so future plan-mode steps
// in the same session can be auto-applied (e.g. after proceeding away and back).
if (!sessionMetaPlanMode && autoAppliedPlanSessionRef.current === resolvedSessionId) {
autoAppliedPlanSessionRef.current = null;
if (!sessionMetaPlanMode && autoAppliedPlanSessions.has(resolvedSessionId)) {
autoAppliedPlanSessions.delete(resolvedSessionId);
return;
}
if (autoAppliedPlanSessionRef.current === resolvedSessionId) return;
if (autoAppliedPlanSessions.has(resolvedSessionId)) return;
// Only auto-apply if both the session metadata AND the current step agree on plan mode.
// sessionMetaPlanMode can be stale (deepMerge hydration preserves deleted keys),
// so we cross-check with the step's actual configuration.
if (sessionMetaPlanMode && currentStepHasPlanMode) {
autoAppliedPlanSessionRef.current = resolvedSessionId;
autoAppliedPlanSessions.add(resolvedSessionId);
setActiveDocument(resolvedSessionId, { type: "plan", taskId });
applyBuiltInPreset("plan");
setPlanMode(resolvedSessionId, true);
@@ -242,6 +242,58 @@ function reconcileRemovedSessionPanels(
const EMPTY_SESSION_IDS_KEY = "";
/**
* Drop the generic "chat" placeholder once a real session is active.
* Skips removal in maximized state to preserve the saved maximize layout.
* Returns true when callers should continue ensuring session panels;
* false when the maximized state intentionally suppresses them.
*/
function prepareLayoutForSessionPanels(api: DockviewApi): boolean {
const preMaximizeLayout = useDockviewStore.getState().preMaximizeLayout;
const chatPanel = api.getPanel("chat");
if (chatPanel && !preMaximizeLayout) {
api.removePanel(chatPanel);
}
// In maximized state, session panels are intentionally absent from the
// layout — they'll be restored when the user exits maximize.
return preMaximizeLayout === null;
}
/**
* Decide whether to force-activate the session panel after it (and any
* sibling tabs) have been ensured.
*
* - It was just created by `ensureSessionPanel` (no prior dockview state
* to honor), or
* - The hook is mounting for the first time (initial page load) — preserve
* the long-standing behavior of focusing the agent tab so the chat is
* visible immediately, even when a saved layout had a different center
* tab active, or
* - The user switched sessions within the same task (intra-task switch
* where dockview hasn't re-activated the new session for us).
*
* After an env (task) switch the prev refs are populated and the task
* changed — `restoreSavedActiveViews` has already applied the saved active
* panel for the incoming task, so calling setActive here would override it
* and force the agent tab on top of whatever the user had focused.
*/
function shouldActivateSessionPanel(args: {
sessionPanelExistedBefore: boolean;
prevTaskId: string | null;
prevSessionId: string | null;
currentTaskId: string | null;
currentSessionId: string;
}): boolean {
const { sessionPanelExistedBefore, prevTaskId, prevSessionId, currentTaskId, currentSessionId } =
args;
if (!sessionPanelExistedBefore) return true;
const isFirstMount = prevTaskId === null && prevSessionId === null;
if (isFirstMount) return true;
const taskChanged = prevTaskId !== currentTaskId;
const sessionChanged = prevSessionId !== currentSessionId;
return sessionChanged && !taskChanged;
}
/**
* Open a dockview tab for every session of the active task and keep them in sync
* with the store.
@@ -256,6 +308,8 @@ const EMPTY_SESSION_IDS_KEY = "";
*/
export function useAutoSessionTab(effectiveSessionId: string | null) {
const sessionTabCreatedRef = useRef<Set<string>>(new Set());
const prevTaskIdRef = useRef<string | null>(null);
const prevSessionIdRef = useRef<string | null>(null);
const appStore = useAppStoreApi();
// Key-based dependency so the effect re-runs when the task's session list
@@ -298,17 +352,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
// be re-created by ensureSessionPanel.
if (!currentSessionIds.includes(effectiveSessionId)) return;
// Remove the generic "chat" placeholder as soon as a real session is
// active — per-session tabs replace it. Skip in maximized state to
// preserve the saved maximize layout.
const chatPanel = api.getPanel("chat");
if (chatPanel && !useDockviewStore.getState().preMaximizeLayout) {
api.removePanel(chatPanel);
}
// In maximized state, session panels are intentionally absent from the
// layout — they'll be restored when the user exits maximize.
if (useDockviewStore.getState().preMaximizeLayout !== null) {
if (!prepareLayoutForSessionPanels(api)) {
sessionTabCreatedRef.current.add(effectiveSessionId);
return;
}
@@ -316,6 +360,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
const initialPosition = resolveInitialPosition(api);
// Active panel first so its group becomes the anchor for siblings.
const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`);
ensureSessionPanel(
api,
effectiveSessionId,
@@ -325,7 +370,16 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
);
const activePanel = api.getPanel(`session:${effectiveSessionId}`);
if (activePanel) {
activePanel.api.setActive();
const shouldActivate = shouldActivateSessionPanel({
sessionPanelExistedBefore,
prevTaskId: prevTaskIdRef.current,
prevSessionId: prevSessionIdRef.current,
currentTaskId: tid ?? null,
currentSessionId: effectiveSessionId,
});
if (shouldActivate) {
activePanel.api.setActive();
}
useDockviewStore.setState({ centerGroupId: activePanel.group.id });
}
@@ -337,5 +391,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
if (sid === effectiveSessionId) continue;
ensureSessionPanel(api, sid, siblingAnchor, true, sessionTabCreatedRef.current);
}
prevTaskIdRef.current = tid ?? null;
prevSessionIdRef.current = effectiveSessionId;
}, [effectiveSessionId, sessionIdsKey, appStore]);
}
@@ -567,7 +567,6 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
() => applyView(displayTasks, effectiveView, { pinnedTaskIds, orderedTaskIds }),
[displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds],
);
return (
<PanelRoot data-testid="task-sidebar">
<SidebarFilterBar />
+274
View File
@@ -69,6 +69,15 @@ function seedDiffUpdateTask(testPage: Page, apiClient: ApiClient, seedData: Seed
});
}
/** Seed a task for the multi-file scenario (3 files, FIRST_MODIFICATION on all). */
function seedMultiFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
return seedTaskWithScenario(testPage, apiClient, seedData, {
title: "Multi-file Diff Update E2E",
scenarioCommand: "/e2e:multi-file-setup",
completionText: "multi-file-setup complete",
});
}
/** Click the Changes dockview tab. */
async function openChangesTab(testPage: Page) {
const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" });
@@ -239,6 +248,271 @@ test.describe("Diff update on file change", () => {
});
});
test.describe("File editor auto-update on file change", () => {
test.describe.configure({ retries: 2, timeout: 120_000 });
test("editor panel auto-updates without re-opening when file changes", async ({
testPage,
apiClient,
seedData,
}) => {
// Regression: opening a file in the FileEditorPanel and then having the
// agent modify it should reactively update the editor content WITHOUT the
// user re-clicking the file.
const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData);
// Open the file in the file editor via the Files tree.
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 10_000 });
const fileRow = session.files.getByText("diff_update_test.txt");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
// The editor tab should appear in dockview.
const editorTab = testPage.locator(".dv-default-tab", { hasText: "diff_update_test.txt" });
await expect(editorTab).toBeVisible({ timeout: 10_000 });
// Verify initial content shows FIRST_MODIFICATION (default Monaco editor).
const editorContent = testPage.locator(".view-lines").first();
await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 15_000 });
// Switch to chat and trigger the second modification.
await session.clickSessionChatTab();
await session.sendMessage("/e2e:diff-update-modify");
await expect(
session.chat.getByText("diff-update-modify complete", { exact: false }),
).toBeVisible({ timeout: 45_000 });
// Click the editor tab back to view it (do NOT re-open from file tree).
// The panel is still mounted, just not the active tab.
await editorTab.click();
// The editor should auto-update with SECOND_MODIFICATION content.
// Re-query .view-lines since DOM may have re-rendered after tab switch.
const updatedEditorContent = testPage.locator(".view-lines").first();
await expect(updatedEditorContent).toContainText("SECOND_MODIFICATION", { timeout: 30_000 });
await expect(updatedEditorContent).toContainText("ALSO_CHANGED", { timeout: 15_000 });
// FIRST_MODIFICATION should be gone.
await expect(updatedEditorContent).not.toContainText("FIRST_MODIFICATION", { timeout: 5_000 });
});
test("editor + diff panels auto-update while agent is streaming (mid-turn)", async ({
testPage,
apiClient,
seedData,
}) => {
// Reproduces the user-reported bug: open both editor and diff from the
// Changes tab, then have the agent modify the file MID-TURN (still
// streaming). Both panels must auto-update within a few seconds while
// the agent's turn is still active — without re-opening the file.
const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData);
// Open the diff panel from the Changes tab.
await openChangesTab(testPage);
await openFileDiff(testPage, "diff_update_test.txt");
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
// Also open the file editor for the same file via the Files tree.
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
const fileRow = session.files.getByText("diff_update_test.txt");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
const editorTab = testPage.locator(".dv-default-tab[type='file-editor']", {
hasText: "diff_update_test.txt",
});
await expect(editorTab).toBeVisible({ timeout: 10_000 });
const editorContent = testPage.locator(".view-lines").first();
await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 15_000 });
// Trigger the streaming scenario: agent will write the file mid-turn and
// keep emitting text for ~6s afterwards. Do NOT wait for completion —
// we want to assert updates while the turn is still streaming.
await session.clickSessionChatTab();
await session.sendMessage("/e2e:diff-update-streaming");
// Wait for the agent to confirm it has started — turn is now live.
await expect(session.chat.getByText("starting work", { exact: false })).toBeVisible({
timeout: 30_000,
});
// While the agent is still mid-turn (it has ~6s of trailing delay), both
// panels must reflect the new content. This is the user's bug scenario.
// Click back to the editor tab to make assertions.
await editorTab.click();
const liveEditorContent = testPage.locator(".view-lines").first();
await expect(liveEditorContent).toContainText("SECOND_MODIFICATION", { timeout: 8_000 });
await expect(liveEditorContent).toContainText("ALSO_CHANGED", { timeout: 5_000 });
// Switch to the diff tab and assert it also updated.
const diffTab = testPage.locator(".dv-default-tab[type='file-diff']", {
hasText: "diff_update_test.txt",
});
await expect(diffTab).toBeVisible({ timeout: 10_000 });
await diffTab.click();
const liveDiffsContainer = getDiffsContainer(testPage);
await expect(liveDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({
timeout: 8_000,
});
await expect(liveDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({
timeout: 5_000,
});
});
});
test.describe("Multi-file editor + diff auto-update", () => {
test.describe.configure({ retries: 2, timeout: 180_000 });
test("diff panel auto-updates across all 3 files during a single multi-file streaming turn", async ({
testPage,
apiClient,
seedData,
}) => {
// Reproduces the user-reported "multiple files / diffs opened" case.
// The Changes panel mounts useFileEditors. Opening a diff for file_a
// mounts another instance. A single multi-file streaming modify turn
// must propagate to gitStatus so:
// - The currently open diff (file_a) auto-updates mid-turn.
// - Switching the preview to file_b shows file_b's NEW diff immediately
// (not the pre-turn FIRST_MODIFICATION).
// - Same for file_c.
// This exercises the gitStatus-driven re-render path without the user
// re-triggering anything per-file.
const { session } = await seedMultiFileTask(testPage, apiClient, seedData);
const fileA = "multi_a.txt";
const fileB = "multi_b.txt";
const fileC = "multi_c.txt";
// Open the diff preview for file_a from the Changes panel.
await openChangesTab(testPage);
await openFileDiff(testPage, fileA);
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
// Trigger the multi-file streaming modification. Don't wait for completion.
await session.clickSessionChatTab();
await session.sendMessage("/e2e:multi-file-modify");
await expect(session.chat.getByText("starting work", { exact: false })).toBeVisible({
timeout: 30_000,
});
// Click back to the file_a diff tab so its panel becomes the visible one.
const diffTabA = testPage
.locator(".dv-default-tab")
.filter({ hasText: `Diff [${fileA}]` })
.first();
await diffTabA.click();
// While the agent is still streaming, the file_a diff must reflect
// SECOND_MODIFICATION + ALSO_CHANGED_0.
await expect(diffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(diffsContainer.getByText("ALSO_CHANGED_0", { exact: true })).toBeVisible({
timeout: 5_000,
});
// Swap the diff preview to file_b — the gitStatus-driven content must
// already have SECOND_MODIFICATION available without re-running the agent.
await openChangesTab(testPage);
await openFileDiff(testPage, fileB);
await expect(diffsContainer.getByText("ALSO_CHANGED_1", { exact: true })).toBeVisible({
timeout: 15_000,
});
// And again for file_c.
await openChangesTab(testPage);
await openFileDiff(testPage, fileC);
await expect(diffsContainer.getByText("ALSO_CHANGED_2", { exact: true })).toBeVisible({
timeout: 15_000,
});
});
});
test.describe("User-save then diff view (colleague repro)", () => {
// Intermittent bug: after agent modifies a file, user opens it in editor,
// edits + saves, then switches to diff view \u2014 the diff panel shows the
// pre-save content (agent's edit only, not the user's save). Workaround
// reported: leave the task and re-enter. We try to repro by driving the
// exact UI sequence and asserting the diff contains the user's marker.
test.describe.configure({ retries: 2, timeout: 120_000 });
test("diff shows user's edit after open-edit-save sequence", async ({
testPage,
apiClient,
seedData,
}) => {
const USER_MARKER = "USER_EDIT_MARKER_42";
const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData);
// Step 1: agent has already modified diff_update_test.txt with FIRST_MODIFICATION.
// Wait for the Changes panel auto-activate (fires once gitStatus 0\u2192N) to
// settle before we click Files \u2014 otherwise the auto-activate races with
// our click and Files ends up inactive. Match any "Changes (N)" count so we
// don't fail when the badge briefly reads a different value.
await expect(testPage.locator(".dv-default-tab", { hasText: /^Changes \(\d+\)/ })).toBeVisible({
timeout: 30_000,
});
// Click Files and verify the file row becomes visible. If a late
// auto-activate stole focus back to Changes, click Files again.
const fileRow = session.fileTreeNode("diff_update_test.txt");
await expect
.poll(
async () => {
await session.clickTab("Files");
return await fileRow.isVisible();
},
{ timeout: 20_000, intervals: [500, 1000, 2000] },
)
.toBe(true);
await fileRow.click();
const editorTab = testPage.locator(".dv-default-tab[type='file-editor']", {
hasText: "diff_update_test.txt",
});
await expect(editorTab).toBeVisible({ timeout: 10_000 });
const editorContent = testPage.locator(".view-lines").first();
await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 30_000 });
// Step 2: type into Monaco \u2014 add a unique marker line at the end.
// Click the view-lines area to focus the editor (Monaco's hidden textarea
// captures input but isn't directly focusable across all browsers).
await editorContent.click();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
// Move caret to end of document, then add a new line with our marker.
await testPage.keyboard.press(`${modifier}+End`);
await testPage.keyboard.press("End");
await testPage.keyboard.type(`\n${USER_MARKER}`);
// Confirm the marker is present in the editor before saving.
await expect(editorContent).toContainText(USER_MARKER, { timeout: 5_000 });
// Step 3: save with Cmd/Ctrl+S.
await testPage.keyboard.press(`${modifier}+s`);
// Step 4: open the diff for the same file.
await openChangesTab(testPage);
await openFileDiff(testPage, "diff_update_test.txt");
// Step 5: the diff must reflect the user's marker.
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText(USER_MARKER, { exact: true })).toBeVisible({
timeout: 15_000,
});
// FIRST_MODIFICATION should still be present (agent's earlier edit).
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 5_000,
});
});
});
test.describe("Untracked file diff update", () => {
test.describe.configure({ retries: 2, timeout: 120_000 });
@@ -1,6 +1,8 @@
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { KanbanPage } from "../../pages/kanban-page";
@@ -23,6 +25,39 @@ async function openFileInPreview(page: Page, session: SessionPage, filename: str
}
}
async function seedFinishedTask(
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ id: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: `Waiting for ${title} session to finish` },
)
.toBe(true);
return task;
}
/** `.dv-tab` is the wrapper dockview toggles `dv-active-tab` on. */
function tabWrapperByText(page: Page, text: string) {
return page.locator(".dv-tab", { has: page.locator(".dv-default-tab", { hasText: text }) });
}
test.describe("Preview tab survives session switch", () => {
test("preview tab persists after switching tasks and back", async ({
testPage,
@@ -122,4 +157,112 @@ test.describe("Preview tab survives session switch", () => {
const fileTab = testPage.locator(".dv-default-tab").filter({ hasText: FILE_A });
await expect(fileTab).toHaveCount(1, { timeout: 15_000 });
});
test("promoted file tab does not duplicate after task switch round-trip", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
// Worker-scoped backend reuses the same repo across tests in the file, so each
// test seeds its own filename to avoid "nothing to commit" collisions.
const FILE_PROMOTED = "promoted.ts";
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(FILE_PROMOTED, "// promoted content");
git.stageAll();
git.commit("seed promoted");
const taskA = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip A");
const taskB = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip B");
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.taskCardByTitle("Promote Round-Trip A").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// Open file in preview, then promote via double-click. The panel keeps id
// `preview:file-editor` with `params.promoted=true` until another file is
// opened, so we sync on the italic class disappearing rather than the testid.
await openFileInPreview(testPage, session, FILE_PROMOTED);
const previewTab = testPage.getByTestId("preview-tab-file-editor");
await expect(previewTab).toBeVisible({ timeout: 15_000 });
await previewTab.dblclick();
await expect(previewTab).not.toHaveClass(/italic/, { timeout: 10_000 });
// Round-trip: switch to Task B, then back to Task A (forces fromJSON restore).
await session.clickTaskInSidebar("Promote Round-Trip B");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 });
await session.waitForLoad();
await session.clickTaskInSidebar("Promote Round-Trip A");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 });
await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 });
// Regression: previously `loadAndRestoreTabs` re-added `file:promoted.ts` on
// top of the snapshot's restored `preview:file-editor` (which was persisted
// as `pinned: true` because it was a promoted preview), producing two tabs
// with the same filename. Strict count of 1 catches the duplicate.
const fileTab = testPage.locator(".dv-default-tab").filter({ hasText: FILE_PROMOTED });
await expect(fileTab).toHaveCount(1, { timeout: 15_000 });
});
test("active center tab is preserved across task switch round-trip", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
const FILE_ACTIVE = "active-file.ts";
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(FILE_ACTIVE, "// active content");
git.stageAll();
git.commit("seed active");
const taskA = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip A");
const taskB = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip B");
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.taskCardByTitle("Active Tab Round-Trip A").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// Open file in preview — it auto-activates, but we click it explicitly to
// make the intent clear and to be robust against any default-active changes.
await openFileInPreview(testPage, session, FILE_ACTIVE);
const fileTabWrapper = tabWrapperByText(testPage, FILE_ACTIVE);
await fileTabWrapper.click();
await expect(fileTabWrapper).toHaveClass(/dv-active-tab/, { timeout: 10_000 });
// Round-trip
await session.clickTaskInSidebar("Active Tab Round-Trip B");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 });
await session.waitForLoad();
await session.clickTaskInSidebar("Active Tab Round-Trip A");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 });
await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 });
// Regression: previously `useAutoSessionTab` always called `setActive()` on
// the session panel after fromJSON, overriding the restored active state.
// The file tab the user left active must remain active across the round-trip.
// (Only one tab per group can be `dv-active-tab` so this implicitly asserts
// the session panel is not active.)
await expect(fileTabWrapper).toHaveClass(/dv-active-tab/, { timeout: 15_000 });
});
});
+273
View File
@@ -0,0 +1,273 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import { useRef } from "react";
import type { FileEditorState } from "@/lib/state/dockview-store";
import type { FileInfo } from "@/lib/state/store";
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
const mockRequestFileContent = vi.fn();
const mockGetWebSocketClient = vi.fn();
let openFilesMap = new Map<string, FileEditorState>();
vi.mock("@/lib/ws/workspace-files", () => ({
requestFileContent: (...args: unknown[]) => mockRequestFileContent(...args),
}));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => mockGetWebSocketClient(),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: {
getState: () => ({ openFiles: openFilesMap }),
},
}));
vi.mock("./use-file-save-delete", () => ({
updatePanelAfterSave: vi.fn(),
}));
vi.mock("@/lib/utils/file-diff", () => ({
calculateHash: async (s: string) => `h:${s.length}:${s.slice(0, 8)}`,
}));
import {
buildGitFileSignature,
syncOpenFileFromWorkspace,
useOpenFileWorkspaceSync,
} from "./file-editors-sync";
const FAKE_CLIENT = {} as ReturnType<typeof import("@/lib/ws/connection").getWebSocketClient>;
const SESSION_ID = "sess-1";
const PATH = "src/foo.ts";
function seedOpenFile(state: Partial<FileEditorState> = {}) {
openFilesMap = new Map<string, FileEditorState>([
[
PATH,
{
path: PATH,
name: "foo.ts",
content: "v1",
originalContent: "v1",
originalHash: "h:2:v1",
isDirty: false,
...state,
},
],
]);
}
describe("buildGitFileSignature", () => {
it("returns a stable __clean__ marker for files absent from git status", () => {
expect(buildGitFileSignature(undefined)).toBe("__clean__");
});
it("changes when the diff content changes (agent-edit detection)", () => {
const before = buildGitFileSignature({
path: PATH,
status: "modified",
staged: false,
additions: 1,
deletions: 0,
diff: "@@ -1 +1 @@\n-v1\n+v2",
} as FileInfo);
const after = buildGitFileSignature({
path: PATH,
status: "modified",
staged: false,
additions: 1,
deletions: 0,
diff: "@@ -1 +1 @@\n-v1\n+v3",
} as FileInfo);
expect(before).not.toBe(after);
});
});
describe("syncOpenFileFromWorkspace", () => {
let updateFileState: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
updateFileState = vi.fn();
});
it("replaces editor content when the agent modifies a clean file", async () => {
// User opens foo.ts (content "v1"). Agent then edits it on disk to "v2".
// The hook detects the git status change and calls this sync function;
// we expect the editor buffer to be replaced in place.
seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" });
mockRequestFileContent.mockResolvedValueOnce({
content: "v2",
is_binary: false,
resolved_path: PATH,
});
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
path: PATH,
updateFileState,
});
expect(mockRequestFileContent).toHaveBeenCalledWith(FAKE_CLIENT, SESSION_ID, PATH);
expect(updateFileState).toHaveBeenCalledTimes(1);
expect(updateFileState).toHaveBeenCalledWith(
PATH,
expect.objectContaining({
content: "v2",
originalContent: "v2",
isDirty: false,
hasRemoteUpdate: false,
}),
);
});
it("flags hasRemoteUpdate (instead of clobbering) when the user has unsaved edits", async () => {
// User has typed local edits (isDirty=true). Agent edits on disk in
// parallel. We must NOT silently replace the user's buffer; we surface
// a Reload affordance via hasRemoteUpdate.
seedOpenFile({
content: "user-typed",
originalContent: "v1",
originalHash: "h:2:v1",
isDirty: true,
});
mockRequestFileContent.mockResolvedValueOnce({
content: "v2-from-agent",
is_binary: false,
resolved_path: PATH,
});
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
path: PATH,
updateFileState,
});
expect(updateFileState).toHaveBeenCalledTimes(1);
expect(updateFileState).toHaveBeenCalledWith(
PATH,
expect.objectContaining({
hasRemoteUpdate: true,
remoteContent: "v2-from-agent",
}),
);
});
it("is a no-op when remote content matches the editor buffer", async () => {
seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" });
mockRequestFileContent.mockResolvedValueOnce({
content: "v1",
is_binary: false,
resolved_path: PATH,
});
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
path: PATH,
updateFileState,
});
expect(updateFileState).not.toHaveBeenCalled();
});
});
function makeStatus(files: Record<string, FileInfo>, timestamp: string): GitStatusEntry {
return {
branch: "main",
remote_branch: null,
modified: [],
added: [],
deleted: [],
untracked: [],
renamed: [],
ahead: 0,
behind: 0,
files,
timestamp,
} as GitStatusEntry;
}
function modifiedFile(diff: string): FileInfo {
return {
path: PATH,
status: "modified",
staged: false,
additions: 1,
deletions: 1,
diff,
};
}
type SyncProps = {
gitStatus: GitStatusEntry | undefined;
openFiles: Map<string, FileEditorState>;
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
};
function renderSyncHook(initial: SyncProps) {
return renderHook(
(props: SyncProps) => {
const activeSessionIdRef = useRef<string | null>(SESSION_ID);
const gitFileSignaturesRef = useRef<Map<string, string>>(new Map());
useOpenFileWorkspaceSync({
gitStatus: props.gitStatus,
openFiles: props.openFiles,
updateFileState: props.updateFileState,
activeSessionIdRef,
gitFileSignaturesRef,
});
},
{ initialProps: initial },
);
}
describe("useOpenFileWorkspaceSync", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetWebSocketClient.mockReturnValue(FAKE_CLIENT);
});
it("refetches the open editor buffer when the agent edits its file", async () => {
// User has foo.ts open showing "v1". Initial git status has no entry for
// foo.ts (clean). Then the agent edits the file: a fresh git status arrives
// with a modified entry. We expect the hook to fetch new content and
// call updateFileState so the editor displays "v2".
seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" });
const updateFileState = vi.fn();
mockRequestFileContent.mockResolvedValue({
content: "v2",
is_binary: false,
resolved_path: PATH,
});
const initialStatus = makeStatus({}, "2026-05-08T11:00:00.000Z");
const { rerender } = renderSyncHook({
gitStatus: initialStatus,
openFiles: openFilesMap,
updateFileState,
});
// First render baselines the signature; no fetch yet.
expect(mockRequestFileContent).not.toHaveBeenCalled();
const editedStatus = makeStatus(
{ [PATH]: modifiedFile("@@ -1 +1 @@\n-v1\n+v2") },
"2026-05-08T11:00:02.000Z",
);
rerender({ gitStatus: editedStatus, openFiles: openFilesMap, updateFileState });
await waitFor(() => expect(mockRequestFileContent).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(updateFileState).toHaveBeenCalledWith(
PATH,
expect.objectContaining({ content: "v2", originalContent: "v2" }),
),
);
cleanup();
});
});
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useEffect } from "react";
import type { FileEditorState } from "@/lib/state/dockview-store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileContent } from "@/lib/ws/workspace-files";
import { calculateHash } from "@/lib/utils/file-diff";
import { updatePanelAfterSave } from "./use-file-save-delete";
import type { FileInfo } from "@/lib/state/store";
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
/**
* Builds a stable signature string from a file's git status entry. The hook
* compares signatures across renders to decide whether the editor's content
* needs re-syncing from the workspace.
*/
export function buildGitFileSignature(file: FileInfo | undefined): string {
if (!file) return "__clean__";
return [
file.status ?? "",
file.staged ? "1" : "0",
String(file.additions ?? 0),
String(file.deletions ?? 0),
file.old_path ?? "",
file.diff ?? "",
].join("|");
}
export type SyncOpenFileArgs = {
client: ReturnType<typeof getWebSocketClient>;
sessionId: string;
path: string;
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
};
/**
* Re-fetches file content from the workspace and reconciles it with the open
* editor buffer. Behavior depends on dirty state:
* - Clean buffer: replaces `content` + `originalContent` so the editor shows
* the latest disk state without losing user focus/cursor.
* - Dirty buffer with matching remote content: clears dirty flag (the user's
* edits happen to equal what landed on disk).
* - Dirty buffer with different remote content: surfaces a "Reload" affordance
* via `hasRemoteUpdate` so the user explicitly chooses to clobber edits.
*/
export async function syncOpenFileFromWorkspace({
client,
sessionId,
path,
updateFileState,
}: SyncOpenFileArgs): Promise<void> {
if (!client) return;
try {
const response = await requestFileContent(client, sessionId, path);
const latest = useDockviewStore.getState().openFiles.get(path);
if (!latest) return;
const remoteHash = await calculateHash(response.content);
if (latest.isDirty) {
if (response.content === latest.content) {
updateFileState(path, {
originalContent: response.content,
originalHash: remoteHash,
isDirty: false,
hasRemoteUpdate: false,
remoteContent: undefined,
remoteOriginalHash: undefined,
});
updatePanelAfterSave(path, latest.name);
return;
}
if (latest.hasRemoteUpdate && latest.remoteContent === response.content) return;
updateFileState(path, {
hasRemoteUpdate: true,
remoteContent: response.content,
remoteOriginalHash: remoteHash,
});
return;
}
if (
latest.content === response.content &&
latest.originalHash === remoteHash &&
!latest.hasRemoteUpdate
) {
return;
}
updateFileState(path, {
content: response.content,
originalContent: response.content,
originalHash: remoteHash,
isDirty: false,
isBinary: response.is_binary,
hasRemoteUpdate: false,
remoteContent: undefined,
remoteOriginalHash: undefined,
});
} catch {
// Ignore sync failures; user can continue editing.
}
}
export type OpenFileWorkspaceSyncParams = {
gitStatus: GitStatusEntry | undefined;
openFiles: Map<string, FileEditorState>;
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
activeSessionIdRef: React.MutableRefObject<string | null>;
gitFileSignaturesRef: React.MutableRefObject<Map<string, string>>;
};
/**
* Watches gitStatus + openFiles and refetches open editor buffers from disk
* whenever a file's git signature changes (the agent edited it, the user ran
* git pull, etc.). Skips the very first observation per file so that opening
* a file doesn't trigger an immediate redundant refetch.
*/
export function useOpenFileWorkspaceSync({
gitStatus,
openFiles,
updateFileState,
activeSessionIdRef,
gitFileSignaturesRef,
}: OpenFileWorkspaceSyncParams) {
useEffect(() => {
const sigMap = gitFileSignaturesRef.current;
for (const path of Array.from(sigMap.keys())) {
if (!openFiles.has(path)) sigMap.delete(path);
}
}, [openFiles, gitFileSignaturesRef]);
useEffect(() => {
const client = getWebSocketClient();
const sessionId = activeSessionIdRef.current;
if (!client || !sessionId) return;
const gitFiles = gitStatus?.files ?? {};
const sigMap = gitFileSignaturesRef.current;
for (const [path, file] of openFiles.entries()) {
// For symlinks, also check the resolved target path in git status
const gitFileInfo = (gitFiles[path] ??
(file.resolvedPath ? gitFiles[file.resolvedPath] : undefined)) as FileInfo | undefined;
const nextSignature = buildGitFileSignature(gitFileInfo);
const prevSignature = sigMap.get(path);
if (prevSignature === undefined) {
sigMap.set(path, nextSignature);
continue;
}
if (prevSignature === nextSignature) continue;
sigMap.set(path, nextSignature);
void syncOpenFileFromWorkspace({ client, sessionId, path, updateFileState });
}
}, [gitStatus, openFiles, updateFileState, activeSessionIdRef, gitFileSignaturesRef]);
}
+25 -131
View File
@@ -15,9 +15,9 @@ import { calculateHash } from "@/lib/utils/file-diff";
import { useToast } from "@/components/toast-provider";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import type { FileContentResponse } from "@/lib/types/backend";
import type { FileInfo } from "@/lib/state/store";
import { useSaveDeleteActions, updatePanelAfterSave } from "./use-file-save-delete";
import { useSaveDeleteActions } from "./use-file-save-delete";
import { PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
import { useOpenFileWorkspaceSync } from "./file-editors-sync";
// Module-level guard: ensures restoration only runs once across all hook instances
let _restoredSessionId: string | null = null;
@@ -84,83 +84,6 @@ function buildPersistedTabs(
});
}
function buildGitFileSignature(file: FileInfo | undefined): string {
if (!file) return "__clean__";
return [
file.status ?? "",
file.staged ? "1" : "0",
String(file.additions ?? 0),
String(file.deletions ?? 0),
file.old_path ?? "",
file.diff ?? "",
].join("|");
}
type SyncOpenFileArgs = {
client: ReturnType<typeof getWebSocketClient>;
sessionId: string;
path: string;
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
};
async function syncOpenFileFromWorkspace({
client,
sessionId,
path,
updateFileState,
}: SyncOpenFileArgs): Promise<void> {
if (!client) return;
try {
const response = await requestFileContent(client, sessionId, path);
const latest = getOpenFiles().get(path);
if (!latest) return;
const remoteHash = await calculateHash(response.content);
if (latest.isDirty) {
if (response.content === latest.content) {
updateFileState(path, {
originalContent: response.content,
originalHash: remoteHash,
isDirty: false,
hasRemoteUpdate: false,
remoteContent: undefined,
remoteOriginalHash: undefined,
});
updatePanelAfterSave(path, latest.name);
return;
}
if (latest.hasRemoteUpdate && latest.remoteContent === response.content) return;
updateFileState(path, {
hasRemoteUpdate: true,
remoteContent: response.content,
remoteOriginalHash: remoteHash,
});
return;
}
if (
latest.content === response.content &&
latest.originalHash === remoteHash &&
!latest.hasRemoteUpdate
) {
return;
}
updateFileState(path, {
content: response.content,
originalContent: response.content,
originalHash: remoteHash,
isDirty: false,
isBinary: response.is_binary,
hasRemoteUpdate: false,
remoteContent: undefined,
remoteOriginalHash: undefined,
});
} catch {
// Ignore sync failures; user can continue editing.
}
}
type RestoreTabsParams = {
activeSessionId: string;
savedTabs: Array<{ path: string; name: string; markdownPreview?: boolean; pinned?: boolean }>;
@@ -173,6 +96,25 @@ type RestoreTabsParams = {
) => void;
};
/**
* Skip persisted tabs the dockview snapshot already restored (preview slot or
* existing pinned panel). Re-adding with `pin: true` here would create a
* duplicate `file:<path>` alongside the existing `preview:file-editor` —
* see the round-trip task switch where a promoted preview was persisted as
* pinned in localStorage and then re-opened on top of the restored preview.
*/
function isAlreadyRestored(
dockApi: ReturnType<typeof useDockviewStore.getState>["api"],
path: string,
): boolean {
if (!dockApi) return false;
if (dockApi.getPanel(`file:${path}`)) return true;
const previewParams = dockApi.getPanel(PREVIEW_FILE_EDITOR_ID)?.params as
| Record<string, unknown>
| undefined;
return previewParams?.previewItemId === path;
}
async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Promise<void> {
const { activeSessionId, savedTabs, savedActiveTab, setFileState, addFileEditorPanel } = params;
const client = getWebSocketClient();
@@ -191,7 +133,9 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
// Create all panels immediately so tabs are visible right away.
// Content is fetched afterwards; if it fails, `useFileLoader` in
// FileEditorPanel retries when the executor becomes available.
const dockApi = useDockviewStore.getState().api;
for (const savedTab of savedTabs) {
if (isAlreadyRestored(dockApi, savedTab.path)) continue;
addFileEditorPanel(savedTab.path, savedTab.name, {
quiet: true,
pin: savedTab.pinned,
@@ -215,11 +159,8 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
/* useFileLoader will retry when executor is ready */
}
}
const dockApi = useDockviewStore.getState().api;
if (dockApi) {
const targetPanel = dockApi.getPanel(savedActiveTab);
if (targetPanel) targetPanel.api.setActive();
}
const targetPanel = dockApi?.getPanel(savedActiveTab);
if (targetPanel) targetPanel.api.setActive();
_restorationInProgress = false;
}
@@ -311,53 +252,6 @@ function useFileEditorEffects({
}, [api, removeFileState]);
}
type OpenFileWorkspaceSyncParams = {
gitStatus: ReturnType<typeof useSessionGitStatus>;
openFiles: ReturnType<typeof useDockviewStore.getState>["openFiles"];
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
activeSessionIdRef: React.MutableRefObject<string | null>;
gitFileSignaturesRef: React.MutableRefObject<Map<string, string>>;
};
function useOpenFileWorkspaceSync({
gitStatus,
openFiles,
updateFileState,
activeSessionIdRef,
gitFileSignaturesRef,
}: OpenFileWorkspaceSyncParams) {
useEffect(() => {
const sigMap = gitFileSignaturesRef.current;
for (const path of Array.from(sigMap.keys())) {
if (!openFiles.has(path)) sigMap.delete(path);
}
}, [openFiles, gitFileSignaturesRef]);
useEffect(() => {
const client = getWebSocketClient();
const sessionId = activeSessionIdRef.current;
if (!client || !sessionId) return;
const gitFiles = gitStatus?.files ?? {};
const sigMap = gitFileSignaturesRef.current;
for (const [path, file] of openFiles.entries()) {
// For symlinks, also check the resolved target path in git status
const gitFileInfo = (gitFiles[path] ??
(file.resolvedPath ? gitFiles[file.resolvedPath] : undefined)) as FileInfo | undefined;
const nextSignature = buildGitFileSignature(gitFileInfo);
const prevSignature = sigMap.get(path);
if (prevSignature === undefined) {
sigMap.set(path, nextSignature);
continue;
}
if (prevSignature === nextSignature) continue;
sigMap.set(path, nextSignature);
void syncOpenFileFromWorkspace({ client, sessionId, path, updateFileState });
}
}, [gitStatus, openFiles, updateFileState, activeSessionIdRef, gitFileSignaturesRef]);
}
type FileEditorActionsParams = {
activeSessionIdRef: React.MutableRefObject<string | null>;
setFileState: (path: string, state: FileEditorState) => void;
+107 -3
View File
@@ -23,9 +23,12 @@ vi.mock("./layout-manager", () => ({
import { getEnvLayout } from "@/lib/local-storage";
import { layoutStructuresMatch, savedLayoutMatchesLive } from "./layout-manager";
const NEW_SESSION_PANEL_ID = "session:new-session";
function makeMockApi() {
return {
panels: [],
groups: [],
layout: vi.fn(),
fromJSON: vi.fn(),
getPanel: vi.fn(() => null),
@@ -67,6 +70,25 @@ function makeParams(overrides?: Partial<EnvSwitchParams>): EnvSwitchParams {
};
}
function makeTwoLeafSavedLayout(
leaves: Array<{ id: string; views: string[]; activeView: string }>,
activeGroup: string,
): ReturnType<typeof getEnvLayout> {
return {
grid: {
root: {
type: "branch" as const,
data: leaves.map((leaf) => ({ type: "leaf", data: leaf })),
},
height: 600,
width: 800,
orientation: "HORIZONTAL" as const,
},
panels: { chat: { contentComponent: "chat" } },
activeGroup,
} as unknown as ReturnType<typeof getEnvLayout>;
}
describe("performEnvSwitch", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -100,7 +122,7 @@ describe("performEnvSwitch", () => {
expect(params.api.addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: "session:new-session",
id: NEW_SESSION_PANEL_ID,
component: "chat",
params: { sessionId: "new-session" },
}),
@@ -109,11 +131,11 @@ describe("performEnvSwitch", () => {
it("skips addPanel on the fast path when the session panel already exists", () => {
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
const panel = { id: "session:new-session", api: { component: "chat" }, group: { id: "g1" } };
const panel = { id: NEW_SESSION_PANEL_ID, api: { component: "chat" }, group: { id: "g1" } };
const params = makeParams({
api: {
...makeMockApi(),
getPanel: vi.fn((id: string) => (id === "session:new-session" ? panel : null)),
getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? panel : null)),
} as unknown as EnvSwitchParams["api"],
});
@@ -146,4 +168,86 @@ describe("performEnvSwitch", () => {
expect(params.api.layout).toHaveBeenCalledWith(800, 600);
expect(params.buildDefault).toHaveBeenCalledWith(params.api);
});
it("preserves the outgoing session panel's tab index when adding the new session on the fast path", () => {
// Regression: the fast-path used to call addPanel with only
// { referenceGroup }, so dockview appended the new session tab to the end
// of the group instead of restoring it to its original slot.
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
const groupPanels = [
{ id: "files", api: { component: "files" } },
{ id: "session:old-session", api: { component: "chat" } },
{ id: "changes", api: { component: "changes" } },
{ id: "terminal-default", api: { component: "terminal" } },
];
const groupId = "center-group";
const outgoing = {
...groupPanels[1],
group: { id: groupId, panels: groupPanels },
};
const api = {
...makeMockApi(),
panels: [outgoing],
groups: [{ id: groupId }],
getPanel: vi.fn(() => null),
} as unknown as EnvSwitchParams["api"];
const params = makeParams({ api });
performEnvSwitch(params);
expect(api.addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: NEW_SESSION_PANEL_ID,
position: { referenceGroup: groupId, index: 1 },
}),
);
});
});
describe("performEnvSwitch fast-path active view restoration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("restores saved per-group active tabs on the fast path", () => {
// Regression: the fast path skips fromJSON, so per-group active tabs
// from the outgoing env would persist into the incoming env. The saved
// layout's activeView for each group must be reapplied.
const setActiveRight = vi.fn();
const setActiveCenter = vi.fn();
const rightGroup = {
id: "right",
panels: [
{ id: "plan", api: { setActive: setActiveRight } },
{ id: "files", api: { setActive: vi.fn() } },
],
};
const centerGroup = {
id: "center",
panels: [{ id: NEW_SESSION_PANEL_ID, api: { setActive: setActiveCenter } }],
};
const savedLayout = makeTwoLeafSavedLayout(
[
{ id: "center", views: ["chat"], activeView: "chat" },
{ id: "right", views: ["plan", "files"], activeView: "plan" },
],
"right",
);
vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout);
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true);
const api = {
...makeMockApi(),
groups: [centerGroup, rightGroup],
getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? centerGroup.panels[0] : null)),
} as unknown as EnvSwitchParams["api"];
performEnvSwitch(makeParams({ api }));
expect(setActiveRight).toHaveBeenCalled();
// The saved activeGroup ("right") is applied last, so its setActive must
// be the most recent — otherwise center would steal global focus.
const lastRightCall = setActiveRight.mock.invocationCallOrder.at(-1) ?? 0;
const lastCenterCall = setActiveCenter.mock.invocationCallOrder.at(-1) ?? 0;
expect(lastRightCall).toBeGreaterThan(lastCenterCall);
});
});
+153 -41
View File
@@ -40,6 +40,60 @@ function savedLayoutHasEphemeralPanels(serialized: SerializedDockview): boolean
return Object.values(panels).some((p) => EPHEMERAL_COMPONENTS.has(p.contentComponent ?? ""));
}
/** Walk the serialized grid tree collecting (groupId, activeView) for each leaf. */
function collectSavedActiveViews(
saved: SerializedDockview,
): Array<{ groupId: string; activeView: string }> {
const out: Array<{ groupId: string; activeView: string }> = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const walk = (node: any): void => {
if (!node) return;
if (Array.isArray(node.data)) {
for (const child of node.data) walk(child);
return;
}
const data = node.data;
if (data?.id && data.activeView) out.push({ groupId: data.id, activeView: data.activeView });
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
walk((saved as any).grid?.root);
return out;
}
/**
* Restore each group's `activeView` from the saved layout. The fast path
* doesn't call `fromJSON`, so per-group active tabs would otherwise carry
* over from the outgoing env (e.g. Task B left "changes" focused in the
* right group, and switching back to Task A would still show "changes"
* even though Task A had "plan" active when it was last saved).
*
* The saved `activeGroup` is applied last so the resulting global focus
* matches what was persisted.
*/
function restoreSavedActiveViews(api: DockviewApi, saved: SerializedDockview): void {
const entries = collectSavedActiveViews(saved);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const savedActiveGroup = (saved as any).activeGroup as string | undefined;
const ordered = savedActiveGroup
? [
...entries.filter((e) => e.groupId !== savedActiveGroup),
...entries.filter((e) => e.groupId === savedActiveGroup),
]
: entries;
for (const { groupId, activeView } of ordered) {
const group = api.groups.find((g) => g.id === groupId);
if (!group) continue;
const panel = group.panels.find((p) => p.id === activeView);
if (panel) {
try {
panel.api.setActive();
} catch {
/* panel may be in a transient state */
}
}
}
}
export type EnvSwitchParams = {
api: DockviewApi;
oldEnvId: string | null;
@@ -53,30 +107,37 @@ export type EnvSwitchParams = {
};
/**
* Remove ephemeral panels (file-editors, diffs, commit-details) from the
* live layout. These are env-scoped panels that shouldn't carry over.
* Predicate matching panels that `removeEphemeralPanels` will close.
*
* When `keepSessionId` is provided, session chat panels whose ID does not
* match `session:{keepSessionId}` are also removed. This handles cross-env
* (cross-task) switches where the fast path is taken: without this, session
* tabs from the old env's task remain visible alongside the new task's tab.
* Ephemeral panels (file-editors, diffs, commit-details, etc.) are env-scoped
* and never carry across switches. When `keepSessionId` is provided, chat
* panels for any other session are also removed so the old env's session tab
* doesn't bleed into the new env. Pulled out so `computeSurvivingIndex` can
* reuse the same survival rules without duplicating them.
*/
function shouldRemoveDuringSwitch(
panel: { id: string; api: { component: string } },
keepSessionId: string | null,
): boolean {
const comp = panel.api.component;
if (EPHEMERAL_COMPONENTS.has(comp)) return true;
if (
keepSessionId !== null &&
comp === "chat" &&
panel.id.startsWith("session:") &&
panel.id !== `session:${keepSessionId}`
) {
return true;
}
return false;
}
/**
* Close every panel that matches `shouldRemoveDuringSwitch`. Used to clear
* env-scoped ephemerals before the new env's panels are restored.
*/
function removeEphemeralPanels(api: DockviewApi, keepSessionId: string | null): void {
const toRemove = api.panels.filter((p) => {
const comp = p.api.component;
if (EPHEMERAL_COMPONENTS.has(comp)) {
return true;
}
if (
keepSessionId !== null &&
comp === "chat" &&
p.id.startsWith("session:") &&
p.id !== `session:${keepSessionId}`
) {
return true;
}
return false;
});
const toRemove = api.panels.filter((p) => shouldRemoveDuringSwitch(p, keepSessionId));
for (const p of toRemove) {
try {
p.api.close();
@@ -86,6 +147,26 @@ function removeEphemeralPanels(api: DockviewApi, keepSessionId: string | null):
}
}
/**
* Given the panels of a group and the id of the panel being replaced, return
* the target tab index for the replacement among the siblings that will
* survive `removeEphemeralPanels`. Returns -1 if the panel isn't in the group.
*/
function computeSurvivingIndex(
groupPanels: readonly { id: string; api: { component: string } }[],
outgoingPanelId: string | undefined,
keepSessionId: string | null,
): number {
if (!outgoingPanelId) return -1;
const idx = groupPanels.findIndex((p) => p.id === outgoingPanelId);
if (idx < 0) return -1;
let count = 0;
for (let i = 0; i < idx; i++) {
if (!shouldRemoveDuringSwitch(groupPanels[i], keepSessionId)) count++;
}
return count;
}
/**
* Fast path: check if we can skip `api.fromJSON()` because the layout
* structure hasn't changed. Returns group IDs if the fast path was taken,
@@ -106,35 +187,66 @@ function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null {
if (!structuresMatch) return null;
if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null;
const outgoingSessionPanel = api.panels.find(
(p) => p.id.startsWith("session:") || p.api.component === "chat",
);
const outgoingGroupId = outgoingSessionPanel?.group?.id;
// Prefer the active session panel so multi-session tasks anchor the
// incoming panel to the group the user was looking at, not whichever
// session tab happens to come first in `api.panels` iteration order.
const isSessionPanel = (p: (typeof api.panels)[number]) =>
p.id.startsWith("session:") || p.api.component === "chat";
const outgoingSessionPanel =
api.panels.find((p) => isSessionPanel(p) && p.api.isActive) ?? api.panels.find(isSessionPanel);
const outgoingGroup = outgoingSessionPanel?.group;
const outgoingGroupId = outgoingGroup?.id;
// Capture the session's index among siblings that will survive
// `removeEphemeralPanels`, so the new session panel lands in the same tab
// slot. Without this, dockview appends and the agent tab drifts to the end
// of the group on every cross-task fast-path switch.
const outgoingIndex = outgoingGroup
? computeSurvivingIndex(outgoingGroup.panels, outgoingSessionPanel?.id, activeSessionId)
: -1;
removeEphemeralPanels(api, activeSessionId);
if (activeSessionId && !api.getPanel(`session:${activeSessionId}`)) {
const sidebarPanel = api.getPanel("sidebar");
let position: import("dockview-react").AddPanelOptions["position"];
if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) {
position = { referenceGroup: outgoingGroupId };
} else if (sidebarPanel) {
position = { direction: "right" as const, referencePanel: "sidebar" };
}
api.addPanel({
id: `session:${activeSessionId}`,
component: "chat",
tabComponent: "sessionTab",
title: "Agent",
params: { sessionId: activeSessionId },
position,
});
addIncomingSessionPanel(api, activeSessionId, outgoingGroupId, outgoingIndex);
}
// The fast path skips `fromJSON`, so per-group active tabs from the
// outgoing env would otherwise persist into the incoming env. Reapply
// them from the saved layout to match what `fromJSON` would have done.
if (saved) restoreSavedActiveViews(api, saved as SerializedDockview);
api.layout(params.safeWidth, params.safeHeight);
return applyLayoutFixups(api);
}
/**
* Add the incoming task's session chat panel, restoring it to the same tab
* slot the outgoing session occupied within `outgoingGroupId` when possible.
*/
function addIncomingSessionPanel(
api: DockviewApi,
sessionId: string,
outgoingGroupId: string | undefined,
outgoingIndex: number,
): void {
let position: import("dockview-react").AddPanelOptions["position"];
if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) {
position =
outgoingIndex >= 0
? { referenceGroup: outgoingGroupId, index: outgoingIndex }
: { referenceGroup: outgoingGroupId };
} else if (api.getPanel("sidebar")) {
position = { direction: "right" as const, referencePanel: "sidebar" };
}
api.addPanel({
id: `session:${sessionId}`,
component: "chat",
tabComponent: "sessionTab",
title: "Agent",
params: { sessionId },
position,
});
}
/**
* Switch the dockview layout between task environments.
*
+35 -10
View File
@@ -651,17 +651,42 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
(window as unknown as { __dockviewApi__: DockviewApi | null }).__dockviewApi__ = api;
}
if (api) {
const resolveFilePath = (panelId: string | undefined): string | null => {
if (!panelId) return null;
if (panelId.startsWith("file:")) return panelId.slice(5);
if (panelId === "preview:file-editor") {
const path = (api.getPanel(panelId)?.params as Record<string, unknown> | undefined)
?.path as string | undefined;
return path ?? null;
}
return null;
};
api.onDidActivePanelChange((event) => {
const id = event?.id;
if (id?.startsWith("file:")) {
set({ activeFilePath: id.slice(5) });
} else if (id === "preview:file-editor") {
const path = (api.getPanel(id)?.params as Record<string, unknown> | undefined)?.path as
| string
| undefined;
set({ activeFilePath: path ?? null });
} else {
set({ activeFilePath: null });
set({ activeFilePath: resolveFilePath(event?.id) });
});
// Track per-panel param-change subscriptions so they can be disposed when
// the panel is removed (e.g. across env switches that re-create the
// preview panel) instead of relying on dockview's internal cleanup.
const paramSubs = new Map<string, { dispose: () => void }>();
api.onDidAddPanel((panel) => {
// The preview file-editor panel reuses a single dockview panel and swaps
// its `params.path` via `updateParameters` when the user previews a
// different file. Dockview does not refire `onDidActivePanelChange` for
// params-only updates on an already-active panel, so subscribe to the
// panel's own parameter-change event and refresh `activeFilePath`.
if (panel.id !== "preview:file-editor") return;
paramSubs.get(panel.id)?.dispose();
const sub = panel.api.onDidParametersChange(() => {
if (!panel.api.isActive) return;
set({ activeFilePath: resolveFilePath(panel.id) });
});
paramSubs.set(panel.id, sub);
});
api.onDidRemovePanel((panel) => {
const sub = paramSubs.get(panel.id);
if (sub) {
sub.dispose();
paramSubs.delete(panel.id);
}
});
}
@@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest";
import type { LayoutState } from "./types";
import { mergePanelsIntoPreset } from "./merger";
const SESSION_ABC = "session:abc";
const SESSION_DEF = "session:def";
function makeLayout(centerPanels: Array<{ id: string; component: string }>): LayoutState {
return {
columns: [
@@ -21,6 +24,34 @@ function makeLayout(centerPanels: Array<{ id: string; component: string }>): Lay
};
}
function makeLayoutWithSide(
centerPanels: Array<{ id: string; component: string }>,
sideColumnId: string,
sidePanels: Array<{ id: string; component: string }>,
): LayoutState {
return {
columns: [
{
id: "sidebar",
groups: [{ panels: [{ id: "sidebar", component: "sidebar", title: "Sidebar" }] }],
},
{
id: "center",
groups: [{ panels: centerPanels.map((p) => ({ ...p, title: p.id })) }],
},
{
id: sideColumnId,
groups: [{ panels: sidePanels.map((p) => ({ ...p, title: p.id })) }],
},
],
};
}
function panelIdsIn(state: LayoutState, columnId: string): string[] {
const col = state.columns.find((c) => c.id === columnId);
return col?.groups.flatMap((g) => g.panels.map((p) => p.id)) ?? [];
}
describe("mergePanelsIntoPreset", () => {
it("replaces chat panel with session panels when session panels exist", () => {
const currentState = makeLayout([{ id: "session:abc123", component: "chat" }]);
@@ -49,8 +80,8 @@ describe("mergePanelsIntoPreset", () => {
it("preserves multiple session panels and drops chat", () => {
const currentState = makeLayout([
{ id: "session:abc", component: "chat" },
{ id: "session:def", component: "chat" },
{ id: SESSION_ABC, component: "chat" },
{ id: SESSION_DEF, component: "chat" },
]);
const targetPreset = makeLayout([{ id: "chat", component: "chat" }]);
@@ -59,8 +90,56 @@ describe("mergePanelsIntoPreset", () => {
const centerPanels = result.columns.find((c) => c.id === "center")!.groups[0].panels;
const panelIds = centerPanels.map((p) => p.id);
expect(panelIds).toContain("session:abc");
expect(panelIds).toContain("session:def");
expect(panelIds).toContain(SESSION_ABC);
expect(panelIds).toContain(SESSION_DEF);
expect(panelIds).not.toContain("chat");
});
it("places side extras (files/changes/terminal) in the side column when one exists", () => {
// Toggling plan mode ON: default layout has files/changes/terminal in
// "right"; the plan preset has a "plan" column. They should land there,
// not pile into center alongside the chat.
const currentState = makeLayoutWithSide([{ id: SESSION_ABC, component: "chat" }], "right", [
{ id: "files", component: "files" },
{ id: "changes", component: "changes" },
{ id: "terminal-default", component: "terminal" },
]);
const targetPreset = makeLayoutWithSide([{ id: "chat", component: "chat" }], "plan", [
{ id: "plan", component: "plan" },
]);
const result = mergePanelsIntoPreset(currentState, targetPreset);
expect(panelIdsIn(result, "center")).toEqual([SESSION_ABC]);
expect(panelIdsIn(result, "plan")).toEqual(["plan", "files", "changes", "terminal-default"]);
});
it("places the surviving plan panel in the center column, not the side column", () => {
// Toggling plan mode OFF: the plan preset has plan in its own column,
// which the default preset doesn't have. Plan should follow the chat
// into center rather than being dumped into "right" alongside files.
const currentState = makeLayoutWithSide([{ id: SESSION_ABC, component: "chat" }], "plan", [
{ id: "plan", component: "plan" },
]);
const targetPreset = makeLayoutWithSide([{ id: "chat", component: "chat" }], "right", [
{ id: "files", component: "files" },
{ id: "changes", component: "changes" },
]);
const result = mergePanelsIntoPreset(currentState, targetPreset);
expect(panelIdsIn(result, "center")).toEqual([SESSION_ABC, "plan"]);
expect(panelIdsIn(result, "right")).toEqual(["files", "changes"]);
});
it("falls back to center for side extras when the target preset has no side column", () => {
const currentState = makeLayoutWithSide([{ id: SESSION_ABC, component: "chat" }], "right", [
{ id: "files", component: "files" },
]);
const targetPreset = makeLayout([{ id: "chat", component: "chat" }]);
const result = mergePanelsIntoPreset(currentState, targetPreset);
expect(panelIdsIn(result, "center")).toEqual([SESSION_ABC, "files"]);
});
});
+54 -29
View File
@@ -5,6 +5,12 @@ import { fromDockviewApi } from "./serializer";
/** Panel IDs that always come from the preset and should never be merged. */
const PRESET_ONLY_PANELS = new Set(["sidebar"]);
/** Panel IDs that, when surviving as extras, belong next to the chat in the
* center column rather than in the side column. The plan panel is paired
* with the chat conceptually, so toggling off plan mode keeps it visible
* in the center group rather than dumping it into the right column. */
const CENTER_EXTRA_PANELS = new Set(["plan"]);
/** Collect all panels from a LayoutState, flattened. */
function collectAllPanels(state: LayoutState): LayoutPanel[] {
const panels: LayoutPanel[] = [];
@@ -23,10 +29,36 @@ function collectPanelIds(state: LayoutState): Set<string> {
return new Set(collectAllPanels(state).map((p) => p.id));
}
/** Identify the column that should receive non-session extras. Prefer the last
* non-sidebar, non-center column (e.g. "right", "plan", "preview", "vscode"),
* falling back to "center" when the preset has no such side column. */
function pickExtrasColumnId(targetPreset: LayoutState): string {
const sideCols = targetPreset.columns.filter((c) => c.id !== "sidebar" && c.id !== "center");
return sideCols[sideCols.length - 1]?.id ?? "center";
}
/** Append `toAdd` panels to a group's first leaf, preserving identity when no-op. */
function appendToFirstGroup(
groups: LayoutState["columns"][number]["groups"],
toAdd: LayoutPanel[],
filterExisting?: (p: LayoutPanel) => boolean,
): LayoutState["columns"][number]["groups"] {
return groups.map((group, idx) => {
if (idx !== 0) return group;
const basePanels = filterExisting ? group.panels.filter(filterExisting) : group.panels;
const existingIds = new Set(basePanels.map((p) => p.id));
const additions = toAdd.filter((p) => !existingIds.has(p.id));
if (additions.length === 0 && basePanels.length === group.panels.length) return group;
return { ...group, panels: [...basePanels, ...additions] };
});
}
/**
* Pure merge logic: merge extra panels from the current state into a target
* preset layout. Session panels (`session:*`) replace the generic `chat`
* panel rather than coexisting alongside it.
* preset layout. Session panels (`session:*`) replace the generic `chat`
* panel in the center column. Other extras (files, changes, terminal, etc.)
* are appended to the side column when one exists (e.g. "right"/"plan"/
* "preview"/"vscode"), otherwise they fall back to the center column.
*/
export function mergePanelsIntoPreset(
currentState: LayoutState,
@@ -43,36 +75,29 @@ export function mergePanelsIntoPreset(
return targetPreset;
}
const hasSessionPanels = extraPanels.some((p) => p.id.startsWith("session:"));
console.debug(
"[layout-merger] merging extra panels into preset:",
extraPanels.map((p) => p.id),
const sessionExtras = extraPanels.filter((p) => p.id.startsWith("session:"));
const centerExtras = extraPanels.filter((p) => CENTER_EXTRA_PANELS.has(p.id));
const sideExtras = extraPanels.filter(
(p) => !p.id.startsWith("session:") && !CENTER_EXTRA_PANELS.has(p.id),
);
const hasSessionPanels = sessionExtras.length > 0;
const extrasColumnId = pickExtrasColumnId(targetPreset);
const mergedColumns = targetPreset.columns.map((col) => {
if (col.id !== "center") return col;
const groups = col.groups.map((group, idx) => {
if (idx !== 0) return group;
// If extra panels include session tabs, drop the generic "chat"
// placeholder — session panels replace it.
const basePanels = hasSessionPanels
? group.panels.filter((p) => p.id !== "chat")
: group.panels;
const existingIds = new Set(basePanels.map((p) => p.id));
const toAdd = extraPanels.filter((p) => !existingIds.has(p.id));
if (toAdd.length === 0 && basePanels.length === group.panels.length) return group;
return {
...group,
panels: [...basePanels, ...toAdd],
};
});
return { ...col, groups };
if (col.id === "center") {
// Sessions always replace the generic "chat" placeholder in center.
// Center-affinity extras (e.g. "plan") sit alongside the chat. When
// the side column doesn't exist, side extras land here too.
const fallbackSide = extrasColumnId === "center" ? sideExtras : [];
const filterChat = hasSessionPanels ? (p: LayoutPanel) => p.id !== "chat" : undefined;
const additions = [...sessionExtras, ...centerExtras, ...fallbackSide];
if (additions.length === 0 && !filterChat) return col;
return { ...col, groups: appendToFirstGroup(col.groups, additions, filterChat) };
}
if (col.id === extrasColumnId && sideExtras.length > 0) {
return { ...col, groups: appendToFirstGroup(col.groups, sideExtras) };
}
return col;
});
return { columns: mergedColumns };