Files
kandev/apps/web/components/task/chat/messages/action-message.tsx
T

229 lines
7.5 KiB
TypeScript

"use client";
import { useState, useCallback, memo, type ReactElement } from "react";
import {
IconAlertTriangle,
IconArchive,
IconTrash,
IconRefresh,
IconPlayerPlay,
IconSparkles,
IconGitCommit,
IconX,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useTaskRemoval } from "@/hooks/use-task-removal";
import { deleteTask } from "@/lib/api/domains/kanban-api";
import { AuthMethodsPanel, GenericAuthPanel } from "./auth-methods-panel";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { MessageAction, RecoveryAuthMethod } from "@/components/task/chat/types";
const ICON_MAP: Record<string, React.ElementType> = {
archive: IconArchive,
trash: IconTrash,
refresh: IconRefresh,
"player-play": IconPlayerPlay,
sparkles: IconSparkles,
"git-commit": IconGitCommit,
"alert-triangle": IconAlertTriangle,
x: IconX,
};
type ActionMeta = {
actions?: MessageAction[];
variant?: string;
is_auth_error?: boolean;
auth_methods?: RecoveryAuthMethod[];
error_output?: string;
};
function isSessionActive(state?: TaskSessionState) {
return state === "RUNNING" || state === "STARTING" || state === "COMPLETED";
}
export const ActionMessage = memo(function ActionMessage({
comment,
sessionState,
}: {
comment: Message;
sessionState?: TaskSessionState;
}) {
const metadata = comment.metadata as ActionMeta | undefined;
const isWarning = metadata?.variant === "warning";
const message = comment.content || "An error occurred";
// Hide once session is active again (recovery succeeded)
if (isSessionActive(sessionState)) return null;
const iconClass = isWarning ? "text-amber-500" : "text-red-500";
const textClass = isWarning
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400";
return (
<div className="w-full">
<div className="flex items-start gap-3 w-full rounded px-2 py-1 -mx-2">
<div className="flex-shrink-0 mt-0.5">
<IconAlertTriangle className={cn("h-4 w-4", iconClass)} />
</div>
<div className="flex-1 min-w-0 pt-0.5">
<div className={cn("text-xs font-mono", textClass)}>{message}</div>
<ActionMessageDetails metadata={metadata} />
{metadata?.actions && metadata.actions.length > 0 && (
<ActionButtons actions={metadata.actions} taskId={comment.task_id} />
)}
</div>
</div>
</div>
);
});
function ActionMessageDetails({ metadata }: { metadata: ActionMeta | undefined }) {
const [hostShellOpen, setHostShellOpen] = useState(false);
const [hostShellCommand, setHostShellCommand] = useState<string | undefined>(undefined);
// Auth recovery uses the kandev host shell (where the agent CLIs are
// installed), not the task environment shell - the task env often isn't
// ready when an auth error fires (no workspace path yet), and the user's
// agent auth state lives in their home dir on the host anyway.
const openHostShellWithCommand = useCallback((command: string) => {
// Trailing newline runs the command immediately. Drop it if you'd rather
// let the user review first.
setHostShellCommand(command + "\n");
setHostShellOpen(true);
}, []);
const openHostShell = useCallback(() => {
setHostShellCommand(undefined);
setHostShellOpen(true);
}, []);
if (!metadata) return null;
return (
<>
{metadata.error_output && (
<pre className="mt-1.5 text-[11px] font-mono text-muted-foreground bg-muted/50 rounded p-2 overflow-auto max-h-[300px] whitespace-pre-wrap break-words">
{metadata.error_output}
</pre>
)}
{metadata.is_auth_error && metadata.auth_methods && metadata.auth_methods.length > 0 && (
<AuthMethodsPanel
methods={metadata.auth_methods}
onOpenTerminal={openHostShellWithCommand}
/>
)}
{metadata.is_auth_error && (!metadata.auth_methods || metadata.auth_methods.length === 0) && (
<GenericAuthPanel onOpenTerminal={openHostShell} />
)}
<HostShellDialog
open={hostShellOpen}
onOpenChange={setHostShellOpen}
initialInput={hostShellCommand}
/>
</>
);
}
function ActionButtons({ actions, taskId }: { actions: MessageAction[]; taskId?: string }) {
return (
<div className="mt-2 flex items-center gap-2">
{actions.map((action, i) => (
<ActionButton key={action.test_id ?? i} action={action} messageTaskId={taskId} />
))}
</div>
);
}
function ActionButton({
action,
messageTaskId,
}: {
action: MessageAction;
messageTaskId?: string;
}): ReactElement | null {
const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle");
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const taskId = messageTaskId || activeTaskId;
const store = useAppStoreApi();
const archiveAndSwitch = useArchiveAndSwitchTask();
const { removeTaskFromBoard } = useTaskRemoval({ store });
const execute = useCallback(async () => {
if (state === "busy") return;
setState("busy");
try {
switch (action.type) {
case "archive_task": {
if (taskId) await archiveAndSwitch(taskId);
break;
}
case "delete_task": {
if (taskId) {
const { activeTaskId, activeSessionId } = store.getState().tasks;
await deleteTask(taskId);
await removeTaskFromBoard(taskId, {
wasActiveTaskId: activeTaskId,
wasActiveSessionId: activeSessionId,
});
}
break;
}
case "ws_request": {
const client = getWebSocketClient();
const params = action.params as
| { method: string; payload: Record<string, unknown> }
| undefined;
if (client && params) await client.request(params.method, params.payload);
break;
}
}
setState("done");
} catch {
setState("error");
setTimeout(() => setState("idle"), 3000);
}
}, [action, state, taskId, store, archiveAndSwitch, removeTaskFromBoard]);
// Once a ws_request has been fired, hide this button: it's no longer
// actionable. If the recovery succeeds the whole ActionMessage unmounts via
// isSessionActive; if it fails, a newer status/error message renders fresh
// buttons, so this stale one would just confuse the user.
if (state === "done" && action.type === "ws_request") return null;
const Icon = action.icon ? ICON_MAP[action.icon] : null;
const disabled = state === "busy" || state === "done";
const isDestructive = action.variant === "destructive";
const button = (
<Button
variant="outline"
size="sm"
className={cn(
"h-7 text-xs cursor-pointer gap-1.5",
isDestructive && "text-destructive hover:text-destructive",
)}
disabled={disabled}
onClick={execute}
data-testid={action.test_id}
>
{Icon && <Icon className="h-3 w-3" />}
{action.label}
</Button>
);
if (action.tooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top">{action.tooltip}</TooltipContent>
</Tooltip>
);
}
return button;
}