Files
kandev/apps/web/components/task/preview-session-tabs.tsx
T

223 lines
7.2 KiB
TypeScript

"use client";
import { useCallback, useMemo } from "react";
import { IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
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 { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session";
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;
ensureSession?: UseEnsureTaskSessionResult;
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,
ensureSession,
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 label="Loading agents…" />;
}
if (sortedSessions.length === 0) {
if (ensureSession?.status === "preparing") {
return <PreviewLoadingState label="Preparing workspace…" />;
}
if (ensureSession?.status === "error") {
return <PreviewEnsureError onRetry={ensureSession.retry} />;
}
return <PreviewEmptyState />;
}
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({ label }: { label: string }) {
return (
<div
className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"
data-testid="preview-loading-state"
>
<IconLoader2 className="h-4 w-4 animate-spin" />
{label}
</div>
);
}
function PreviewEmptyState() {
return (
<div className="flex h-full flex-col">
<div
className="flex flex-1 items-center justify-center text-sm text-muted-foreground"
data-testid="preview-empty-state"
>
No agents yet.
</div>
</div>
);
}
function PreviewEnsureError({ onRetry }: { onRetry: () => void }) {
return (
<div
className="flex h-full flex-col items-center justify-center gap-3 text-sm"
data-testid="preview-ensure-error"
>
<span className="text-muted-foreground">Failed to prepare workspace.</span>
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onRetry}>
Retry
</Button>
</div>
);
}