diff --git a/apps/web/components/kanban-display-dropdown.tsx b/apps/web/components/kanban-display-dropdown.tsx index 5ff7bf797f..84c25fd6a8 100644 --- a/apps/web/components/kanban-display-dropdown.tsx +++ b/apps/web/components/kanban-display-dropdown.tsx @@ -14,7 +14,6 @@ import { IconAdjustmentsHorizontal } from "@tabler/icons-react"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import type { Workspace, Repository } from "@/lib/types/http"; import type { WorkflowsState } from "@/lib/state/slices"; -import { Badge } from "@kandev/ui/badge"; function getRepositoryPlaceholder( repositoriesLoading: boolean, @@ -186,12 +185,7 @@ export function KanbanDisplayDropdown() { onTogglePreviewOnClick?.(!!checked); }} /> - - Open preview on click{" "} - - beta - - + Open preview on click

When enabled, clicking a task opens the preview panel. When disabled, clicking diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index c835c1d02b..1681d7c9c7 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -1,6 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; import { useRouter } from "next/navigation"; import { KanbanBoard } from "./kanban-board"; import { TaskPreviewPanel } from "./task-preview-panel"; @@ -35,6 +43,10 @@ function useUrlSync(selectedTaskId: string | null, selectedTaskSessionId: string if (selectedTaskId && selectedTaskSessionId) { url.searchParams.set("sessionId", selectedTaskSessionId); + } else if (selectedTaskId) { + // Task still open but no active session (e.g. all sessions deleted) — + // drop any stale sessionId from the URL. + url.searchParams.delete("sessionId"); } window.history.replaceState({}, "", url.toString()); @@ -86,7 +98,49 @@ function useResizeHandler( ); } -export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { +// Holds the user's explicit session pick for the selected task, resetting to null +// whenever the task changes. Uses React's "store previous props" pattern to avoid +// a setState-in-effect cascade (https://react.dev/reference/react/useState#storing-information-from-previous-renders). +function useSessionSelectionReset( + selectedTaskId: string | null | undefined, + initialValue: string | null = null, +): [string | null, Dispatch>] { + const [value, setValue] = useState(initialValue); + const [prevTaskId, setPrevTaskId] = useState(selectedTaskId ?? null); + const currentTaskId = selectedTaskId ?? null; + if (prevTaskId !== currentTaskId) { + setPrevTaskId(currentTaskId); + // Only reset on a real task-to-task transition. When prevTaskId is null we + // are hydrating the initial task from the URL — preserve the seeded initialValue. + if (prevTaskId !== null) { + setValue(null); + } + } + return [value, setValue]; +} + +function useSelectedTask( + selectedTaskId: string | null | undefined, + kanbanTasks: KanbanState["tasks"], +) { + return useMemo(() => { + if (!selectedTaskId || kanbanTasks.length === 0) return null; + const task = kanbanTasks.find((t: KanbanState["tasks"][number]) => t.id === selectedTaskId); + if (!task) return null; + return { + id: task.id, + title: task.title, + workflowStepId: task.workflowStepId, + state: task.state, + description: task.description, + position: task.position, + repositoryId: task.repositoryId, + primarySessionId: task.primarySessionId, + }; + }, [selectedTaskId, kanbanTasks]); +} + +export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWithPreviewProps) { const router = useRouter(); const { isMobile } = useResponsiveBreakpoint(); @@ -105,27 +159,17 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { const { containerRef, shouldFloat, kanbanWidth } = useKanbanLayout(isOpen, previewWidthPx); const { sessionId: selectedTaskSessionId, isLoading } = useTaskSession(selectedTaskId ?? null); + // User-selected tab overrides the default primary session pick. + // Reset when the selected task changes. + const [userSelectedSessionId, setUserSelectedSessionId] = useSessionSelectionReset( + selectedTaskId, + initialSessionId ?? null, + ); + // Track resize state const isResizingRef = useRef(false); - // Compute selected task from kanbanTasks and selectedTaskId - const selectedTask = useMemo(() => { - if (!selectedTaskId || kanbanTasks.length === 0) return null; - - const task = kanbanTasks.find((t: KanbanState["tasks"][number]) => t.id === selectedTaskId); - if (!task) return null; - - return { - id: task.id, - title: task.title, - workflowStepId: task.workflowStepId, - state: task.state, - description: task.description, - position: task.position, - repositoryId: task.repositoryId, - primarySessionId: task.primarySessionId, - }; - }, [selectedTaskId, kanbanTasks]); + const selectedTask = useSelectedTask(selectedTaskId, kanbanTasks); // Close panel if selected task no longer exists useEffect(() => { @@ -154,7 +198,11 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { [router], ); - useUrlSync(selectedTaskId ?? null, selectedTaskSessionId ?? null); + const activeSessionId = selectedTaskId + ? (userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId) + : null; + + useUrlSync(selectedTaskId ?? null, activeSessionId ?? null); const handlePreviewTaskWithData = useCallback( (task: Task) => { @@ -171,10 +219,6 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { const handleResizeMouseDown = useResizeHandler(isResizingRef, previewWidthPx, updatePreviewWidth); - const activeSessionId = selectedTaskId - ? (selectedTask?.primarySessionId ?? selectedTaskSessionId) - : null; - // On mobile, skip the preview panel entirely — card clicks navigate directly if (isMobile) { return ( @@ -195,6 +239,7 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { onPreviewTask={handlePreviewTaskWithData} onNavigateToTask={handleNavigateToTask} onClose={close} + onSessionChange={setUserSelectedSessionId} onResizeMouseDown={handleResizeMouseDown} /> ) : ( @@ -207,6 +252,7 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { onPreviewTask={handlePreviewTaskWithData} onNavigateToTask={handleNavigateToTask} onClose={close} + onSessionChange={setUserSelectedSessionId} onResizeMouseDown={handleResizeMouseDown} /> )} @@ -234,6 +280,7 @@ type PreviewLayoutProps = { onPreviewTask: (task: Task) => void; onNavigateToTask: (task: Task) => void; onClose: () => void; + onSessionChange: (sessionId: string | null) => void; onResizeMouseDown: (e: React.MouseEvent) => void; }; @@ -245,6 +292,7 @@ function FloatingPreviewLayout({ onPreviewTask, onNavigateToTask, onClose, + onSessionChange, onResizeMouseDown, }: PreviewLayoutProps) { return ( @@ -271,6 +319,7 @@ function FloatingPreviewLayout({ sessionId={activeSessionId} onClose={onClose} onMaximize={(task) => onNavigateToTask(task)} + onSessionChange={onSessionChange} /> @@ -287,6 +336,7 @@ function InlinePreviewLayout({ onPreviewTask, onNavigateToTask, onClose, + onSessionChange, onResizeMouseDown, }: PreviewLayoutProps & { isOpen: boolean }) { return ( @@ -306,6 +356,7 @@ function InlinePreviewLayout({ sessionId={activeSessionId} onClose={onClose} onMaximize={(task) => onNavigateToTask(task)} + onSessionChange={onSessionChange} /> diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index 889329ee7d..702e349437 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; -import { Badge } from "@kandev/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { IconSettings, IconList, IconLayoutKanban, IconChartBar } from "@tabler/icons-react"; @@ -139,12 +138,7 @@ function MobileDisplayOptions(props: MobileDisplayOptionsProps) { onTogglePreviewOnClick?.(!!checked); }} /> - - Open preview on click{" "} - - beta - - + Open preview on click diff --git a/apps/web/components/session-tabs.tsx b/apps/web/components/session-tabs.tsx index 264d79537f..50ad4c7802 100644 --- a/apps/web/components/session-tabs.tsx +++ b/apps/web/components/session-tabs.tsx @@ -12,6 +12,8 @@ export type SessionTab = { alwaysShowClose?: boolean; onClose?: (event: MouseEvent) => void; className?: string; + testId?: string; + closeTestId?: string; }; type SessionTabsProps = { @@ -24,6 +26,8 @@ type SessionTabsProps = { addButtonLabel?: string; separatorAfterIndex?: number; className?: string; + /** Overrides the default `TabsList` className — use to drop the pill background, etc. */ + listClassName?: string; // Collapse support collapsible?: boolean; isCollapsed?: boolean; @@ -75,6 +79,7 @@ function SessionTabItem({ )} {tab.icon} @@ -85,6 +90,7 @@ function SessionTabItem({ @@ -149,13 +155,16 @@ export function SessionTabs({ addButtonLabel = "+", separatorAfterIndex, className, + listClassName, collapsible = false, isCollapsed = false, onToggleCollapse, rightContent, }: SessionTabsProps) { + const defaultListClassName = + "p-0 !h-7 rounded-sm overflow-x-auto overflow-y-hidden min-w-0 shrink [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"; const tabsList = ( - + {tabs.map((tab, index) => ( void; onMaximize?: (task: Task) => void; + onSessionChange?: (sessionId: string | null) => void; } export function TaskPreviewPanel({ @@ -19,32 +18,8 @@ export function TaskPreviewPanel({ sessionId = null, onClose, onMaximize, + onSessionChange, }: TaskPreviewPanelProps) { - const { taskSessionId } = useTaskChatSession(task?.id ?? null); - const activeSessionId = sessionId ?? taskSessionId; - - const handleSendMessage = async (content: string) => { - if (!task?.id) return; - - const client = getWebSocketClient(); - if (!client) return; - - if (!activeSessionId) { - console.error("No active task session. Start an agent before sending a message."); - return; - } - - try { - await client.request( - "message.add", - { task_id: task.id, session_id: activeSessionId, content }, - 10000, - ); - } catch (error) { - console.error("Failed to send message:", error); - } - }; - return (

{/* Content */} -
+
{task ? ( - + ) : (
Select a task to start chatting diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 47e1f875d3..037d2d48fc 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -181,101 +181,151 @@ export function useAutoPRPanel() { }, [taskId, hasPR, hasApi, sessionId]); } -/** Remove panels for terminal sessions that are no longer active. */ -function cleanupTerminalSessionPanels( - api: DockviewReadyEvent["api"], +function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] { + const { centerGroupId } = useDockviewStore.getState(); + const centerGroupExists = centerGroupId && api.groups.some((g) => g.id === centerGroupId); + if (centerGroupExists) return { referenceGroup: centerGroupId }; + const sb = api.getPanel("sidebar"); + if (sb) return { direction: "right" as const, referencePanel: "sidebar" }; + return undefined; +} + +function ensureSessionPanel( + api: DockviewApi, + sessionId: string, + position: AddPanelOptions["position"], + inactive: boolean, createdSet: Set, - activeSessionId: string, - sessions: Record, ): void { - for (const panelId of [...createdSet]) { - if (panelId === activeSessionId) continue; - const sess = sessions[panelId]; - if (!sess) continue; - if (sess.state === "COMPLETED" || sess.state === "CANCELLED" || sess.state === "FAILED") { - const stalePanel = api.getPanel(`session:${panelId}`); - if (stalePanel) { - try { - stalePanel.api.close(); - } catch { - /* already gone */ - } + if (api.getPanel(`session:${sessionId}`)) { + createdSet.add(sessionId); + return; + } + api.addPanel({ + id: `session:${sessionId}`, + component: "chat", + tabComponent: "sessionTab", + title: "Agent", + params: { sessionId }, + position, + inactive, + }); + createdSet.add(sessionId); +} + +/** Close panels we previously created for sessions no longer in the task's list. */ +function reconcileRemovedSessionPanels( + api: DockviewApi, + createdSet: Set, + currentSessionIds: string[], + keepSessionId: string, +): void { + const currentIds = new Set(currentSessionIds); + for (const createdId of [...createdSet]) { + if (createdId === keepSessionId) continue; + if (currentIds.has(createdId)) continue; + const stalePanel = api.getPanel(`session:${createdId}`); + if (stalePanel) { + try { + stalePanel.api.close(); + } catch { + /* already gone */ } - createdSet.delete(panelId); } + createdSet.delete(createdId); } } +const EMPTY_SESSION_IDS_KEY = ""; + /** - * Auto-create a session tab when a session becomes active. - * Replaces the generic "chat" panel with a per-session tab on first use. + * Open a dockview tab for every session of the active task and keep them in sync + * with the store. + * + * - On mount / session-list change: create a panel for each session if one does + * not exist yet. Siblings are added adjacent to the active session's group so + * they show up as tabs in the center area. + * - The panel for `effectiveSessionId` is the active tab; the rest are added + * inactive so switching the active session doesn't blow focus out of the + * already-open layout. + * - Deleted sessions have their panels closed. */ export function useAutoSessionTab(effectiveSessionId: string | null) { const sessionTabCreatedRef = useRef>(new Set()); const appStore = useAppStoreApi(); + + // Key-based dependency so the effect re-runs when the task's session list + // changes (add/remove). Inside the effect we re-read the real array from + // the store so we don't capture a stale reference. + const sessionIdsKey = useAppStore((s) => { + const tid = s.tasks.activeTaskId; + if (!tid) return EMPTY_SESSION_IDS_KEY; + const list = s.taskSessionsByTask.itemsByTaskId[tid]; + if (!list || list.length === 0) return EMPTY_SESSION_IDS_KEY; + return list.map((ss) => ss.id).join(","); + }); + useEffect(() => { - if (!effectiveSessionId) return; const api = useDockviewStore.getState().api; if (!api) return; - // Always remove the generic "chat" panel when a session is active — - // it's replaced by per-session tabs. Must run before the early return - // so restored layouts with both "chat" and session panels get cleaned up. - // Skip removal in maximized state to avoid triggering the safety net - // which could disrupt the saved maximize layout. + // Re-read the current session list straight from the store so we iterate + // the live array (sessionIdsKey change is what gets us here). + const tid = appStore.getState().tasks.activeTaskId; + const currentSessions = tid + ? (appStore.getState().taskSessionsByTask.itemsByTaskId[tid] ?? []) + : []; + const currentSessionIds = currentSessions.map((s) => s.id); + + // Always reconcile removed panels — even when effectiveSessionId is null + // (e.g. all sessions deleted) so orphaned tabs are closed. + reconcileRemovedSessionPanels( + api, + sessionTabCreatedRef.current, + currentSessionIds, + effectiveSessionId ?? "", + ); + + if (!effectiveSessionId) return; + + // Remove the generic "chat" placeholder as soon as a real session is + // active — per-session tabs replace it. Skip in maximized state to + // preserve the saved maximize layout. const chatPanel = api.getPanel("chat"); if (chatPanel && !useDockviewStore.getState().preMaximizeLayout) { api.removePanel(chatPanel); } - if (api.getPanel(`session:${effectiveSessionId}`)) { - sessionTabCreatedRef.current.add(effectiveSessionId); - // Activate the existing panel so it comes to focus - const existingPanel = api.getPanel(`session:${effectiveSessionId}`); - if (existingPanel) existingPanel.api.setActive(); - return; - } - // In maximized state the session panel is intentionally absent from the layout; - // it will be restored when the user exits maximize (via preMaximizeLayout). - // Adding it here would destroy the saved maximize layout. + + // In maximized state, session panels are intentionally absent from the + // layout — they'll be restored when the user exits maximize. if (useDockviewStore.getState().preMaximizeLayout !== null) { sessionTabCreatedRef.current.add(effectiveSessionId); return; } - // Resolve position: prefer centerGroupId if the group still exists, - // fall back to placing right of sidebar, or omit position entirely. - const { centerGroupId } = useDockviewStore.getState(); - const centerGroupExists = centerGroupId && api.groups.some((g) => g.id === centerGroupId); - let position: AddPanelOptions["position"]; - if (centerGroupExists) { - position = { referenceGroup: centerGroupId }; - } else { - const sb = api.getPanel("sidebar"); - if (sb) { - position = { direction: "right" as const, referencePanel: "sidebar" }; - } - } - api.addPanel({ - id: `session:${effectiveSessionId}`, - component: "chat", - tabComponent: "sessionTab", - title: "Agent", - params: { sessionId: effectiveSessionId }, - position, - }); - const panel = api.getPanel(`session:${effectiveSessionId}`); - if (panel) { - panel.api.setActive(); - useDockviewStore.setState({ centerGroupId: panel.group.id }); - } - sessionTabCreatedRef.current.add(effectiveSessionId); - // Clean up panels for completed intermediate sessions (e.g., sessions - // created during on_turn_start profile switch that were immediately completed). - cleanupTerminalSessionPanels( + const initialPosition = resolveInitialPosition(api); + + // Active panel first so its group becomes the anchor for siblings. + ensureSessionPanel( api, - sessionTabCreatedRef.current, effectiveSessionId, - appStore.getState().taskSessions.items, + initialPosition, + false, + sessionTabCreatedRef.current, ); - }, [effectiveSessionId, appStore]); + const activePanel = api.getPanel(`session:${effectiveSessionId}`); + if (activePanel) { + activePanel.api.setActive(); + useDockviewStore.setState({ centerGroupId: activePanel.group.id }); + } + + const siblingAnchor: AddPanelOptions["position"] = activePanel + ? { referenceGroup: activePanel.group.id } + : initialPosition; + + for (const sid of currentSessionIds) { + if (sid === effectiveSessionId) continue; + ensureSessionPanel(api, sid, siblingAnchor, true, sessionTabCreatedRef.current); + } + }, [effectiveSessionId, sessionIdsKey, appStore]); } diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx new file mode 100644 index 0000000000..0e70cb2ca5 --- /dev/null +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { IconLoader2 } from "@tabler/icons-react"; +import { AgentLogo } from "@/components/agent-logo"; +import { SessionTabs, type SessionTab } from "@/components/session-tabs"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption"; +import { useTaskSessions } from "@/hooks/use-task-sessions"; +import type { AgentProfileOption } from "@/lib/state/slices"; +import type { TaskSession, TaskSessionState } from "@/lib/types/http"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { PassthroughTerminal } from "./passthrough-terminal"; +import { TaskChatPanel } from "./task-chat-panel"; +import { + buildAgentLabelsById, + pickActiveSessionId, + resolveAgentLabelFor, + sortSessions, +} from "./session-sort"; + +const LABEL_SEPARATOR = " \u2022 "; + +type PreviewSessionTabsProps = { + taskId: string; + sessionId: string | null; + onSessionChange?: (sessionId: string | null) => void; +}; + +/** + * Read-only 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. + */ +export function PreviewSessionTabs({ + taskId, + sessionId, + onSessionChange, +}: PreviewSessionTabsProps) { + const { sessions, isLoaded } = useTaskSessions(taskId); + const agentProfiles = useAppStore((state) => state.agentProfiles.items); + + const sortedSessions = useMemo(() => sortSessions(sessions), [sessions]); + const agentLabelsById = useMemo(() => buildAgentLabelsById(agentProfiles), [agentProfiles]); + const profilesById = useMemo( + () => Object.fromEntries(agentProfiles.map((p) => [p.id, p])), + [agentProfiles], + ); + + const activeSessionId = useMemo( + () => pickActiveSessionId(sortedSessions, sessionId), + [sortedSessions, sessionId], + ); + const activeSession = useMemo( + () => sortedSessions.find((s) => s.id === activeSessionId) ?? null, + [sortedSessions, activeSessionId], + ); + + // Mirrors the full-page task view: ensure the backend execution for the + // active session is ready (resumes / restores workspace after a kandev + // restart where the session row is persisted but agentctl isn't alive). + useSessionResumption(taskId, activeSessionId); + + 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: "bg-muted/50 data-[state=active]:bg-muted", + }; + }), + [sortedSessions, agentLabelsById, profilesById], + ); + + if (!isLoaded && sortedSessions.length === 0) { + return ; + } + + if (sortedSessions.length === 0) { + return ( +
+
+ No agents yet. +
+
+ ); + } + + return ( +
+
+ onSessionChange?.(id)} + listClassName="bg-transparent p-0 !h-7 gap-1 overflow-x-auto overflow-y-hidden min-w-0 shrink [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]" + /> +
+
+ {activeSession && ( + + )} +
+
+ ); +} + +function resolveProfileSubLabel( + session: TaskSession, + profile: AgentProfileOption | null | undefined, + agentLabelsById: Record, +): string { + const fullLabel = profile?.label ?? resolveAgentLabelFor(session, agentLabelsById); + const parts = fullLabel.split(LABEL_SEPARATOR); + return parts[1] ?? parts[0] ?? fullLabel; +} + +function SessionAgentLogo({ profile }: { profile: AgentProfileOption | null | undefined }) { + if (!profile?.agent_name) { + // Keep tabs visually aligned when the agent profile is missing/unknown. + return ( +