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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+ return ;
+}
+
+function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: string }) {
+ const { toast } = useToast();
+ const handleSendMessage = useCallback(
+ async (content: string) => {
+ const client = getWebSocketClient();
+ if (!client) return;
+ try {
+ await client.request(
+ "message.add",
+ { task_id: taskId, session_id: session.id, content },
+ 10000,
+ );
+ } catch (error) {
+ console.error("Failed to send message:", error);
+ toast({ title: "Failed to send message", variant: "error" });
+ }
+ },
+ [taskId, session.id, toast],
+ );
+
+ if (session.is_passthrough) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function isSessionActive(state: TaskSessionState): boolean {
+ return state === "RUNNING" || state === "STARTING";
+}
+
+function RunningSpinner() {
+ return ;
+}
+
+function PreviewLoadingState() {
+ return (
+
+ Loading agents…
+
+ );
+}
diff --git a/apps/web/components/task/session-sort.test.ts b/apps/web/components/task/session-sort.test.ts
new file mode 100644
index 0000000000..c6e9399770
--- /dev/null
+++ b/apps/web/components/task/session-sort.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildAgentLabelsById,
+ pickActiveSessionId,
+ resolveAgentLabelFor,
+ sortSessions,
+} from "./session-sort";
+import type { TaskSession, TaskSessionState } from "@/lib/types/http";
+
+const EPOCH = "2025-01-01T00:00:00Z";
+
+function makeSession(overrides: Partial): TaskSession {
+ return {
+ id: "s1",
+ task_id: "t1",
+ environment_id: "e1",
+ state: "CREATED" as TaskSessionState,
+ started_at: EPOCH,
+ updated_at: EPOCH,
+ ...overrides,
+ } as TaskSession;
+}
+
+describe("sortSessions", () => {
+ it("orders running sessions before completed ones", () => {
+ const sessions = [
+ makeSession({ id: "done", state: "COMPLETED", started_at: "2025-01-05T00:00:00Z" }),
+ makeSession({ id: "run", state: "RUNNING", started_at: "2025-01-01T00:00:00Z" }),
+ ];
+ expect(sortSessions(sessions).map((s) => s.id)).toEqual(["run", "done"]);
+ });
+
+ it("breaks ties by most recent started_at", () => {
+ const sessions = [
+ makeSession({ id: "old", state: "RUNNING", started_at: "2025-01-01T00:00:00Z" }),
+ makeSession({ id: "new", state: "RUNNING", started_at: "2025-01-05T00:00:00Z" }),
+ ];
+ expect(sortSessions(sessions).map((s) => s.id)).toEqual(["new", "old"]);
+ });
+});
+
+describe("resolveAgentLabelFor", () => {
+ it("prefers the current store label when the profile still exists", () => {
+ const session = makeSession({
+ agent_profile_id: "p1",
+ agent_profile_snapshot: { label: "Snapshot Agent" },
+ });
+ const labels = buildAgentLabelsById([{ id: "p1", label: "Store Agent" } as never]);
+ expect(resolveAgentLabelFor(session, labels)).toBe("Store Agent");
+ });
+
+ it("falls back to snapshot label when the profile is no longer in the store", () => {
+ const session = makeSession({
+ agent_profile_id: "deleted",
+ agent_profile_snapshot: { label: "Snapshot Agent" },
+ });
+ expect(resolveAgentLabelFor(session, {})).toBe("Snapshot Agent");
+ });
+
+ it("returns 'Unknown agent' when both are missing", () => {
+ const session = makeSession({ agent_profile_id: "missing" });
+ expect(resolveAgentLabelFor(session, {})).toBe("Unknown agent");
+ });
+});
+
+describe("pickActiveSessionId", () => {
+ it("returns null for an empty list", () => {
+ expect(pickActiveSessionId([], "anything")).toBeNull();
+ });
+
+ it("honors a preferred session when it exists", () => {
+ const sessions = [makeSession({ id: "a" }), makeSession({ id: "b", is_primary: true })];
+ expect(pickActiveSessionId(sessions, "a")).toBe("a");
+ });
+
+ it("ignores the preferred session when not present and falls back to primary", () => {
+ const sessions = [makeSession({ id: "a" }), makeSession({ id: "b", is_primary: true })];
+ expect(pickActiveSessionId(sessions, "missing")).toBe("b");
+ });
+
+ it("falls back to the first session when no primary exists", () => {
+ const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })];
+ expect(pickActiveSessionId(sessions, null)).toBe("a");
+ });
+});
diff --git a/apps/web/components/task/session-sort.ts b/apps/web/components/task/session-sort.ts
new file mode 100644
index 0000000000..a5b3e8fb14
--- /dev/null
+++ b/apps/web/components/task/session-sort.ts
@@ -0,0 +1,59 @@
+import type { TaskSession, TaskSessionState } from "@/lib/types/http";
+import type { AgentProfileOption } from "@/lib/state/slices";
+
+const STATUS_ORDER: Record = {
+ RUNNING: 1,
+ STARTING: 1,
+ WAITING_FOR_INPUT: 2,
+ CREATED: 3,
+ COMPLETED: 4,
+ FAILED: 5,
+ CANCELLED: 6,
+};
+
+export function sortSessions(sessions: readonly TaskSession[]): TaskSession[] {
+ return [...sessions].sort((a, b) => {
+ const d = (STATUS_ORDER[a.state] ?? 99) - (STATUS_ORDER[b.state] ?? 99);
+ return d !== 0 ? d : new Date(b.started_at).getTime() - new Date(a.started_at).getTime();
+ });
+}
+
+export function buildAgentLabelsById(
+ agentProfiles: readonly AgentProfileOption[],
+): Record {
+ return Object.fromEntries(agentProfiles.map((p) => [p.id, p.label]));
+}
+
+/**
+ * Resolves the display label for a session's agent.
+ *
+ * Store first so that renaming an agent profile is reflected everywhere that
+ * calls this (matches the long-standing dropdown behavior). Falls back to the
+ * snapshot label only when the profile is no longer in the store — that keeps
+ * tabs/rows for sessions whose profile was deleted from rendering as
+ * "Unknown agent".
+ */
+export function resolveAgentLabelFor(
+ session: TaskSession,
+ agentLabelsById: Record,
+): string {
+ const storeLabel = session.agent_profile_id
+ ? (agentLabelsById[session.agent_profile_id] ?? null)
+ : null;
+ if (storeLabel) return storeLabel;
+ const snapshotLabel = (session.agent_profile_snapshot?.label as string | undefined) ?? null;
+ if (snapshotLabel) return snapshotLabel;
+ return "Unknown agent";
+}
+
+export function pickActiveSessionId(
+ sessions: readonly TaskSession[],
+ preferredSessionId: string | null | undefined,
+): string | null {
+ if (sessions.length === 0) return null;
+ if (preferredSessionId && sessions.some((s) => s.id === preferredSessionId)) {
+ return preferredSessionId;
+ }
+ const primary = sessions.find((s) => s.is_primary);
+ return (primary ?? sessions[0]).id;
+}
diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx
index 4c86b2f342..65c37848e2 100644
--- a/apps/web/components/task/sessions-dropdown.tsx
+++ b/apps/web/components/task/sessions-dropdown.tsx
@@ -23,22 +23,12 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { performLayoutSwitch } from "@/lib/state/dockview-store";
import type { TaskSession, TaskSessionState } from "@/lib/types/http";
-import type { AgentProfileOption } from "@/lib/state/slices";
import { getSessionStateIcon } from "@/lib/ui/state-icons";
import { getWebSocketClient } from "@/lib/ws/connection";
+import { buildAgentLabelsById, resolveAgentLabelFor, sortSessions } from "./session-sort";
type SessionStatus = "running" | "waiting_input" | "complete" | "failed" | "cancelled";
-const STATUS_ORDER: Record = {
- RUNNING: 1,
- STARTING: 1,
- WAITING_FOR_INPUT: 2,
- CREATED: 3,
- COMPLETED: 4,
- FAILED: 5,
- CANCELLED: 6,
-};
-
// Format duration from start time
function formatDuration(startedAt: string, isRunning: boolean, now: number): string {
const start = new Date(startedAt).getTime();
@@ -181,20 +171,10 @@ function useSessionsDropdownState(taskId: string | null) {
const { sessions, loadSessions } = useTaskSessions(taskId);
const currentTime = useRunningSessionsClock(sessions);
- const agentLabelsById = useMemo(
- () => Object.fromEntries(agentProfiles.map((p: AgentProfileOption) => [p.id, p.label])),
- [agentProfiles],
- );
- const sortedSessions = useMemo(() => {
- const visible = taskId ? sessions : [];
- return [...visible].sort((a: TaskSession, b: TaskSession) => {
- const d = (STATUS_ORDER[a.state] ?? 99) - (STATUS_ORDER[b.state] ?? 99);
- return d !== 0 ? d : new Date(b.started_at).getTime() - new Date(a.started_at).getTime();
- });
- }, [sessions, taskId]);
+ const agentLabelsById = useMemo(() => buildAgentLabelsById(agentProfiles), [agentProfiles]);
+ const sortedSessions = useMemo(() => sortSessions(taskId ? sessions : []), [sessions, taskId]);
const resolveAgentLabel = useCallback(
- (s: TaskSession) =>
- (s.agent_profile_id && agentLabelsById[s.agent_profile_id]) || "Unknown agent",
+ (s: TaskSession) => resolveAgentLabelFor(s, agentLabelsById),
[agentLabelsById],
);
return { sortedSessions, currentTime, loadSessions, resolveAgentLabel };
diff --git a/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts b/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts
new file mode 100644
index 0000000000..d0b90084ad
--- /dev/null
+++ b/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts
@@ -0,0 +1,147 @@
+import { test, expect } from "../../fixtures/test-base";
+import { KanbanPage } from "../../pages/kanban-page";
+import { SessionPage } from "../../pages/session-page";
+
+const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
+
+/**
+ * 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
+ *
+ * Session creation and deletion are deliberately NOT exposed in the preview
+ * panel — those live on the full-page task view.
+ */
+test.describe("Preview session tabs", () => {
+ test("shows all sessions as tabs and switches between them", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(180_000);
+
+ // 1. Create a task — first session becomes primary.
+ // Task descriptions use the scenario registry (`/e2e:`), so we pick a
+ // scenario with a unique, agent-only response string to avoid prompt/response
+ // text collisions in `getByText` assertions.
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Preview Tabs Task",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ // 2. Wait for first session to finish.
+ await expect
+ .poll(
+ async () => {
+ const { sessions } = await apiClient.listTaskSessions(task.id);
+ return DONE_STATES.includes(sessions[0]?.state);
+ },
+ { timeout: 30_000, message: "Waiting for first session to finish" },
+ )
+ .toBe(true);
+
+ const { sessions: afterFirst } = await apiClient.listTaskSessions(task.id);
+ const primaryId = afterFirst[0].id;
+
+ // 3. Navigate to the full task view and launch a second session via the new-session dialog.
+ // This mirrors the approach in preview-primary-session.spec.ts since there is no
+ // dedicated API helper to start a second session on an existing task.
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const card = kanban.taskCardByTitle("Preview Tabs Task");
+ await expect(card).toBeVisible({ timeout: 10_000 });
+ await card.click();
+ await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
+ timeout: 15_000,
+ });
+
+ await session.addPanelButton().click();
+ await testPage.getByTestId("new-session-button").click();
+ const dialog = testPage.getByRole("dialog");
+ await expect(dialog).toBeVisible({ timeout: 5_000 });
+ // Dialog prompts use the script command form; the agent echoes the argument.
+ await dialog.locator("textarea").fill('e2e:message("secondary-session-response")');
+ await dialog.getByRole("button").filter({ hasText: /Start/ }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // 4. Wait for the second session to finish (two sessions in a done state).
+ await expect
+ .poll(
+ async () => {
+ const { sessions } = await apiClient.listTaskSessions(task.id);
+ return sessions.filter((s) => DONE_STATES.includes(s.state)).length;
+ },
+ { timeout: 60_000, message: "Waiting for second session to finish" },
+ )
+ .toBe(2);
+
+ const { sessions: afterSecond } = await apiClient.listTaskSessions(task.id);
+ const secondaryId = afterSecond.find((s) => s.id !== primaryId)?.id;
+ if (!secondaryId) throw new Error("Secondary session not created");
+
+ // The first session remains primary by default — creating a second via the
+ // new-session dialog does not steal the primary flag (verified by
+ // preview-primary-session.spec.ts).
+
+ // 5. Enable preview-on-click and return to the kanban board.
+ await apiClient.saveUserSettings({ enable_preview_on_click: true });
+ await kanban.goto();
+
+ const previewCard = kanban.taskCardByTitle("Preview Tabs Task");
+ await expect(previewCard).toBeVisible({ timeout: 10_000 });
+ await expect(previewCard.getByRole("button", { name: "Open full page" })).toBeVisible({
+ timeout: 10_000,
+ });
+ await previewCard.click();
+
+ // 6. Preview panel + both tabs are visible.
+ const previewPanel = testPage.getByTestId("task-preview-panel");
+ await expect(previewPanel).toBeVisible({ timeout: 10_000 });
+
+ const primaryTab = testPage.getByTestId(`preview-session-tab-${primaryId}`);
+ const secondaryTab = testPage.getByTestId(`preview-session-tab-${secondaryId}`);
+ await expect(primaryTab).toBeVisible({ timeout: 10_000 });
+ await expect(secondaryTab).toBeVisible();
+
+ // 7. Primary tab is active by default and its session content is visible.
+ // "simple mock response" appears only in the agent's reply, not in any prompt,
+ // so the single getByText match is unambiguous.
+ await expect(primaryTab).toHaveAttribute("data-state", "active");
+ await expect(secondaryTab).toHaveAttribute("data-state", "inactive");
+ await expect(previewPanel.getByText("simple mock response", { exact: false })).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // 8. Click the secondary tab → content switches, URL updates.
+ // The echoed marker "secondary-session-response" appears in both the user
+ // prompt and the agent reply; `.first()` picks one deterministically and
+ // is enough to prove the secondary session's body is rendered.
+ await secondaryTab.click();
+ await expect(secondaryTab).toHaveAttribute("data-state", "active");
+ await expect(primaryTab).toHaveAttribute("data-state", "inactive");
+ await expect(
+ previewPanel.getByText("secondary-session-response", { exact: false }).first(),
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(
+ previewPanel.getByText("simple mock response", { exact: false }),
+ ).not.toBeVisible();
+ await expect(testPage).toHaveURL(new RegExp(`sessionId=${secondaryId}`), { timeout: 5_000 });
+
+ // 9. Read-only tab bar: no close buttons and no add button are rendered.
+ await expect(testPage.getByTestId(`preview-session-tab-close-${primaryId}`)).toHaveCount(0);
+ await expect(testPage.getByTestId(`preview-session-tab-close-${secondaryId}`)).toHaveCount(0);
+ await expect(previewPanel.getByRole("button", { name: "+" })).toHaveCount(0);
+ });
+});