) => 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
+ ,
+ );
+ 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
+ ,
+ );
+ 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
+ ,
+ );
+ 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
+ ,
+ );
+ 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
+ ,
+ );
+ 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
+ ,
+ );
+
+ // 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
+ ,
+ );
+ mockMarkTaskPlanSeen.mockClear();
+
+ mockPlan = agentPlan(TS_LATER);
+ rerender(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ,
+ );
+
+ expect(mockMarkTaskPlanSeen).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/components/task/plan-tab.tsx b/apps/web/components/task/plan-tab.tsx
new file mode 100644
index 0000000000..c2b4146ea4
--- /dev/null
+++ b/apps/web/components/task/plan-tab.tsx
@@ -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 (
+
+
+ {hasUnseen && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts
new file mode 100644
index 0000000000..a6f1fc62dd
--- /dev/null
+++ b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts
@@ -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);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-task-plan.ts b/apps/web/hooks/domains/session/use-task-plan.ts
index f88d630a3e..b11cf7b0fc 100644
--- a/apps/web/hooks/domains/session/use-task-plan.ts
+++ b/apps/web/hooks/domains/session/use-task-plan.ts
@@ -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(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(() => {
diff --git a/apps/web/hooks/use-plan-panel-auto-open.test.ts b/apps/web/hooks/use-plan-panel-auto-open.test.ts
new file mode 100644
index 0000000000..67e24c2477
--- /dev/null
+++ b/apps/web/hooks/use-plan-panel-auto-open.test.ts
@@ -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) => 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) => 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();
+ });
+});
diff --git a/apps/web/hooks/use-plan-panel-auto-open.ts b/apps/web/hooks/use-plan-panel-auto-open.ts
new file mode 100644
index 0000000000..6cdc396359
--- /dev/null
+++ b/apps/web/hooks/use-plan-panel-auto-open.ts
@@ -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]);
+}
diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts
index 4b70217e7d..e394a513be 100644
--- a/apps/web/lib/state/dockview-layout-builders.ts
+++ b/apps/web/lib/state/dockview-layout-builders.ts
@@ -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);
+ }
}
diff --git a/apps/web/lib/state/dockview-panel-actions.ts b/apps/web/lib/state/dockview-panel-actions.ts
index 517b53f91e..4554b471a6 100644
--- a/apps/web/lib/state/dockview-panel-actions.ts
+++ b/apps/web/lib/state/dockview-panel-actions.ts
@@ -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();
diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts
index 78c466b710..4583c30c1f 100644
--- a/apps/web/lib/state/dockview-store.ts
+++ b/apps/web/lib/state/dockview-store.ts
@@ -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;
diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts
index 1d60a7d841..13179d8c29 100644
--- a/apps/web/lib/state/layout-manager/constants.ts
+++ b/apps/web/lib/state/layout-manager/constants.ts
@@ -45,7 +45,7 @@ export const STRUCTURAL_COMPONENTS = new Set([
export const PANEL_REGISTRY: Record> = {
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: "" } },
diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts
index 54e55a8180..1a2a0e10e6 100644
--- a/apps/web/lib/state/slices/session/session-slice.ts
+++ b/apps/web/lib/state/slices/session/session-slice.ts
@@ -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 ?? "";
}),
};
}
diff --git a/apps/web/lib/state/slices/session/task-plans.test.ts b/apps/web/lib/state/slices/session/task-plans.test.ts
new file mode 100644
index 0000000000..9b7dbe482c
--- /dev/null
+++ b/apps/web/lib/state/slices/session/task-plans.test.ts
@@ -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()(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 {
+ 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();
+ });
+});
diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts
index 304559e8ce..10718ea1b5 100644
--- a/apps/web/lib/state/slices/session/types.ts
+++ b/apps/web/lib/state/slices/session/types.ts
@@ -67,6 +67,7 @@ export type TaskPlansState = {
loadingByTaskId: Record;
loadedByTaskId: Record;
savingByTaskId: Record;
+ lastSeenUpdatedAtByTaskId: Record;
};
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;
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index 6b82010661..cc7a75eb22 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -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;
diff --git a/apps/web/lib/ws/handlers/task-plans.test.ts b/apps/web/lib/ws/handlers/task-plans.test.ts
new file mode 100644
index 0000000000..92c31a6603
--- /dev/null
+++ b/apps/web/lib/ws/handlers/task-plans.test.ts
@@ -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> = {}) {
+ 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) {
+ return { id: "msg-1", type: "notification", action, payload };
+}
+
+function makeStore(
+ overrides: Record = {},
+ prevPlan: Record | 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;
+}
+
+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);
+ });
+});
diff --git a/apps/web/lib/ws/handlers/task-plans.ts b/apps/web/lib/ws/handlers/task-plans.ts
index fb671622c6..9973ad0c26 100644
--- a/apps/web/lib/ws/handlers/task-plans.ts
+++ b/apps/web/lib/ws/handlers/task-plans.ts
@@ -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, 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, 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): 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);
},
};
}