feat(web): session tabs on kanban preview panel (#648)

This commit is contained in:
Carlos Florêncio
2026-04-20 22:31:09 +01:00
committed by GitHub
parent eb8583e0e1
commit 6909fe6854
11 changed files with 698 additions and 163 deletions
@@ -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);
}}
/>
<span className="text-sm text-foreground">
Open preview on click{" "}
<Badge variant="secondary" className="mr-1">
beta
</Badge>
</span>
<span className="text-sm text-foreground">Open preview on click</span>
</label>
<p className="text-xs text-muted-foreground pl-6">
When enabled, clicking a task opens the preview panel. When disabled, clicking
+76 -25
View File
@@ -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<SetStateAction<string | null>>] {
const [value, setValue] = useState<string | null>(initialValue);
const [prevTaskId, setPrevTaskId] = useState<string | null>(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}
/>
</div>
</div>
@@ -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}
/>
</div>
</div>
@@ -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);
}}
/>
<span className="text-sm">
Open preview on click{" "}
<Badge variant="secondary" className="ml-1">
beta
</Badge>
</span>
<span className="text-sm">Open preview on click</span>
</label>
</div>
</div>
+10 -1
View File
@@ -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({
)}
<TabsTrigger
value={tab.id}
data-testid={tab.testId}
className={tab.className + " group relative py-1 cursor-pointer rounded-sm max-w-[120px]"}
>
{tab.icon}
@@ -85,6 +90,7 @@ function SessionTabItem({
<span
role="button"
tabIndex={-1}
data-testid={tab.closeTestId}
className={`absolute right-1 rounded bg-background hover:bg-muted hover:text-foreground text-muted-foreground transition-opacity ${tab.alwaysShowClose ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
onClick={tab.onClose}
>
@@ -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 = (
<TabsList className="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]">
<TabsList className={listClassName ?? defaultListClassName}>
{tabs.map((tab, index) => (
<SessionTabItem
key={tab.id}
+9 -30
View File
@@ -3,15 +3,14 @@
import { IconArrowsMaximize, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import type { Task } from "./kanban-card";
import { TaskChatPanel } from "./task/task-chat-panel";
import { useTaskChatSession } from "@/hooks/use-task-chat-session";
import { getWebSocketClient } from "@/lib/ws/connection";
import { PreviewSessionTabs } from "./task/preview-session-tabs";
interface TaskPreviewPanelProps {
task: Task | null;
sessionId?: string | null;
onClose: () => 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 (
<div
data-testid="task-preview-panel"
@@ -74,9 +49,13 @@ export function TaskPreviewPanel({
</div>
{/* Content */}
<div className="flex-1 min-h-0 p-4 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
{task ? (
<TaskChatPanel onSend={handleSendMessage} sessionId={activeSessionId} />
<PreviewSessionTabs
taskId={task.id}
sessionId={sessionId}
onSessionChange={onSessionChange}
/>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a task to start chatting
+119 -69
View File
@@ -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<string>,
activeSessionId: string,
sessions: Record<string, { state?: string }>,
): 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<string>,
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<Set<string>>(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]);
}
@@ -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<SessionTab[]>(
() =>
sortedSessions.map((session) => {
const profile = session.agent_profile_id ? profilesById[session.agent_profile_id] : null;
return {
id: session.id,
label: resolveProfileSubLabel(session, profile, agentLabelsById),
icon: isSessionActive(session.state) ? (
<RunningSpinner />
) : (
<SessionAgentLogo profile={profile} />
),
testId: `preview-session-tab-${session.id}`,
className: "bg-muted/50 data-[state=active]:bg-muted",
};
}),
[sortedSessions, agentLabelsById, profilesById],
);
if (!isLoaded && sortedSessions.length === 0) {
return <PreviewLoadingState />;
}
if (sortedSessions.length === 0) {
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
No agents yet.
</div>
</div>
);
}
return (
<div className="flex h-full flex-col min-h-0" data-testid="preview-session-tabs">
<div className="border-b px-2 py-1">
<SessionTabs
tabs={tabs}
activeTab={activeSessionId ?? ""}
onTabChange={(id) => 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]"
/>
</div>
<div className="flex-1 min-h-0">
{activeSession && (
<PreviewSessionBody key={activeSession.id} session={activeSession} taskId={taskId} />
)}
</div>
</div>
);
}
function resolveProfileSubLabel(
session: TaskSession,
profile: AgentProfileOption | null | undefined,
agentLabelsById: Record<string, string>,
): 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 (
<span aria-hidden="true" className="h-3 w-3 shrink-0 rounded-full bg-muted-foreground/40" />
);
}
return <AgentLogo agentName={profile.agent_name} size={12} className="shrink-0" />;
}
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 (
<div className="h-full bg-card">
<PassthroughTerminal sessionId={session.id} mode="agent" />
</div>
);
}
return (
<div className="flex h-full flex-col">
<TaskChatPanel onSend={handleSendMessage} sessionId={session.id} hideSessionsDropdown />
</div>
);
}
function isSessionActive(state: TaskSessionState): boolean {
return state === "RUNNING" || state === "STARTING";
}
function RunningSpinner() {
return <IconLoader2 className="h-3 w-3 shrink-0 text-blue-500 animate-spin" />;
}
function PreviewLoadingState() {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading agents…
</div>
);
}
@@ -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>): 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");
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { TaskSession, TaskSessionState } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
const STATUS_ORDER: Record<TaskSessionState, number> = {
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<string, string> {
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, string>,
): 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;
}
+4 -24
View File
@@ -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<TaskSessionState, number> = {
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 };
@@ -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:<name>`), 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);
});
});