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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user