feat(web): session tabs on kanban preview panel (#648)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user