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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user