feat(web): auto-open plan panel with unseen-changes indicator (#650)

This commit is contained in:
Carlos Florêncio
2026-04-25 18:21:33 +01:00
committed by GitHub
parent 22ebba30b3
commit d431688557
18 changed files with 842 additions and 38 deletions
@@ -20,6 +20,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useFileEditors } from "@/hooks/use-file-editors";
import { useLspFileOpener } from "@/hooks/use-lsp-file-opener";
import { useEditorKeybinds } from "@/hooks/use-editor-keybinds";
import { usePlanPanelAutoOpen } from "@/hooks/use-plan-panel-auto-open";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
@@ -38,6 +39,7 @@ import { PassthroughTerminal } from "./passthrough-terminal";
import { PanelRoot, PanelBody } from "./panel-primitives";
import { ContextMenuTab } from "./tab-context-menu";
import { ChangesTab } from "./changes-tab";
import { PlanTab } from "./plan-tab";
import { PreviewFileTab, PreviewDiffTab, PreviewCommitTab, PinnedDefaultTab } from "./preview-tab";
import { SessionTab } from "./session-tab";
import {
@@ -149,9 +151,23 @@ function PermanentTab(props: IDockviewPanelHeaderProps) {
return <DockviewDefaultTab {...props} hideClose />;
}
/** Sync the user's default saved layout from settings into the dockview store. */
function useSyncUserDefaultLayout() {
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const setUserDefaultLayout = useDockviewStore((s) => s.setUserDefaultLayout);
useEffect(() => {
const defaultLayout = savedLayouts.find((l) => l.is_default);
const state = defaultLayout?.layout as unknown as
| import("@/lib/state/layout-manager").LayoutState
| undefined;
setUserDefaultLayout(state?.columns ? state : null);
}, [savedLayouts, setUserDefaultLayout]);
}
const tabComponents: Record<string, React.FunctionComponent<IDockviewPanelHeaderProps>> = {
permanentTab: PermanentTab,
changesTab: ChangesTab,
planTab: PlanTab,
sessionTab: SessionTab,
previewFileTab: PreviewFileTab,
previewDiffTab: PreviewDiffTab,
@@ -451,24 +467,11 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
const review = useReviewDialog(effectiveSessionId);
// Sync user's default saved layout into dockview store
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const setUserDefaultLayout = useDockviewStore((s) => s.setUserDefaultLayout);
useEffect(() => {
const defaultLayout = savedLayouts.find((l) => l.is_default);
const state = defaultLayout?.layout as unknown as
| import("@/lib/state/layout-manager").LayoutState
| undefined;
setUserDefaultLayout(state?.columns ? state : null);
}, [savedLayouts, setUserDefaultLayout]);
// Connect LSP Go-to-Definition navigation to dockview file tabs
useSyncUserDefaultLayout();
useLspFileOpener();
// Global editor keybinds (tab nav, terminal toggle)
useEditorKeybinds();
usePlanPanelAutoOpen();
// Keep sessionIdRef in sync for use inside event handlers
useEffect(() => {
sessionIdRef.current = effectiveSessionId;
}, [effectiveSessionId]);
@@ -138,7 +138,10 @@ function AddPanelMenuItems({
VS Code
</DropdownMenuItem>
{!state.isPassthrough && (
<DropdownMenuItem onClick={() => addPlanPanel(groupId)} className="cursor-pointer text-xs">
<DropdownMenuItem
onClick={() => addPlanPanel({ groupId })}
className="cursor-pointer text-xs"
>
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
Plan
</DropdownMenuItem>
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import type { TaskPlan } from "@/lib/types/http";
const mockMarkTaskPlanSeen = vi.fn();
let mockActiveTaskId: string | null = "task-1";
let mockPlan: TaskPlan | null = null;
let mockLastSeen: string | undefined = undefined;
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
tasks: { activeTaskId: mockActiveTaskId },
taskPlans: {
byTaskId: mockActiveTaskId && mockPlan ? { [mockActiveTaskId]: mockPlan } : {},
lastSeenUpdatedAtByTaskId:
mockActiveTaskId && mockLastSeen !== undefined
? { [mockActiveTaskId]: mockLastSeen }
: {},
},
markTaskPlanSeen: mockMarkTaskPlanSeen,
}),
}));
vi.mock("dockview-react", () => ({
DockviewDefaultTab: () => null,
}));
import { PlanTab } from "./plan-tab";
const TS = "2026-04-20T00:00:00Z";
const TS_LATER = "2026-04-20T01:00:00Z";
function agentPlan(updated_at = TS): TaskPlan {
return {
id: "plan-1",
task_id: "task-1",
title: "Plan",
content: "# Plan",
created_by: "agent",
created_at: TS,
updated_at,
};
}
type TabApi = {
isActive: boolean;
onDidActiveChange: (listener: (e: { isActive: boolean }) => void) => { dispose: () => void };
};
function makeApi(isActive: boolean): TabApi {
return {
isActive,
onDidActiveChange: () => ({ dispose: () => {} }),
};
}
describe("PlanTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockActiveTaskId = "task-1";
mockPlan = agentPlan();
mockLastSeen = undefined;
});
it("renders the indicator when an agent-authored plan is unseen", () => {
const { container } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeTruthy();
});
it("does not render the indicator when the plan is user-authored", () => {
mockPlan = { ...agentPlan(), created_by: "user" };
const { container } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeNull();
});
it("does not render the indicator when lastSeen matches plan.updated_at", () => {
mockLastSeen = TS;
const { container } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeNull();
});
it("marks seen on initial render when api.isActive is true", () => {
render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(true) } as any)} />,
);
expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1");
});
it("marks seen synchronously when an update lands while the tab is already active", () => {
// Initial render with the tab active and plan already seen
mockLastSeen = TS;
const { rerender } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(true) } as any)} />,
);
mockMarkTaskPlanSeen.mockClear();
// Plan updates while the tab is still active — re-render with new updated_at.
mockPlan = agentPlan(TS_LATER);
rerender(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(true) } as any)} />,
);
// useLayoutEffect must have fired markTaskPlanSeen — keeps the dot from flashing.
expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1");
});
it("does not mark seen on update when the tab is not active", () => {
mockLastSeen = TS;
const { rerender } = render(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
mockMarkTaskPlanSeen.mockClear();
mockPlan = agentPlan(TS_LATER);
rerender(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlanTab {...({ api: makeApi(false) } as any)} />,
);
expect(mockMarkTaskPlanSeen).not.toHaveBeenCalled();
});
});
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useLayoutEffect } from "react";
import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react";
import { useAppStore } from "@/components/state-provider";
/**
* Custom tab component for the Plan panel.
* Shows a small indicator dot when the agent has written/updated the plan but
* the user hasn't focused the Plan panel yet. Focusing the tab clears it.
*/
export function PlanTab(props: IDockviewPanelHeaderProps) {
const { api } = props;
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const plan = useAppStore((s) => (activeTaskId ? s.taskPlans.byTaskId[activeTaskId] : null));
const lastSeen = useAppStore((s) =>
activeTaskId ? s.taskPlans.lastSeenUpdatedAtByTaskId[activeTaskId] : undefined,
);
const markTaskPlanSeen = useAppStore((s) => s.markTaskPlanSeen);
// Clear the indicator when the tab becomes active.
useEffect(() => {
const disposable = api.onDidActiveChange((event) => {
if (event.isActive && activeTaskId) markTaskPlanSeen(activeTaskId);
});
return () => disposable.dispose();
}, [api, activeTaskId, markTaskPlanSeen]);
// If the tab is already active when the plan changes (user is viewing it),
// treat updates as immediately seen. Use useLayoutEffect so the seen-mark
// commits before paint — otherwise the dot flashes for one frame between
// the WS update render and the seen-mark render.
const planUpdatedAt = plan?.updated_at;
useLayoutEffect(() => {
if (api.isActive && activeTaskId) markTaskPlanSeen(activeTaskId);
}, [api, activeTaskId, markTaskPlanSeen, planUpdatedAt]);
const hasUnseen = plan?.created_by === "agent" && lastSeen !== plan.updated_at;
return (
<div data-testid="plan-tab" className="relative">
<DockviewDefaultTab {...props} />
{hasUnseen && (
<span
data-testid="plan-tab-indicator"
className="absolute top-0.5 left-0 size-2 rounded-full bg-primary pointer-events-none"
/>
)}
</div>
);
}
@@ -0,0 +1,190 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
const CREATE_PLAN_SCRIPT = [
'e2e:thinking("creating plan")',
"e2e:delay(100)",
'e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"## Initial\\n\\nStep one","title":"Plan v1"})',
"e2e:delay(100)",
'e2e:message("plan created")',
].join("\n");
const UPDATE_PLAN_SCRIPT = [
'e2e:thinking("updating plan")',
"e2e:delay(100)",
'e2e:mcp:kandev:update_task_plan_kandev({"task_id":"{task_id}","content":"## Updated\\n\\nStep one\\nStep two"})',
"e2e:delay(100)",
'e2e:message("plan updated")',
].join("\n");
function planTabLocator(page: Page) {
// `.dv-tab` is the wrapper dockview toggles `dv-active-tab` on; `.dv-default-tab`
// below it never gets the active class so we target the outer wrapper here.
return page.locator(".dv-tab", { has: page.locator(".dv-default-tab:has-text('Plan')") });
}
function planTabIndicator(page: Page) {
return page.getByTestId("plan-tab-indicator");
}
async function seedTaskAndWaitForIdle(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
description: string,
) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return { session, taskId: task.id };
}
test.describe("Plan panel auto-open + indicator", () => {
test.describe.configure({ retries: 1 });
test("agent create reveals plan tab with indicator and keeps chat focused", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const { session } = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"plan indicator create",
CREATE_PLAN_SCRIPT,
);
// Plan tab is rendered (panel mounted as a sibling of chat in the center group)
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
// Chat panel remained active (no focus steal — plan panel body stays hidden)
await expect(session.chat).toBeVisible();
await expect(planTabLocator(testPage)).not.toHaveClass(/dv-active-tab/);
// Indicator dot is visible on the Plan tab
await expect(planTabIndicator(testPage)).toBeVisible();
});
test("clicking the Plan tab clears the indicator and reveals plan content", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const { session } = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"plan indicator acknowledge",
CREATE_PLAN_SCRIPT,
);
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
await expect(planTabIndicator(testPage)).toBeVisible();
await session.clickTab("Plan");
await expect(planTabLocator(testPage)).toHaveClass(/dv-active-tab/);
await expect(planTabIndicator(testPage)).toHaveCount(0);
await expect(session.planPanel).toBeVisible();
await expect(session.planPanel.getByText("Step one")).toBeVisible({ timeout: 10_000 });
});
test("agent update while on chat re-arms the indicator", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { session } = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"plan indicator update",
CREATE_PLAN_SCRIPT,
);
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
await expect(planTabIndicator(testPage)).toBeVisible();
// Acknowledge then leave back to chat
await session.clickTab("Plan");
await expect(planTabIndicator(testPage)).toHaveCount(0);
await session.clickSessionChatTab();
await expect(planTabLocator(testPage)).not.toHaveClass(/dv-active-tab/);
// Trigger an agent update via a follow-up message
await session.sendMessage(UPDATE_PLAN_SCRIPT);
await expect(session.idleInput()).toBeVisible({ timeout: 45_000 });
// Chat still focused, indicator re-armed
await expect(planTabLocator(testPage)).not.toHaveClass(/dv-active-tab/);
await expect(planTabIndicator(testPage)).toBeVisible();
// Clicking the Plan tab shows the updated content
await session.clickTab("Plan");
await expect(session.planPanel.getByText("Step two")).toBeVisible({ timeout: 15_000 });
});
test("page refresh with existing agent-authored plan shows no stale indicator", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { session, taskId } = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"plan indicator refresh",
CREATE_PLAN_SCRIPT,
);
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
await expect(planTabIndicator(testPage)).toBeVisible();
// Acknowledge
await session.clickTab("Plan");
await expect(planTabIndicator(testPage)).toHaveCount(0);
// Layout persistence is debounced (~300ms) — wait for the saved
// layout to actually include the Plan panel before reloading,
// otherwise the restore will not bring it back.
await testPage.waitForFunction(
() => {
const raw = localStorage.getItem("dockview-layout-v1");
return !!raw && raw.includes('"id":"plan"');
},
null,
{ timeout: 5_000 },
);
// Reload
await testPage.goto(`/t/${taskId}`);
await session.waitForLoad();
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
await expect(planTabIndicator(testPage)).toHaveCount(0);
});
});
@@ -30,6 +30,7 @@ export function useTaskPlan(taskId: string | null, options?: { visible?: boolean
const setTaskPlan = useAppStore((state) => state.setTaskPlan);
const setTaskPlanLoading = useAppStore((state) => state.setTaskPlanLoading);
const setTaskPlanSaving = useAppStore((state) => state.setTaskPlanSaving);
const markTaskPlanSeen = useAppStore((state) => state.markTaskPlanSeen);
const connectionStatus = useAppStore((state) => state.connection.status);
const [error, setError] = useState<string | null>(null);
@@ -42,13 +43,15 @@ export function useTaskPlan(taskId: string | null, options?: { visible?: boolean
try {
const fetchedPlan = await getTaskPlan(taskId);
setTaskPlan(taskId, fetchedPlan);
// Initial fetch is not a notification — mark as seen so no indicator flashes.
markTaskPlanSeen(taskId);
} catch (err) {
console.error("Failed to fetch task plan:", err);
setError(err instanceof Error ? err.message : "Failed to fetch plan");
} finally {
setTaskPlanLoading(taskId, false);
}
}, [taskId, setTaskPlan, setTaskPlanLoading]);
}, [taskId, setTaskPlan, setTaskPlanLoading, markTaskPlanSeen]);
// Fetch plan on mount or when taskId changes
useEffect(() => {
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import type { TaskPlan } from "@/lib/types/http";
const mockAddPlanPanel = vi.fn();
const mockGetPanel = vi.fn();
let mockActiveTaskId: string | null = "task-1";
let mockPlan: TaskPlan | null = null;
let mockLastSeen: string | undefined = undefined;
let mockIsRestoringLayout = false;
let mockApi: { getPanel: typeof mockGetPanel } | null = { getPanel: mockGetPanel };
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
tasks: { activeTaskId: mockActiveTaskId },
taskPlans: {
byTaskId: mockActiveTaskId && mockPlan ? { [mockActiveTaskId]: mockPlan } : {},
lastSeenUpdatedAtByTaskId:
mockActiveTaskId && mockLastSeen !== undefined
? { [mockActiveTaskId]: mockLastSeen }
: {},
},
}),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
api: mockApi,
isRestoringLayout: mockIsRestoringLayout,
addPlanPanel: mockAddPlanPanel,
}),
}));
import { usePlanPanelAutoOpen } from "./use-plan-panel-auto-open";
const TS = "2026-04-20T00:00:00Z";
const TS_LATER = "2026-04-20T01:00:00Z";
function agentPlan(updated_at = TS): TaskPlan {
return {
id: "plan-1",
task_id: "task-1",
title: "Plan",
content: "# Plan",
created_by: "agent",
created_at: TS,
updated_at,
};
}
describe("usePlanPanelAutoOpen", () => {
beforeEach(() => {
vi.clearAllMocks();
mockActiveTaskId = "task-1";
mockPlan = agentPlan();
mockLastSeen = undefined;
mockIsRestoringLayout = false;
mockApi = { getPanel: mockGetPanel };
mockGetPanel.mockReturnValue(null);
});
it("opens plan panel for unseen agent plan", () => {
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).toHaveBeenCalledWith({ quiet: true, inCenter: true });
});
it("does not open when isRestoringLayout is true", () => {
mockIsRestoringLayout = true;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when api is null", () => {
mockApi = null;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when plan created_by is user", () => {
mockPlan = { ...agentPlan(), created_by: "user" };
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when plan is already seen (lastSeen === updated_at)", () => {
mockLastSeen = TS;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("opens again when plan is updated after being seen", () => {
mockLastSeen = TS;
mockPlan = agentPlan(TS_LATER);
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).toHaveBeenCalledWith({ quiet: true, inCenter: true });
});
it("does not open when plan panel already exists in layout", () => {
mockGetPanel.mockReturnValue({ id: "plan" });
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when plan is null", () => {
mockPlan = null;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useAppStore } from "@/components/state-provider";
import { useDockviewStore } from "@/lib/state/dockview-store";
/**
* Watches the active task's plan and opens the Plan panel quietly (without
* stealing focus from the current session) whenever the agent has written a
* new version the user hasn't seen.
*
* Reactive-effect placement is important: the WS event and `activeTaskId`
* being set in the store are a race at page-load time, so doing this in the
* WS handler (which sees only the event moment) loses events. Running as an
* effect keyed on `[activeTaskId, plan.updated_at, lastSeen]` catches both
* orderings.
*/
export function usePlanPanelAutoOpen() {
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const plan = useAppStore((s) => (activeTaskId ? s.taskPlans.byTaskId[activeTaskId] : null));
const lastSeen = useAppStore((s) =>
activeTaskId ? s.taskPlans.lastSeenUpdatedAtByTaskId[activeTaskId] : undefined,
);
const api = useDockviewStore((s) => s.api);
const isRestoringLayout = useDockviewStore((s) => s.isRestoringLayout);
const addPlanPanel = useDockviewStore((s) => s.addPlanPanel);
useEffect(() => {
if (!api || isRestoringLayout) return;
if (!plan || plan.created_by !== "agent") return;
if (lastSeen === plan.updated_at) return;
if (api.getPanel("plan")) return;
addPlanPanel({ quiet: true, inCenter: true });
}, [api, isRestoringLayout, plan, lastSeen, addPlanPanel]);
}
@@ -113,7 +113,13 @@ export function focusOrAddPanel(
}
}
const prev = quiet ? api.activePanel : null;
api.addPanel(options);
if (prev) prev.api.setActive();
// For quiet adds use dockview's `inactive` flag so the new panel is never
// briefly activated. The save-active / restore-active dance flips the new
// panel's `isActive` to true and then back, which fires spurious
// onDidActiveChange events on listeners (e.g. PlanTab's seen-mark).
if (quiet) {
api.addPanel({ ...options, inactive: true });
} else {
api.addPanel(options);
}
}
+8 -3
View File
@@ -408,13 +408,18 @@ export function buildExtraPanelActions(get: StoreGet) {
position: { referenceGroup: centerGroupId },
});
},
addPlanPanel: (groupId?: string) => {
const { api } = get();
addPlanPanel: (opts?: { groupId?: string; quiet?: boolean; inCenter?: boolean }) => {
const { api, centerGroupId } = get();
if (!api) return;
const groupId = opts?.groupId ?? (opts?.inCenter ? centerGroupId : undefined);
const position = groupId
? { referenceGroup: groupId }
: { referencePanel: "chat" as const, direction: "right" as const };
focusOrAddPanel(api, { id: "plan", component: "plan", title: "Plan", position });
focusOrAddPanel(
api,
{ id: "plan", component: "plan", title: "Plan", tabComponent: "planTab", position },
opts?.quiet ?? false,
);
},
addPRPanel: () => {
const { api, centerGroupId } = get();
+1 -1
View File
@@ -109,7 +109,7 @@ type DockviewStore = {
addBrowserPanel: (url?: string, groupId?: string) => void;
addVscodePanel: () => void;
openInternalVscode: (goto_: { file: string; line: number; col: number } | null) => void;
addPlanPanel: (groupId?: string) => void;
addPlanPanel: (opts?: { groupId?: string; quiet?: boolean; inCenter?: boolean }) => void;
addPRPanel: () => void;
addTerminalPanel: (terminalId?: string, groupId?: string) => void;
selectedDiff: { path: string; content?: string } | null;
@@ -45,7 +45,7 @@ export const STRUCTURAL_COMPONENTS = new Set([
export const PANEL_REGISTRY: Record<string, Omit<LayoutPanel, "id">> = {
sidebar: { component: "sidebar", title: "Sidebar" },
chat: { component: "chat", title: "Agent", tabComponent: "permanentTab" },
plan: { component: "plan", title: "Plan" },
plan: { component: "plan", title: "Plan", tabComponent: "planTab" },
changes: { component: "changes", title: "Changes", tabComponent: "changesTab" },
files: { component: "files", title: "Files" },
browser: { component: "browser", title: "Browser", params: { url: "" } },
@@ -74,7 +74,13 @@ export const defaultSessionState: SessionSliceState = {
sessionWorktreesBySessionId: { itemsBySessionId: {} },
pendingModel: { bySessionId: {} },
activeModel: { bySessionId: {} },
taskPlans: { byTaskId: {}, loadingByTaskId: {}, loadedByTaskId: {}, savingByTaskId: {} },
taskPlans: {
byTaskId: {},
loadingByTaskId: {},
loadedByTaskId: {},
savingByTaskId: {},
lastSeenUpdatedAtByTaskId: {},
},
queue: { bySessionId: {}, isLoading: {} },
};
@@ -176,6 +182,12 @@ function buildTaskPlanActions(set: ImmerSet) {
delete draft.taskPlans.loadingByTaskId[taskId];
delete draft.taskPlans.loadedByTaskId[taskId];
delete draft.taskPlans.savingByTaskId[taskId];
delete draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId];
}),
markTaskPlanSeen: (taskId: string) =>
set((draft) => {
const plan = draft.taskPlans.byTaskId[taskId];
draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId] = plan?.updated_at ?? "";
}),
};
}
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createSessionSlice } from "./session-slice";
import type { SessionSlice } from "./types";
import type { TaskPlan } from "@/lib/types/http";
function makeStore() {
return create<SessionSlice>()(immer(createSessionSlice));
}
const TASK_ID = "task-1";
const TS_EPOCH = "2026-04-20T00:00:00Z";
const TS_LATER = "2026-04-20T01:00:00Z";
const TS_LATEST = "2026-04-20T02:00:00Z";
function makePlan(overrides: Partial<TaskPlan> = {}): TaskPlan {
return {
id: "plan-1",
task_id: TASK_ID,
title: "Plan",
content: "# Plan",
created_by: "agent",
created_at: TS_EPOCH,
updated_at: TS_EPOCH,
...overrides,
};
}
describe("task plan slice", () => {
it("markTaskPlanSeen writes the current plan updated_at", () => {
const store = makeStore();
store.getState().setTaskPlan(TASK_ID, makePlan({ updated_at: TS_LATER }));
store.getState().markTaskPlanSeen(TASK_ID);
expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER);
});
it("markTaskPlanSeen with no plan writes an empty-string sentinel", () => {
const store = makeStore();
store.getState().markTaskPlanSeen("task-missing");
expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId["task-missing"]).toBe("");
});
it("setTaskPlan does not change lastSeenUpdatedAtByTaskId", () => {
const store = makeStore();
store.getState().setTaskPlan(TASK_ID, makePlan({ updated_at: TS_EPOCH }));
store.getState().markTaskPlanSeen(TASK_ID);
// New update arrives — seen should NOT advance automatically
store.getState().setTaskPlan(TASK_ID, makePlan({ updated_at: TS_LATEST }));
expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_EPOCH);
});
it("clearTaskPlan removes the lastSeen entry", () => {
const store = makeStore();
store.getState().setTaskPlan(TASK_ID, makePlan());
store.getState().markTaskPlanSeen(TASK_ID);
store.getState().clearTaskPlan(TASK_ID);
expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBeUndefined();
expect(store.getState().taskPlans.byTaskId[TASK_ID]).toBeUndefined();
});
});
@@ -67,6 +67,7 @@ export type TaskPlansState = {
loadingByTaskId: Record<string, boolean>;
loadedByTaskId: Record<string, boolean>;
savingByTaskId: Record<string, boolean>;
lastSeenUpdatedAtByTaskId: Record<string, string>;
};
export type QueuedMessage = {
@@ -141,6 +142,7 @@ export type SessionSliceActions = {
setTaskPlanLoading: (taskId: string, loading: boolean) => void;
setTaskPlanSaving: (taskId: string, saving: boolean) => void;
clearTaskPlan: (taskId: string) => void;
markTaskPlanSeen: (taskId: string) => void;
// Queue actions
setQueueStatus: (sessionId: string, status: QueueStatus) => void;
setQueueLoading: (sessionId: string, loading: boolean) => void;
+1
View File
@@ -395,6 +395,7 @@ export type AppState = {
setTaskPlanLoading: (taskId: string, loading: boolean) => void;
setTaskPlanSaving: (taskId: string, saving: boolean) => void;
clearTaskPlan: (taskId: string) => void;
markTaskPlanSeen: (taskId: string) => void;
// Queue actions
setQueueStatus: (sessionId: string, status: import("./slices/session/types").QueueStatus) => void;
setQueueLoading: (sessionId: string, loading: boolean) => void;
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import { registerTaskPlansHandlers } from "./task-plans";
const TASK_ID = "task-1";
const ACTION_CREATED = "task.plan.created";
const ACTION_UPDATED = "task.plan.updated";
const ACTION_DELETED = "task.plan.deleted";
function makePayload(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "plan-1",
task_id: TASK_ID,
title: "Plan",
content: "# Plan",
created_by: "agent",
created_at: "2026-04-20T00:00:00Z",
updated_at: "2026-04-20T00:00:00Z",
...overrides,
};
}
function makeMessage(action: string, payload: Record<string, unknown>) {
return { id: "msg-1", type: "notification", action, payload };
}
function makeStore(
overrides: Record<string, unknown> = {},
prevPlan: Record<string, unknown> | null = null,
) {
const state = {
tasks: { activeTaskId: TASK_ID, activeSessionId: "s-1" },
taskPlans: { byTaskId: prevPlan ? { [TASK_ID]: prevPlan } : {} },
setTaskPlan: vi.fn(),
markTaskPlanSeen: vi.fn(),
...overrides,
};
return {
getState: () => state as unknown as AppState,
setState: vi.fn(),
subscribe: vi.fn(),
destroy: vi.fn(),
getInitialState: vi.fn(),
} as unknown as StoreApi<AppState>;
}
describe("task.plan.* handlers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("agent created: stores plan and does NOT mark seen", () => {
const store = makeStore();
const handlers = registerTaskPlansHandlers(store);
handlers[ACTION_CREATED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_CREATED, makePayload()) as any,
);
expect(store.getState().setTaskPlan).toHaveBeenCalledWith(TASK_ID, expect.any(Object));
expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled();
});
it("agent updated: stores plan and does NOT mark seen", () => {
const store = makeStore();
const handlers = registerTaskPlansHandlers(store);
handlers[ACTION_UPDATED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_UPDATED, makePayload()) as any,
);
expect(store.getState().setTaskPlan).toHaveBeenCalled();
expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled();
});
it("user-authored create: marks plan as seen", () => {
const store = makeStore();
const handlers = registerTaskPlansHandlers(store);
handlers[ACTION_CREATED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_CREATED, makePayload({ created_by: "user" })) as any,
);
expect(store.getState().setTaskPlan).toHaveBeenCalled();
expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID);
});
it("user-authored update: marks seen even when task is not active", () => {
const store = makeStore({
tasks: { activeTaskId: "other-task", activeSessionId: "s-1" },
});
const handlers = registerTaskPlansHandlers(store);
handlers[ACTION_UPDATED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_UPDATED, makePayload({ created_by: "user" })) as any,
);
expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID);
});
it("agent update on plan originally created by user: stores plan without marking seen", () => {
// Backend sets created_by to the last modifier on update, not the original
// creator — so an agent update on a user-created plan emits created_by="agent".
const store = makeStore();
const handlers = registerTaskPlansHandlers(store);
handlers[ACTION_UPDATED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_UPDATED, makePayload({ created_by: "agent" })) as any,
);
expect(store.getState().setTaskPlan).toHaveBeenCalled();
expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled();
});
it("user-authored update with UNCHANGED content does NOT mark seen", () => {
// Editor auto-save round-trips the agent's plan content through TipTap
// and saves it as user-authored — same content, new updated_at. Without
// a content-change check this would erase the agent's unseen indicator.
const store = makeStore({}, makePayload({ content: "# Plan", created_by: "agent" }));
const handlers = registerTaskPlansHandlers(store);
const payload = makePayload({
content: "# Plan",
created_by: "user",
updated_at: "2026-04-20T01:00:00Z",
});
handlers[ACTION_UPDATED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_UPDATED, payload) as any,
);
expect(store.getState().setTaskPlan).toHaveBeenCalled();
expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled();
});
it("user-authored update with CHANGED content marks seen", () => {
const store = makeStore({}, makePayload({ content: "# Old", created_by: "agent" }));
const handlers = registerTaskPlansHandlers(store);
const payload = makePayload({
content: "# New",
created_by: "user",
updated_at: "2026-04-20T01:00:00Z",
});
handlers[ACTION_UPDATED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_UPDATED, payload) as any,
);
expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID);
});
it("delete: nulls plan and marks as seen", () => {
const store = makeStore();
const handlers = registerTaskPlansHandlers(store);
handlers[ACTION_DELETED]!(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeMessage(ACTION_DELETED, { task_id: TASK_ID }) as any,
);
expect(store.getState().setTaskPlan).toHaveBeenCalledWith(TASK_ID, null);
expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID);
});
});
+13 -12
View File
@@ -2,12 +2,12 @@ import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import type { BackendMessageMap } from "@/lib/types/backend";
import type { WsHandlers } from "@/lib/ws/handlers/types";
import { useDockviewStore } from "@/lib/state/dockview-store";
type PlanMessage = BackendMessageMap["task.plan.created"] | BackendMessageMap["task.plan.updated"];
function handlePlanUpsert(store: StoreApi<AppState>, message: PlanMessage) {
const { task_id, id, title, content, created_by, created_at, updated_at } = message.payload;
const prevPlan = store.getState().taskPlans.byTaskId[task_id];
store.getState().setTaskPlan(task_id, {
id,
task_id,
@@ -18,17 +18,13 @@ function handlePlanUpsert(store: StoreApi<AppState>, message: PlanMessage) {
updated_at,
});
// Auto-open plan panel side-by-side with chat when agent writes a plan
if (created_by === "agent" && task_id === store.getState().tasks.activeTaskId) {
const dockview = useDockviewStore.getState();
if (dockview.isRestoringLayout) return;
if (dockview.api?.getPanel("plan")) return;
const activeSessionId = store.getState().tasks.activeSessionId;
if (!activeSessionId) return;
dockview.addPlanPanel();
store.getState().setActiveDocument(activeSessionId, { type: "plan", taskId: task_id });
// User-authored writes mark the plan as seen — but only when the content
// actually changed. The plan editor's auto-save on mount can emit a
// user-authored update with unchanged content (TipTap markdown round-trip
// normalises whitespace), which would otherwise wipe an unseen agent
// indicator the moment the panel opens.
if (created_by === "user" && prevPlan?.content !== content) {
store.getState().markTaskPlanSeen(task_id);
}
}
@@ -38,7 +34,12 @@ export function registerTaskPlansHandlers(store: StoreApi<AppState>): WsHandlers
"task.plan.updated": (message) => handlePlanUpsert(store, message),
"task.plan.deleted": (message) => {
const { task_id } = message.payload;
// Intentionally NOT clearTaskPlan: setTaskPlan(null) preserves
// loadedByTaskId[taskId] = true so useTaskPlan doesn't see !isLoaded
// and refetch a plan that was just deleted. clearTaskPlan would drop
// that flag and trigger a wasted HTTP round-trip.
store.getState().setTaskPlan(task_id, null);
store.getState().markTaskPlanSeen(task_id);
},
};
}