diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index eca33ba2cf..3c76acdb3d 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -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 ; } +/** 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> = { 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]); diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 633f080daa..e2e0032c91 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -138,7 +138,10 @@ function AddPanelMenuItems({ VS Code {!state.isPassthrough && ( - addPlanPanel(groupId)} className="cursor-pointer text-xs"> + addPlanPanel({ groupId })} + className="cursor-pointer text-xs" + > Plan diff --git a/apps/web/components/task/plan-tab.test.tsx b/apps/web/components/task/plan-tab.test.tsx new file mode 100644 index 0000000000..420856413d --- /dev/null +++ b/apps/web/components/task/plan-tab.test.tsx @@ -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) => 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); }, }; }