feat(web): auto-open plan panel with unseen-changes indicator (#650)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user