feat(web): support right-click session actions in the kanban preview panel (#3321)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-04 00:47:45 +00:00
committed by GitHub
parent db3a11333e
commit 32ccd3d31e
10 changed files with 973 additions and 87 deletions
+29 -6
View File
@@ -1,12 +1,13 @@
import type { ReactNode, RefObject } from "react";
import type { RefObject } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@kandev/ui/context-menu", () => ({
ContextMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
ContextMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
// Deliberately NOT mocking @kandev/ui/context-menu: the real Radix
// ContextMenuTrigger only conditionally renders its portal-based Content
// while closed, so `renderContextMenu` output (never wrapped in
// ContextMenuContent by these tests) still renders directly as a Root child —
// and using the real component is what let the data-state regression below
// reproduce at all (see #data-state test).
import { SessionTabs, type SessionTab } from "@/components/session-tabs";
describe("SessionTabs terminal tab contract", () => {
@@ -86,4 +87,26 @@ describe("SessionTabs terminal tab contract", () => {
expect(screen.queryByText("Terminal", { selector: "span.truncate" })).toBeNull();
expect(screen.getByRole("tab", { name: "Terminal" })).toBeTruthy();
});
it("keeps the tab's own active/inactive data-state when it has a context menu and no custom content", () => {
// Regression: ContextMenuTrigger asChild clones its own open/closed
// data-state onto its single child via Slot. When that child is the bare
// TabsTrigger (no tab.content wrapper div in between), the clone lands
// directly on TabsTrigger's DOM node and overwrites its active/inactive
// data-state, silently breaking data-[state=active] styling.
const tabs: SessionTab[] = [
{
id: "session-1",
label: "Session 1",
testId: "session-tab-1",
renderContextMenu: () => null,
},
{ id: "session-2", label: "Session 2", testId: "session-tab-2" },
];
render(<SessionTabs tabs={tabs} activeTab="session-1" onTabChange={vi.fn()} />);
expect(screen.getByTestId("session-tab-1").getAttribute("data-state")).toBe("active");
expect(screen.getByTestId("session-tab-2").getAttribute("data-state")).toBe("inactive");
});
});
+8 -1
View File
@@ -166,7 +166,14 @@ function SessionTabItem({
)}
{tab.renderContextMenu ? (
<ContextMenu>
<ContextMenuTrigger asChild>{tabTarget}</ContextMenuTrigger>
{/* `display: contents` keeps this wrapper out of layout while giving
ContextMenuTrigger's `asChild` Slot a DOM node of its own to clone
onto — otherwise, when tabTarget is the bare TabsTrigger (no
tab.content), the Slot's own data-state (open/closed) overwrites
TabsTrigger's data-state (active/inactive) on the same node. */}
<ContextMenuTrigger asChild>
<div className="contents">{tabTarget}</div>
</ContextMenuTrigger>
{tab.renderContextMenu(closeAnchorRef)}
</ContextMenu>
) : (
@@ -0,0 +1,56 @@
"use client";
import { useSessionActions } from "@/hooks/domains/session/use-session-actions";
import { shareableSessionStateClient } from "@/components/task/share/share-button";
import type { TaskSession } from "@/lib/types/http";
import { SessionContextMenuItems } from "./session-tab-menu";
/**
* Per-session context menu for a kanban preview tab.
*
* Mounted once per session inside `SessionTabs`' `renderContextMenu` slot, so
* it owns its own `useSessionActions` instance (stop/resume/setPrimary/delete)
* scoped to that session. Confirmation dialogs (delete/share/handoff) have no
* per-tab slot to render into, so they're hoisted to `PreviewSessionTabs` —
* this component only requests them via the callbacks below.
*/
export function PreviewSessionTabMenu({
session,
taskId,
isPrimary,
onRename,
onRequestDelete,
onSessionRemoved,
onShareRequested,
onHandoff,
}: {
session: TaskSession;
taskId: string;
isPrimary: boolean;
onRename: (sessionId: string) => void;
onRequestDelete: (sessionId: string, event: Event, confirmDelete: () => Promise<boolean>) => void;
onSessionRemoved: (sessionId: string) => void;
onShareRequested: (sessionId: string) => void;
onHandoff: (sessionId: string, profileId: string) => void;
}) {
const { setPrimary, stop, resume, remove } = useSessionActions({
sessionId: session.id,
taskId,
onDeleted: () => onSessionRemoved(session.id),
});
return (
<SessionContextMenuItems
sessionState={session.state}
isPrimary={isPrimary}
canShare={shareableSessionStateClient(session.state)}
taskId={taskId}
sessionId={session.id}
actions={{ handleSetPrimary: setPrimary, handleStop: stop, handleResume: resume }}
onDelete={(event) => onRequestDelete(session.id, event, () => remove({ feedback: "toast" }))}
onShare={() => onShareRequested(session.id)}
onHandoffProfile={(profileId) => onHandoff(session.id, profileId)}
onStartRename={() => onRename(session.id)}
/>
);
}
@@ -1,3 +1,5 @@
/* eslint-disable max-lines -- preview lifecycle and Plan-tab contracts share one store harness. */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
@@ -11,6 +13,7 @@ import {
import { createStore } from "zustand/vanilla";
import { useStore } from "zustand";
import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { TaskPlan } from "@/lib/types/http-agents";
import type { ChatSubmitPayload } from "./chat/chat-input-container";
import type { EntityReference } from "@/lib/types/entity-reference";
@@ -18,6 +21,21 @@ import type { EntityReference } from "@/lib/types/entity-reference";
const mocks = vi.hoisted(() => ({
getWebSocketClient: vi.fn(),
onSend: null as null | ((payload: ChatSubmitPayload) => Promise<void>),
sessions: [] as TaskSession[],
agentProfiles: [] as AgentProfileOption[],
primarySessionId: null as string | null,
kanbanTasks: [{ id: "task-1", primarySessionId: null as string | null }],
kanbanSnapshots: {} as Record<
string,
{ tasks: Array<{ id: string; primarySessionId: string | null }> }
>,
setPrimary: vi.fn(),
stop: vi.fn(),
resume: vi.fn(),
remove: vi.fn(async (_sessionId?: string, _options?: unknown) => true),
renameSession: vi.fn(async () => undefined),
upsertTaskSessionFromEvent: vi.fn(),
taskSessionItems: {} as Record<string, TaskSession>,
useTaskSessions: vi.fn(),
useSessionResumption: vi.fn(),
markTaskPlanSeen: vi.fn(),
@@ -49,6 +67,83 @@ vi.mock("@/components/editors/tiptap/tiptap-plan-readonly", () => ({
<div data-testid={props.testId}>{props.content}</div>
),
}));
vi.mock("@/hooks/domains/session/use-session-actions", () => ({
useSessionActions: ({
sessionId,
onDeleted,
}: {
sessionId?: string;
onDeleted?: () => void;
}) => ({
setPrimary: () => mocks.setPrimary(sessionId),
stop: () => mocks.stop(sessionId),
resume: () => mocks.resume(sessionId),
remove: async (options?: unknown) => {
const ok = await mocks.remove(sessionId, options);
if (ok) onDeleted?.();
return ok;
},
}),
isSessionStoppable: (s: string) =>
s === "RUNNING" || s === "STARTING" || s === "WAITING_FOR_INPUT",
isSessionDeletable: (s: string) => s !== "RUNNING" && s !== "STARTING",
isSessionResumable: (s: string) => s === "COMPLETED" || s === "FAILED" || s === "CANCELLED",
}));
vi.mock("@/components/agent-logo", () => ({
AgentLogo: ({ agentName }: { agentName: string }) => (
<span data-testid={`agent-logo-${agentName}`} />
),
}));
vi.mock("@/components/task/handoff-profile-menu-items", () => ({
HandoffContextMenuSub: () => (
<div role="menuitem" data-testid="session-handoff-submenu">
Handoff
</div>
),
}));
vi.mock("@/components/task/share/share-dialog", () => ({
ShareDialog: () => null,
}));
vi.mock("@/components/task/new-session-dialog", () => ({
NewSessionDialog: () => null,
}));
vi.mock("@/lib/api/domains/session-api", () => ({
renameSession: mocks.renameSession,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn(), updateToast: vi.fn() }),
}));
// Real TabRenameInput auto-focuses on mount inside a useEffect, which races
// against Radix's context-menu-close focus restoration under jsdom's
// synchronous focus/blur emulation (not reproducible in a real browser —
// the identical pattern already ships for terminal tabs). Stubbing it keeps
// this suite testing the preview's own wiring (open → commit through the
// shared rename hook), not TabRenameInput's unrelated focus management.
vi.mock("./tab-rename-input", () => ({
TabRenameInput: ({
initial,
onCommit,
testId,
}: {
initial: string;
onCommit: (next: string) => void;
testId?: string;
}) => {
let value = initial;
return (
<input
data-testid={testId}
defaultValue={initial}
onChange={(e) => {
value = e.target.value;
}}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit(value);
}}
/>
);
},
}));
type TaskPlansState = {
byTaskId: Record<string, TaskPlan | null>;
@@ -58,7 +153,7 @@ type TaskPlansState = {
};
type FakeAppState = {
agentProfiles: { items: never[] };
agentProfiles: { items: AgentProfileOption[] };
connection: { status: "connected" | "disconnected" };
taskPlans: TaskPlansState;
markTaskPlanSeen: (taskId: string) => void;
@@ -118,9 +213,39 @@ function setTaskPlansState(patch: Partial<TaskPlansState>) {
fakeStore.setState((state) => ({ taskPlans: { ...state.taskPlans, ...patch } }));
}
type TestAppState = FakeAppState & {
kanban: {
tasks: Array<{ id: string; primarySessionId: string | null }>;
};
kanbanMulti: {
snapshots: Record<string, { tasks: Array<{ id: string; primarySessionId: string | null }> }>;
};
};
const appStoreApi = {
getState: () => ({
...fakeStore.getState(),
taskSessions: { items: mocks.taskSessionItems },
upsertTaskSessionFromEvent: mocks.upsertTaskSessionFromEvent,
}),
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: FakeAppState) => unknown) => useStore(fakeStore, selector),
useAppStoreApi: () => fakeStore,
useAppStore: (selector: (state: TestAppState) => unknown) =>
useStore(fakeStore, (state) =>
selector({
...state,
agentProfiles: { items: mocks.agentProfiles },
kanban: {
tasks: mocks.kanbanTasks.map((task) => ({
...task,
primarySessionId: mocks.primarySessionId ?? task.primarySessionId,
})),
},
kanbanMulti: { snapshots: mocks.kanbanSnapshots },
}),
),
useAppStoreApi: () => appStoreApi,
}));
import { PreviewSessionBody, PreviewSessionTabs } from "./preview-session-tabs";
@@ -129,12 +254,25 @@ import { usePreviewPlanSummary } from "./preview-plan-panel";
const TASK_ID = "task-1";
const TASK_ID_B = "task-2";
const TIMESTAMP = "2026-07-21T00:00:00Z";
const START_TIME = TIMESTAMP;
const LATER_TIMESTAMP = "2026-07-22T00:00:00Z";
const SESSION_A_TAB_TESTID = "preview-session-tab-session-a";
const PLAN_INDICATOR_TESTID = "preview-plan-tab-indicator";
const PLAN_TAB_TESTID = "preview-plan-tab";
const PLAN_PANEL_TESTID = "preview-plan-panel";
const PLAN_ERROR_TESTID = "preview-plan-error-state";
function makeSession(id: string, overrides: Partial<TaskSession> = {}): TaskSession {
return {
id,
task_id: TASK_ID,
state: "COMPLETED",
started_at: START_TIME,
updated_at: START_TIME,
...overrides,
} as TaskSession;
}
const session: TaskSession = {
id: toSessionId("session-1"),
task_id: toTaskId(TASK_ID),
@@ -147,6 +285,30 @@ afterEach(() => {
cleanup();
mocks.getWebSocketClient.mockReset();
mocks.onSend = null;
mocks.sessions = [];
mocks.agentProfiles = [];
mocks.primarySessionId = null;
mocks.kanbanTasks = [{ id: "task-1", primarySessionId: null }];
mocks.kanbanSnapshots = {};
mocks.setPrimary.mockClear();
mocks.stop.mockClear();
mocks.resume.mockClear();
mocks.remove.mockClear();
mocks.remove.mockImplementation(async () => true);
mocks.renameSession.mockClear();
mocks.upsertTaskSessionFromEvent.mockClear();
mocks.taskSessionItems = {};
mocks.useTaskSessions.mockReset();
mocks.useTaskSessions.mockImplementation(() => ({ sessions: mocks.sessions, isLoaded: true }));
mocks.useSessionResumption.mockReset();
mocks.useSessionResumption.mockImplementation(() => ({
error: null,
notice: null,
resumeSession: vi.fn(),
}));
mocks.getTaskPlan.mockReset();
mocks.getTaskPlan.mockResolvedValue(null);
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
describe("PreviewSessionBody send failures", () => {
@@ -206,6 +368,274 @@ describe("PreviewSessionBody send failures", () => {
});
});
describe("PreviewSessionTabs tab label", () => {
it("shows the session's custom name instead of the agent-derived label", () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED", name: "My renamed agent" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
expect(screen.getByTestId(SESSION_A_TAB_TESTID).textContent).toContain("My renamed agent");
});
});
describe("PreviewSessionTabs session context menu", () => {
it("renders the full lifecycle menu on right-click, without Close Others", () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
expect(screen.getByRole("menuitem", { name: "Rename…" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Set as Primary" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Resume" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Share" })).toBeTruthy();
expect(screen.getByTestId("session-handoff-submenu")).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Close Others" })).toBeNull();
});
it("disables Set as Primary when the session is already primary", () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
mocks.primarySessionId = "session-a";
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
expect(
screen.getByRole("menuitem", { name: "Set as Primary" }).getAttribute("aria-disabled"),
).toBe("true");
});
it("uses the latest primary session from a multi-workflow snapshot", () => {
mocks.sessions = [
makeSession("session-a", { state: "RUNNING" }),
makeSession("session-b", { state: "RUNNING" }),
];
mocks.kanbanTasks = [];
mocks.kanbanSnapshots = {
"workflow-2": { tasks: [{ id: TASK_ID, primarySessionId: "session-a" }] },
};
const { rerender } = render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-b" />);
mocks.kanbanSnapshots = {
"workflow-2": { tasks: [{ id: TASK_ID, primarySessionId: "session-b" }] },
};
rerender(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-b" />);
fireEvent.contextMenu(screen.getByTestId("preview-session-tab-session-b"));
expect(
screen.getByRole("menuitem", { name: "Set as Primary" }).getAttribute("aria-disabled"),
).toBe("true");
});
it("shows Stop (not Resume) and hides Delete for a running session", () => {
mocks.sessions = [makeSession("session-a", { state: "RUNNING" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
expect(screen.getByRole("menuitem", { name: "Stop" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Resume" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Delete" })).toBeNull();
});
it("opens the inline rename input and commits through the shared rename hook", async () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
mocks.taskSessionItems = { "session-a": mocks.sessions[0] };
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
fireEvent.click(screen.getByRole("menuitem", { name: "Rename…" }));
const input = await screen.findByTestId("preview-session-tab-rename-input");
fireEvent.change(input, { target: { value: "renamed session" } });
fireEvent.keyDown(input, { key: "Enter" });
await vi.waitFor(() =>
expect(mocks.renameSession).toHaveBeenCalledWith("session-a", "renamed session"),
);
expect(screen.queryByTestId("preview-session-tab-rename-input")).toBeNull();
});
it("opens the delete confirmation popover and calls session.delete on confirm", async () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
const confirm = await screen.findByTestId("session-delete-confirm");
fireEvent.click(confirm);
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
});
});
async function deleteViaContextMenu(tabTestId: string) {
fireEvent.contextMenu(screen.getByTestId(tabTestId));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
const confirm = await screen.findByTestId("session-delete-confirm");
fireEvent.click(confirm);
}
/** Makes `mocks.remove` hang until the returned resolver is called, so a
* test can change props (simulate the user switching tabs) while a
* `session.delete` round-trip is still in flight. */
function deferDelete() {
let resolveDelete: (ok: boolean) => void = () => {};
mocks.remove.mockImplementation(
() =>
new Promise<boolean>((resolve) => {
resolveDelete = resolve;
}),
);
return {
settle: async (ok: boolean) => {
await act(async () => {
resolveDelete(ok);
await Promise.resolve();
await Promise.resolve();
});
},
};
}
describe("PreviewSessionTabs delete session selection", () => {
it("does not change the viewed session when a non-active tab is deleted", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
makeSession("session-c", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-b"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu("preview-session-tab-session-a");
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
expect(onSessionChange).not.toHaveBeenCalled();
});
it("re-points to a remaining session when the active tab is deleted", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu("preview-session-tab-session-a");
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
expect(onSessionChange).toHaveBeenCalledWith("session-b");
});
it("calls onSessionChange with null when the last remaining session is deleted", async () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
const onSessionChange = vi.fn();
render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu(SESSION_A_TAB_TESTID);
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
expect(onSessionChange).toHaveBeenCalledWith(null);
});
});
describe("PreviewSessionTabs deferred delete", () => {
it("does not redirect when the active tab changes while its own delete is still pending", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
makeSession("session-c", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
const pendingDelete = deferDelete();
const { rerender } = render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu(SESSION_A_TAB_TESTID);
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
// User switches away from the tab being deleted before session.delete settles.
rerender(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-b"
onSessionChange={onSessionChange}
/>,
);
await pendingDelete.settle(true);
expect(onSessionChange).not.toHaveBeenCalled();
});
it("redirects once the pending delete resolves for a tab that became active in the meantime", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
const pendingDelete = deferDelete();
const { rerender } = render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu("preview-session-tab-session-b");
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-b", { feedback: "toast" }),
);
// User switches to the tab now pending deletion before session.delete settles.
rerender(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-b"
onSessionChange={onSessionChange}
/>,
);
await pendingDelete.settle(true);
expect(onSessionChange).toHaveBeenCalledWith("session-a");
});
});
function agentPlan(overrides: Partial<TaskPlan> = {}): TaskPlan {
return {
id: "plan-1",
+260 -24
View File
@@ -1,11 +1,12 @@
"use client";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AgentLogo } from "@/components/agent-logo";
import { GridSpinner } from "@/components/grid-spinner";
import { PanelLoadingState } from "@/components/panel-loading-state";
import { SessionTabs, type SessionTab } from "@/components/session-tabs";
import { useAppStore } from "@/components/state-provider";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session";
@@ -15,8 +16,13 @@ import { sendMessageRequest } from "@/hooks/use-message-handler";
import type { ChatSubmitPayload } from "./chat/chat-input-container";
import { EnsureSessionErrorEmptyState, SessionRecoveryFeedback } from "./ensure-session-error";
import { PassthroughToolbar } from "./passthrough-toolbar";
import { PreviewSessionTabMenu } from "./preview-session-tab-menu";
import { SessionTabDialogs } from "./session-tab-menu";
import { TabRenameInput } from "./tab-rename-input";
import { PreviewPlanPanel, usePreviewPlanSummary } from "./preview-plan-panel";
import { TaskChatPanel } from "./task-chat-panel";
import type { HandoffPreset } from "./new-session-dialog";
import { MAX_SESSION_NAME_LENGTH, useSessionRenameCommitter } from "./use-session-rename";
import {
buildAgentLabelsById,
isSessionActive,
@@ -39,12 +45,211 @@ type PreviewSessionTabsProps = {
onSessionChange?: (sessionId: string | null) => void;
};
/** Delete-confirmation state hoisted out of the per-tab menu: `SessionTabs`
* only gives each tab a slot for its `ContextMenuContent`, not a sibling
* slot for a dialog, so the confirmation popover and the anchor it needs
* live here instead. */
type MenuDeleteState = { sessionId: string; confirm: () => Promise<boolean> };
/** Holds the rename/delete/share/handoff UI state shared by every preview tab. */
function usePreviewSessionTabDialogs(taskId: string, sortedSessions: TaskSession[]) {
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
const [menuDeleteState, setMenuDeleteState] = useState<MenuDeleteState | null>(null);
const menuDeleteAnchorRef = useRef<HTMLElement>(null);
const menuDeleteFocusBoundaryRef = useRef<HTMLElement>(null);
const [shareSessionId, setShareSessionId] = useState<string | null>(null);
const [handoffOpen, setHandoffOpen] = useState(false);
const [handoffPreset, setHandoffPreset] = useState<HandoffPreset | null>(null);
const handleRequestDelete = useCallback(
(targetSessionId: string, event: Event, confirmDelete: () => Promise<boolean>) => {
const anchor = event.currentTarget;
if (!(anchor instanceof HTMLElement)) return;
menuDeleteAnchorRef.current = anchor;
menuDeleteFocusBoundaryRef.current = anchor.closest('[data-slot="context-menu-content"]');
setMenuDeleteState({ sessionId: targetSessionId, confirm: confirmDelete });
},
[],
);
const handleConfirmDelete = useCallback(() => {
void menuDeleteState?.confirm();
}, [menuDeleteState]);
const handleMenuDeleteOpenChange = useCallback((open: boolean) => {
if (!open) setMenuDeleteState(null);
}, []);
const handleHandoffProfile = useCallback((sourceSessionId: string, targetProfileId: string) => {
setHandoffPreset({ sourceSessionId, targetProfileId });
setHandoffOpen(true);
}, []);
const renamingSession = sortedSessions.find((s) => s.id === renamingSessionId) ?? null;
const handleCommitRename = useSessionRenameCommitter(
renamingSessionId ?? undefined,
taskId,
renamingSession?.name ?? null,
() => setRenamingSessionId(null),
);
return {
renamingSessionId,
setRenamingSessionId,
handleCommitRename,
menuDeleteState,
menuDeleteAnchorRef,
menuDeleteFocusBoundaryRef,
handleRequestDelete,
handleConfirmDelete,
handleMenuDeleteOpenChange,
shareSessionId,
setShareSessionId,
handoffOpen,
setHandoffOpen,
handoffPreset,
setHandoffPreset,
handleHandoffProfile,
};
}
type PreviewDialogsState = ReturnType<typeof usePreviewSessionTabDialogs>;
/** Builds the `SessionTab[]` for the preview strip, wiring each tab's
* context menu and (while renaming) its inline rename input. */
function useBuildPreviewTabs({
sortedSessions,
profilesById,
agentLabelsById,
taskId,
isPrimarySession,
dialogs,
handleSessionRemoved,
}: {
sortedSessions: TaskSession[];
profilesById: Record<string, AgentProfileOption>;
agentLabelsById: Record<string, string>;
taskId: string;
isPrimarySession: (session: TaskSession) => boolean;
dialogs: PreviewDialogsState;
handleSessionRemoved: (sessionId: string) => void;
}): SessionTab[] {
return useMemo(
() =>
sortedSessions.map((session) => {
const profile = session.agent_profile_id ? profilesById[session.agent_profile_id] : null;
// Custom name wins over the agent-derived label, mirroring the
// full-page tab title (resolveSessionTabTitle) so a rename is
// visible here, not just persisted.
const label = session.name || resolveProfileSubLabel(session, profile, agentLabelsById);
return {
id: session.id,
label,
icon: isSessionActive(session.state) ? (
<RunningSpinner />
) : (
<SessionAgentLogo profile={profile} />
),
testId: `preview-session-tab-${session.id}`,
className: PREVIEW_TAB_CLASS_NAME,
renderContextMenu: () => (
<PreviewSessionTabMenu
session={session}
taskId={taskId}
isPrimary={isPrimarySession(session)}
onRename={dialogs.setRenamingSessionId}
onRequestDelete={dialogs.handleRequestDelete}
onSessionRemoved={handleSessionRemoved}
onShareRequested={dialogs.setShareSessionId}
onHandoff={dialogs.handleHandoffProfile}
/>
),
content:
dialogs.renamingSessionId === session.id ? (
<TabRenameInput
initial={label}
seqBadge={null}
onCommit={dialogs.handleCommitRename}
onCancel={() => dialogs.setRenamingSessionId(null)}
testId="preview-session-tab-rename-input"
maxLength={MAX_SESSION_NAME_LENGTH}
/>
) : undefined,
};
}),
[
sortedSessions,
agentLabelsById,
profilesById,
taskId,
isPrimarySession,
dialogs,
handleSessionRemoved,
],
);
}
/** Hoisted delete/share/handoff dialogs, keyed off whichever session is
* currently the target (see `usePreviewSessionTabDialogs`). */
function PreviewSessionTabDialogHost({
dialogs,
sortedSessions,
profilesById,
agentLabelsById,
taskId,
isPrimarySession,
}: {
dialogs: PreviewDialogsState;
sortedSessions: TaskSession[];
profilesById: Record<string, AgentProfileOption>;
agentLabelsById: Record<string, string>;
taskId: string;
isPrimarySession: (session: TaskSession) => boolean;
}) {
const deleteTargetSession = dialogs.menuDeleteState
? (sortedSessions.find((s) => s.id === dialogs.menuDeleteState?.sessionId) ?? null)
: null;
const deleteTargetName = deleteTargetSession
? deleteTargetSession.name ||
resolveProfileSubLabel(
deleteTargetSession,
deleteTargetSession.agent_profile_id
? profilesById[deleteTargetSession.agent_profile_id]
: null,
agentLabelsById,
)
: null;
return (
<SessionTabDialogs
confirmDelete={false}
menuDeleteOpen={dialogs.menuDeleteState !== null}
menuDeleteAnchorRef={dialogs.menuDeleteAnchorRef}
menuDeleteFocusBoundaryRef={dialogs.menuDeleteFocusBoundaryRef}
setConfirmDelete={dialogs.handleMenuDeleteOpenChange}
isPrimary={deleteTargetSession ? isPrimarySession(deleteTargetSession) : false}
sessionCount={sortedSessions.length}
onConfirmDelete={dialogs.handleConfirmDelete}
targetName={deleteTargetName}
taskId={taskId}
sessionId={dialogs.shareSessionId ?? undefined}
shareOpen={dialogs.shareSessionId !== null}
setShareOpen={(open) => {
if (!open) dialogs.setShareSessionId(null);
}}
handoffOpen={dialogs.handoffOpen}
setHandoffOpen={dialogs.setHandoffOpen}
handoffPreset={dialogs.handoffPreset}
setHandoffPreset={dialogs.setHandoffPreset}
groupId={undefined}
/>
);
}
/**
* Read-only session tabs for the kanban preview panel.
* Session tabs for the kanban preview panel.
*
* Tabs only switch between existing sessions — creating or deleting sessions
* is deliberately restricted to the full-page task view.
* Right-click a tab for the same lifecycle context menu as the full-page
* task view (rename, set primary, stop/resume, delete, share, handoff).
* Creating a new session is still restricted to the full-page view.
*/
// eslint-disable-next-line max-lines-per-function -- preview owns the shared session and Plan-tab composition.
export function PreviewSessionTabs({
taskId,
sessionId,
@@ -55,6 +260,13 @@ export function PreviewSessionTabs({
const { t } = useTranslation();
const { sessions, isLoaded } = useTaskSessions(taskId);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const primarySessionId = useAppStore(
(state) =>
(
state.kanban.tasks.find((task) => task.id === taskId) ??
findTaskInSnapshots(taskId, state.kanbanMulti.snapshots)
)?.primarySessionId ?? null,
);
const sortedSessions = useMemo(() => sortSessions(sessions), [sessions]);
const agentLabelsById = useMemo(() => buildAgentLabelsById(agentProfiles), [agentProfiles]);
@@ -62,6 +274,11 @@ export function PreviewSessionTabs({
() => Object.fromEntries(agentProfiles.map((p) => [p.id, p])),
[agentProfiles],
);
const isPrimarySession = useCallback(
(session: TaskSession) =>
primarySessionId ? primarySessionId === session.id : session.is_primary === true,
[primarySessionId],
);
const activeSessionId = useMemo(
() => pickActiveSessionId(sortedSessions, sessionId),
@@ -77,6 +294,26 @@ export function PreviewSessionTabs({
// restart where the session row is persisted but agentctl isn't alive).
const resumption = useSessionResumption(taskId, activeSessionId);
const dialogs = usePreviewSessionTabDialogs(taskId, sortedSessions);
// `handleSessionRemoved` is captured once by `useSessionActions`'s `remove`
// closure at the moment delete is confirmed, and `session.delete` can take
// a WS round-trip to settle. Reading these off refs (updated every render)
// instead of closing over `activeSessionId`/`sortedSessions` means the
// eventual `onDeleted` call sees which tab is active *now*, not which one
// was active when the delete was requested.
const activeSessionIdRef = useRef(activeSessionId);
activeSessionIdRef.current = activeSessionId;
const sortedSessionsRef = useRef(sortedSessions);
sortedSessionsRef.current = sortedSessions;
const handleSessionRemoved = useCallback(
(removedSessionId: string) => {
if (removedSessionId !== activeSessionIdRef.current) return;
const remaining = sortedSessionsRef.current.filter((s) => s.id !== removedSessionId);
onSessionChange?.(remaining[0]?.id ?? null);
},
[onSessionChange],
);
// Do not force the Plan view while the session list is still loading. The
// first render can have zero sessions before the list arrives, and treating
// that transient state as sessionless would mark an unseen plan as seen.
@@ -84,26 +321,17 @@ export function PreviewSessionTabs({
const planTabState = usePreviewPlanTab(taskId, hasNoSessions, onSessionChange);
const { viewMode, planTab, hasPlan } = planTabState;
const tabs = useMemo<SessionTab[]>(
() => [
...sortedSessions.map((session) => {
const profile = session.agent_profile_id ? profilesById[session.agent_profile_id] : null;
return {
id: session.id,
label: resolveProfileSubLabel(session, profile, agentLabelsById),
icon: isSessionActive(session.state) ? (
<RunningSpinner />
) : (
<SessionAgentLogo profile={profile} />
),
testId: `preview-session-tab-${session.id}`,
className: PREVIEW_TAB_CLASS_NAME,
};
}),
planTab,
],
[sortedSessions, agentLabelsById, profilesById, planTab],
);
const sessionTabs = useBuildPreviewTabs({
sortedSessions,
profilesById,
agentLabelsById,
taskId,
isPrimarySession,
dialogs,
handleSessionRemoved,
});
const tabs = useMemo<SessionTab[]>(() => [...sessionTabs, planTab], [sessionTabs, planTab]);
if (!isLoaded && sortedSessions.length === 0) {
return <PreviewLoadingState label={t("task:loadingAgents")} />;
@@ -146,6 +374,14 @@ export function PreviewSessionTabs({
taskId={taskId}
/>
</div>
<PreviewSessionTabDialogHost
dialogs={dialogs}
sortedSessions={sortedSessions}
profilesById={profilesById}
agentLabelsById={agentLabelsById}
taskId={taskId}
isPrimarySession={isPrimarySession}
/>
</div>
);
}
@@ -97,6 +97,43 @@ describe("SessionContextMenuItems", () => {
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete.mock.calls[0]?.[0].defaultPrevented).toBe(true);
});
it("omits Close Others (and its separator) when the action isn't provided", () => {
render(
<ContextMenu>
<ContextMenuTrigger asChild>
<button type="button">Session</button>
</ContextMenuTrigger>
<SessionContextMenuItems
sessionState="COMPLETED"
isPrimary={false}
canShare={false}
taskId={null}
sessionId={undefined}
actions={{
handleSetPrimary: vi.fn(),
handleStop: vi.fn(),
handleResume: vi.fn(),
}}
onDelete={vi.fn()}
onShare={vi.fn()}
onHandoffProfile={vi.fn()}
onStartRename={vi.fn()}
/>
</ContextMenu>,
);
fireEvent.contextMenu(screen.getByRole("button", { name: "Session" }), {
clientX: 100,
clientY: 100,
});
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Close Others" })).toBeNull();
// Only the setAsPrimary/stop-or-resume separator renders; no orphan
// separator is left behind where Close Others' own separator would go.
expect(screen.getAllByRole("separator")).toHaveLength(1);
});
});
function SessionDeletePopoverHarness({ onConfirm }: { onConfirm: () => void }) {
+13 -6
View File
@@ -25,12 +25,15 @@ import type { TaskSessionState } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
import { SessionDeleteDescription } from "./session-delete-description";
/** Lifecycle callbacks the context menu needs from the owning tab. */
/** Lifecycle callbacks the context menu needs from the owning tab.
* `handleCloseOthers` is dockview-specific (closing sibling panels) and has
* no equivalent where tabs are a plain session switcher, so it's optional —
* omitting it hides the Close Others item entirely. */
export type SessionTabMenuActions = {
handleSetPrimary: () => void;
handleStop: () => void;
handleResume: () => void;
handleCloseOthers: () => void;
handleCloseOthers?: () => void;
};
export function DeleteSessionDialog({
@@ -194,10 +197,14 @@ export function SessionContextMenuItems({
<HandoffContextMenuSub taskId={taskId} onSelectProfile={onHandoffProfile} />
</>
)}
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer" onSelect={actions.handleCloseOthers}>
{t("task:closeOthers")}
</ContextMenuItem>
{actions.handleCloseOthers && (
<>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer" onSelect={actions.handleCloseOthers}>
{t("task:closeOthers")}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
);
}
+2 -45
View File
@@ -14,9 +14,7 @@ import { IconStar } from "@tabler/icons-react";
import { AgentLogo } from "@/components/agent-logo";
import { GridSpinner } from "@/components/grid-spinner";
import { ContextMenu, ContextMenuTrigger } from "@kandev/ui/context-menu";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { renameSession } from "@/lib/api/domains/session-api";
import { useAppStore } from "@/components/state-provider";
import {
useSessionActions,
isSessionDeletable as isDeletable,
@@ -36,7 +34,7 @@ import { TabRenameInput } from "./tab-rename-input";
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
import { SessionTabCloseAction } from "./session-tab-close-action";
import { useSessionTabDelete } from "./use-session-tab-delete";
import { useTranslation } from "react-i18next";
import { MAX_SESSION_NAME_LENGTH, useSessionRenameCommitter } from "./use-session-rename";
function useSessionTabState(sessionId: string | undefined) {
const isPrimary = useAppStore((state) => {
@@ -129,47 +127,6 @@ function useSessionTabState(sessionId: string | undefined) {
};
}
/** Mirrors the backend's maxSessionNameLength so the optimistic store update
* matches what the rename broadcast will echo back. */
const MAX_SESSION_NAME_LENGTH = 120;
/** Commit a session tab rename: persist via WS and optimistically update the store. */
function useSessionRenameCommitter(
sessionId: string | undefined,
taskId: string | null,
currentName: string | null,
onDone: () => void,
) {
const { t } = useTranslation();
const appStoreApi = useAppStoreApi();
const { toast } = useToast();
return useCallback(
async (next: string) => {
onDone();
if (!sessionId || !taskId) return;
const normalized = next.trim().slice(0, MAX_SESSION_NAME_LENGTH);
if ((currentName ?? "") === normalized) return;
try {
await renameSession(sessionId, normalized);
const existing = appStoreApi.getState().taskSessions.items[sessionId];
if (existing) {
appStoreApi
.getState()
.upsertTaskSessionFromEvent(taskId, { ...existing, name: normalized });
}
} catch (error) {
console.error("rename session:", error);
toast({
title: t("task:renameFailed"),
description: error instanceof Error ? error.message : t("common:unknownError"),
variant: "error",
});
}
},
[sessionId, taskId, currentName, appStoreApi, onDone, toast],
);
}
function useSessionTabActions(
sessionId: string | undefined,
taskId: string | null,
@@ -0,0 +1,48 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useAppStoreApi } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { renameSession } from "@/lib/api/domains/session-api";
/** Mirrors the backend's maxSessionNameLength so the optimistic store update
* matches what the rename broadcast will echo back. */
export const MAX_SESSION_NAME_LENGTH = 120;
/** Commit a session tab rename: persist via WS and optimistically update the store. */
export function useSessionRenameCommitter(
sessionId: string | undefined,
taskId: string | null,
currentName: string | null,
onDone: () => void,
) {
const { t } = useTranslation();
const appStoreApi = useAppStoreApi();
const { toast } = useToast();
return useCallback(
async (next: string) => {
onDone();
if (!sessionId || !taskId) return;
const normalized = next.trim().slice(0, MAX_SESSION_NAME_LENGTH);
if ((currentName ?? "") === normalized) return;
try {
await renameSession(sessionId, normalized);
const existing = appStoreApi.getState().taskSessions.items[sessionId];
if (existing) {
appStoreApi
.getState()
.upsertTaskSessionFromEvent(taskId, { ...existing, name: normalized });
}
} catch (error) {
console.error("rename session:", error);
toast({
title: t("task:renameFailed"),
description: error instanceof Error ? error.message : t("common:unknownError"),
variant: "error",
});
}
},
[sessionId, taskId, currentName, appStoreApi, onDone, toast, t],
);
}
@@ -1,6 +1,7 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { waitForSessionDone } from "../../helpers/session";
import { watchWs } from "../../helpers/causal-waits";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
@@ -16,9 +17,10 @@ const CREATE_PLAN_SCRIPT = [
* Tests the session tabs on the kanban right-side preview panel:
* - Every session of the task shows up as a tab
* - Clicking a tab switches the rendered session body and updates the URL
* - Right-clicking a tab opens the same lifecycle menu as the full-page view
*
* Session creation and deletion are deliberately NOT exposed in the preview
* panel — those live on the full-page task view.
* Session creation is deliberately NOT exposed in the preview panel — that
* still lives on the full-page task view.
*/
test.describe("Preview session tabs", () => {
test("shows all sessions as tabs and switches between them", async ({
@@ -302,6 +304,89 @@ test.describe("Preview auto-prepare", () => {
});
});
/**
* Right-clicking a preview session tab must open the same lifecycle menu as
* the full-page task view (rename, set primary, stop/resume, delete, share,
* handoff) — minus Close Others, which has no meaning in a tab bar that's
* just a session switcher. Mirrors `e2e/tests/session/session-tab-rename.spec.ts`.
*/
test.describe("Preview session tab context menu", () => {
test("opens the lifecycle menu on right-click and renames through it", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Preview Tab Menu Task",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state);
},
{ timeout: 30_000, message: "Waiting for session to finish" },
)
.toBe(true);
const { sessions } = await apiClient.listTaskSessions(task.id);
const sessionId = sessions[0].id;
await apiClient.saveUserSettings({ enable_preview_on_click: true });
const kanban = new KanbanPage(testPage);
const wsWatcher = watchWs(testPage);
await kanban.goto();
const previewCard = kanban.taskCardByTitle("Preview Tab Menu Task");
await expect(previewCard).toBeVisible({ timeout: 10_000 });
await previewCard.click();
const previewPanel = testPage.getByTestId("task-preview-panel");
await expect(previewPanel).toBeVisible({ timeout: 10_000 });
const tab = testPage.getByTestId(`preview-session-tab-${sessionId}`);
await expect(tab).toBeVisible({ timeout: 10_000 });
// Sole session of the task is primary by default — the menu must reflect
// that (Set as Primary disabled) and offer the full lifecycle set, minus
// Close Others.
await tab.click({ button: "right" });
await expect(testPage.getByRole("menuitem", { name: "Rename…" })).toBeVisible();
const setPrimary = testPage.getByRole("menuitem", { name: "Set as Primary" });
await expect(setPrimary).toBeVisible();
await expect(setPrimary).toHaveAttribute("aria-disabled", "true");
await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible();
await expect(testPage.getByRole("menuitem", { name: "Share" })).toBeVisible();
await expect(testPage.getByTestId("session-handoff-submenu")).toBeVisible();
await expect(testPage.getByRole("menuitem", { name: "Close Others" })).toHaveCount(0);
// Drive rename end-to-end: the input opens, and committing it persists
// through the same WS action ("session.rename") the full-page tab uses.
await testPage.getByRole("menuitem", { name: "Rename…" }).click();
const input = testPage.getByTestId("preview-session-tab-rename-input");
await input.click();
await expect(input).toBeFocused();
await input.fill("Renamed Preview Session");
const renamed = wsWatcher.waitForResponse("session.rename");
await input.press("Enter");
await renamed;
await expect(tab).toContainText("Renamed Preview Session");
});
});
/**
* Tests the read-only Plan tab added to the preview panel's tab bar: it
* carries the same unseen-plan indicator as the full-page Plan tab, and