8aab100d5e
In the kanban Preview tab, render a small kandev-controlled composer below the PassthroughTerminal when the session is is_passthrough. Submitting (Enter or click) posts message.add over WS — the backend's Executor.Prompt routes passthrough sessions to PTY stdin so the CLI agent actually receives it. Shift+Enter inserts a newline; empty/whitespace input is disabled. This closes #989 together with the previous backend commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
294 lines
9.4 KiB
TypeScript
294 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
|
|
import { IconLoader2, IconSend } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Textarea } from "@kandev/ui/textarea";
|
|
import { AgentLogo } from "@/components/agent-logo";
|
|
import { GridSpinner } from "@/components/grid-spinner";
|
|
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 } from "@/lib/types/http";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { PassthroughTerminal } from "./passthrough-terminal";
|
|
import { TaskChatPanel } from "./task-chat-panel";
|
|
import {
|
|
buildAgentLabelsById,
|
|
isSessionActive,
|
|
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="flex h-full flex-col bg-card">
|
|
<div className="flex-1 min-h-0">
|
|
<PassthroughTerminal sessionId={session.id} mode="agent" />
|
|
</div>
|
|
<PassthroughComposer onSubmit={handleSendMessage} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<TaskChatPanel onSend={handleSendMessage} sessionId={session.id} hideSessionsDropdown />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* PassthroughComposer is the kandev-controlled compose box rendered alongside
|
|
* the PTY in passthrough mode. Submitting forwards the typed text via the
|
|
* onSubmit prop (which posts `message.add` over WS); the backend's
|
|
* `Executor.Prompt` routes passthrough sessions to PTY stdin so the CLI agent
|
|
* actually receives it. Enter submits; Shift+Enter inserts a newline.
|
|
*/
|
|
export function PassthroughComposer({
|
|
onSubmit,
|
|
}: {
|
|
onSubmit: (content: string) => Promise<void>;
|
|
}) {
|
|
const [value, setValue] = useState("");
|
|
const [isSending, setIsSending] = useState(false);
|
|
const trimmed = value.trim();
|
|
const canSubmit = trimmed.length > 0 && !isSending;
|
|
|
|
const submit = useCallback(async () => {
|
|
if (!canSubmit) return;
|
|
setIsSending(true);
|
|
try {
|
|
await onSubmit(trimmed);
|
|
setValue("");
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
}, [canSubmit, onSubmit, trimmed]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void submit();
|
|
}
|
|
},
|
|
[submit],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-shrink-0 items-end gap-2 border-t bg-card px-2 py-2"
|
|
data-testid="passthrough-composer"
|
|
>
|
|
<Textarea
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Message the CLI agent (Enter to send, Shift+Enter for newline)"
|
|
rows={1}
|
|
disabled={isSending}
|
|
className="min-h-9 max-h-32 flex-1 resize-none"
|
|
data-testid="passthrough-composer-textarea"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="default"
|
|
onClick={() => void submit()}
|
|
disabled={!canSubmit}
|
|
className="cursor-pointer h-9 shrink-0"
|
|
data-testid="passthrough-composer-submit"
|
|
aria-label="Send message to CLI agent"
|
|
>
|
|
<IconSend className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RunningSpinner() {
|
|
return <GridSpinner className="text-muted-foreground shrink-0 text-[12px]" />;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|