From 32ccd3d31eee042968c50fb2e7bef78e9cd2867b Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 4 Sep 2026 00:47:45 +0000 Subject: [PATCH] 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 Co-authored-by: Carlos Florencio --- apps/web/components/session-tabs.test.tsx | 35 +- apps/web/components/session-tabs.tsx | 9 +- .../task/preview-session-tab-menu.tsx | 56 +++ .../task/preview-session-tabs.test.tsx | 436 +++++++++++++++++- .../components/task/preview-session-tabs.tsx | 284 +++++++++++- .../components/task/session-tab-menu.test.tsx | 37 ++ apps/web/components/task/session-tab-menu.tsx | 19 +- apps/web/components/task/session-tab.tsx | 47 +- .../web/components/task/use-session-rename.ts | 48 ++ .../tests/kanban/preview-session-tabs.spec.ts | 89 +++- 10 files changed, 973 insertions(+), 87 deletions(-) create mode 100644 apps/web/components/task/preview-session-tab-menu.tsx create mode 100644 apps/web/components/task/use-session-rename.ts diff --git a/apps/web/components/session-tabs.test.tsx b/apps/web/components/session-tabs.test.tsx index 5465931404..41c20afb70 100644 --- a/apps/web/components/session-tabs.test.tsx +++ b/apps/web/components/session-tabs.test.tsx @@ -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(); + + expect(screen.getByTestId("session-tab-1").getAttribute("data-state")).toBe("active"); + expect(screen.getByTestId("session-tab-2").getAttribute("data-state")).toBe("inactive"); + }); }); diff --git a/apps/web/components/session-tabs.tsx b/apps/web/components/session-tabs.tsx index 65e93f397c..b98f1554ac 100644 --- a/apps/web/components/session-tabs.tsx +++ b/apps/web/components/session-tabs.tsx @@ -166,7 +166,14 @@ function SessionTabItem({ )} {tab.renderContextMenu ? ( - {tabTarget} + {/* `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. */} + +
{tabTarget}
+
{tab.renderContextMenu(closeAnchorRef)}
) : ( diff --git a/apps/web/components/task/preview-session-tab-menu.tsx b/apps/web/components/task/preview-session-tab-menu.tsx new file mode 100644 index 0000000000..e5fea16cb2 --- /dev/null +++ b/apps/web/components/task/preview-session-tab-menu.tsx @@ -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) => 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 ( + onRequestDelete(session.id, event, () => remove({ feedback: "toast" }))} + onShare={() => onShareRequested(session.id)} + onHandoffProfile={(profileId) => onHandoff(session.id, profileId)} + onStartRename={() => onRename(session.id)} + /> + ); +} diff --git a/apps/web/components/task/preview-session-tabs.test.tsx b/apps/web/components/task/preview-session-tabs.test.tsx index df466dcce8..6aedbe9787 100644 --- a/apps/web/components/task/preview-session-tabs.test.tsx +++ b/apps/web/components/task/preview-session-tabs.test.tsx @@ -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), + 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, useTaskSessions: vi.fn(), useSessionResumption: vi.fn(), markTaskPlanSeen: vi.fn(), @@ -49,6 +67,83 @@ vi.mock("@/components/editors/tiptap/tiptap-plan-readonly", () => ({
{props.content}
), })); +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 }) => ( + + ), +})); +vi.mock("@/components/task/handoff-profile-menu-items", () => ({ + HandoffContextMenuSub: () => ( +
+ Handoff +
+ ), +})); +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 ( + { + value = e.target.value; + }} + onKeyDown={(e) => { + if (e.key === "Enter") onCommit(value); + }} + /> + ); + }, +})); type TaskPlansState = { byTaskId: Record; @@ -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) { fakeStore.setState((state) => ({ taskPlans: { ...state.taskPlans, ...patch } })); } +type TestAppState = FakeAppState & { + kanban: { + tasks: Array<{ id: string; primarySessionId: string | null }>; + }; + kanbanMulti: { + snapshots: Record }>; + }; +}; + +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 { + 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(); + + 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(); + + 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(); + + 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(); + + mocks.kanbanSnapshots = { + "workflow-2": { tasks: [{ id: TASK_ID, primarySessionId: "session-b" }] }, + }; + rerender(); + 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(); + + 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(); + + 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(); + + 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((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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + 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( + , + ); + + 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( + , + ); + await pendingDelete.settle(true); + + expect(onSessionChange).toHaveBeenCalledWith("session-a"); + }); +}); + function agentPlan(overrides: Partial = {}): TaskPlan { return { id: "plan-1", diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index 6aa299b386..9e848984fb 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -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 }; + +/** Holds the rename/delete/share/handoff UI state shared by every preview tab. */ +function usePreviewSessionTabDialogs(taskId: string, sortedSessions: TaskSession[]) { + const [renamingSessionId, setRenamingSessionId] = useState(null); + const [menuDeleteState, setMenuDeleteState] = useState(null); + const menuDeleteAnchorRef = useRef(null); + const menuDeleteFocusBoundaryRef = useRef(null); + const [shareSessionId, setShareSessionId] = useState(null); + const [handoffOpen, setHandoffOpen] = useState(false); + const [handoffPreset, setHandoffPreset] = useState(null); + + const handleRequestDelete = useCallback( + (targetSessionId: string, event: Event, confirmDelete: () => Promise) => { + 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; + +/** 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; + agentLabelsById: Record; + 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) ? ( + + ) : ( + + ), + testId: `preview-session-tab-${session.id}`, + className: PREVIEW_TAB_CLASS_NAME, + renderContextMenu: () => ( + + ), + content: + dialogs.renamingSessionId === session.id ? ( + 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; + agentLabelsById: Record; + 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 ( + { + 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( - () => [ - ...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) ? ( - - ) : ( - - ), - 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(() => [...sessionTabs, planTab], [sessionTabs, planTab]); if (!isLoaded && sortedSessions.length === 0) { return ; @@ -146,6 +374,14 @@ export function PreviewSessionTabs({ taskId={taskId} /> + ); } diff --git a/apps/web/components/task/session-tab-menu.test.tsx b/apps/web/components/task/session-tab-menu.test.tsx index 1d1690adee..5a2d5ea7b8 100644 --- a/apps/web/components/task/session-tab-menu.test.tsx +++ b/apps/web/components/task/session-tab-menu.test.tsx @@ -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( + + + + + + , + ); + + 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 }) { diff --git a/apps/web/components/task/session-tab-menu.tsx b/apps/web/components/task/session-tab-menu.tsx index a539b87160..4ed46af3ac 100644 --- a/apps/web/components/task/session-tab-menu.tsx +++ b/apps/web/components/task/session-tab-menu.tsx @@ -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({ )} - - - {t("task:closeOthers")} - + {actions.handleCloseOthers && ( + <> + + + {t("task:closeOthers")} + + + )} ); } diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 14b4e03044..a15eec351c 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -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, diff --git a/apps/web/components/task/use-session-rename.ts b/apps/web/components/task/use-session-rename.ts new file mode 100644 index 0000000000..635c4409ed --- /dev/null +++ b/apps/web/components/task/use-session-rename.ts @@ -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], + ); +} diff --git a/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts b/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts index fdc313754a..1f8769f4d6 100644 --- a/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts +++ b/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts @@ -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