7e8b4bb30e
* feat(quick-chat): eager-init agent on profile selection Switch quick-chat backend from `IntentPrepare` to `IntentStart` so the agent process boots immediately when the user picks a profile, instead of waiting for the first prompt. The ACP `session/new` handshake fires before the user types anything, so the slash menu, mode dropdown, and model selector are populated up-front. Fixes the empty-slash-menu UX in quick chat for ACP agents (OpenCode, Claude, Codex, etc.). Frontend picker shows a per-card spinner + disables sibling cards during boot. Rapid profile switches (and other picker-bypassing actions like opening a new tab, switching tabs, or closing the modal) supersede the in-flight start via a monotonic request id; the resolved task is cleaned up via `deleteQuickChatTask` so we never orphan an ephemeral task or its agent process. Backend rollback uses a fresh background context so `DeleteTask` runs even when the request context has already been cancelled by the client. `mock-agent` now emits `available_commands_update` from `NewSession` and `LoadSession` (matching real OpenCode/Claude) so the eager-init pipeline is exercised in e2e. Existing prompt-time emit kept as an idempotent fallback. * test+cleanup(quick-chat): unit tests for useAgentSelection; minor cleanups - Add 5 unit tests for useAgentSelection covering happy path, rapid-pick supersession, reset() during in-flight, and error toast vs silent-on-superseded. Exercises the orphan-task cleanup that the eager-init flow depends on. (claude bot review) - Drop redundant isDisabled alias in AgentPickerView. (claude bot review) - Drop unused ctx parameter from emitAvailableCommandsAfterDelay — it was always called with context.Background() so the Done() branch was dead code. (claude bot review) - Re-format with prettier after the rebase. * fix(quick-chat): use constants.TaskDeleteTimeout for rollback ctx Match the other DeleteTask call sites in task_http_handlers.go (line 851) instead of an ad-hoc 10s timeout. (claude bot review)
210 lines
7.3 KiB
TypeScript
210 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { memo } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { IconLoader2, IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { PassthroughTerminal } from "@/components/task/passthrough-terminal";
|
|
import { QuickChatContent } from "./quick-chat-content";
|
|
import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog";
|
|
import { useQuickChatModal } from "./use-quick-chat-modal";
|
|
|
|
type QuickChatModalProps = {
|
|
workspaceId: string;
|
|
};
|
|
|
|
function QuickChatTabs({
|
|
sessions,
|
|
activeSessionId,
|
|
onTabChange,
|
|
onTabClose,
|
|
onNewChat,
|
|
}: {
|
|
sessions: Array<{ sessionId: string; workspaceId: string; name?: string }>;
|
|
activeSessionId: string;
|
|
onTabChange: (sessionId: string) => void;
|
|
onTabClose: (sessionId: string) => void;
|
|
onNewChat: () => void;
|
|
}) {
|
|
if (sessions.length === 0) return null;
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/20">
|
|
<div className="flex items-center gap-1 overflow-x-auto flex-1 scrollbar-hide">
|
|
{sessions.map((s, index) => {
|
|
const isActive = s.sessionId === activeSessionId;
|
|
// Show "New Chat" for empty session IDs (agent picker tabs)
|
|
const tabName = s.sessionId === "" ? "New Chat" : s.name || `Chat ${index + 1}`;
|
|
return (
|
|
<div
|
|
key={s.sessionId || `new-${index}`}
|
|
className={`flex items-center gap-1 rounded transition-colors whitespace-nowrap ${
|
|
isActive
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onTabChange(s.sessionId)}
|
|
className="flex items-center px-2.5 py-1 text-xs cursor-pointer"
|
|
>
|
|
<span className="truncate max-w-[100px]">{tabName}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={`Close ${tabName}`}
|
|
className="p-1 cursor-pointer opacity-60 hover:opacity-100"
|
|
onClick={() => onTabClose(s.sessionId)}
|
|
>
|
|
<IconX className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 w-6 p-0 cursor-pointer shrink-0"
|
|
onClick={onNewChat}
|
|
aria-label="Start new chat"
|
|
>
|
|
<IconPlus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useIsQuickChatPassthrough(sessionId: string) {
|
|
return useAppStore(
|
|
useShallow((s) => {
|
|
const session = s.taskSessions.items[sessionId];
|
|
if (typeof session?.is_passthrough === "boolean") return session.is_passthrough;
|
|
const profileId =
|
|
session?.agent_profile_id ??
|
|
s.quickChat.sessions.find((qs) => qs.sessionId === sessionId)?.agentProfileId;
|
|
if (!profileId) return false;
|
|
return s.agentProfiles.items.find((p) => p.id === profileId)?.cli_passthrough === true;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function QuickChatSessionView({ sessionId }: { sessionId: string }) {
|
|
const isPassthrough = useIsQuickChatPassthrough(sessionId);
|
|
if (isPassthrough) {
|
|
return (
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
<PassthroughTerminal key={sessionId} sessionId={sessionId} mode="agent" />
|
|
</div>
|
|
);
|
|
}
|
|
return <QuickChatContent sessionId={sessionId} />;
|
|
}
|
|
|
|
function AgentPickerView({
|
|
onSelectAgent,
|
|
pendingAgentId,
|
|
}: {
|
|
onSelectAgent: (agentId: string) => void;
|
|
pendingAgentId: string | null;
|
|
}) {
|
|
const agentProfiles = useAppStore((s) => s.agentProfiles.items) ?? [];
|
|
const isLoading = pendingAgentId !== null;
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col items-center justify-center p-8">
|
|
<div className="max-w-2xl w-full space-y-6">
|
|
<div className="text-center space-y-2">
|
|
<h3 className="text-lg font-medium">Choose an agent to start chatting</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Select an AI agent to begin your conversation
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{agentProfiles.map((profile) => {
|
|
const isPending = pendingAgentId === profile.id;
|
|
return (
|
|
<button
|
|
key={profile.id}
|
|
onClick={() => onSelectAgent(profile.id)}
|
|
disabled={isLoading}
|
|
className="group relative flex flex-col items-start gap-2 rounded-lg border p-4 text-left transition-all hover:border-primary hover:bg-accent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-transparent"
|
|
>
|
|
<div className="flex items-center gap-2 w-full">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-md border bg-background">
|
|
{isPending ? (
|
|
<IconLoader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<IconMessageCircle className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm truncate">{profile.label}</p>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{isPending ? "Starting agent..." : profile.agent_name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: QuickChatModalProps) {
|
|
const {
|
|
isOpen,
|
|
sessions,
|
|
activeSessionId,
|
|
sessionToClose,
|
|
activeSessionNeedsAgent,
|
|
pendingAgentId,
|
|
setActiveQuickChatSession,
|
|
setSessionToClose,
|
|
handleOpenChange,
|
|
handleNewChat,
|
|
handleSelectAgent,
|
|
handleCloseTab,
|
|
handleConfirmClose,
|
|
} = useQuickChatModal(workspaceId);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
<DialogContent
|
|
className="!max-w-[80vw] !w-[80vw] max-h-[85vh] h-[85vh] p-0 gap-0 flex flex-col shadow-2xl"
|
|
showCloseButton={false}
|
|
overlayClassName="bg-transparent"
|
|
>
|
|
<DialogTitle className="sr-only">Quick Chat</DialogTitle>
|
|
<QuickChatTabs
|
|
sessions={sessions}
|
|
activeSessionId={activeSessionId || ""}
|
|
onTabChange={setActiveQuickChatSession}
|
|
onTabClose={handleCloseTab}
|
|
onNewChat={handleNewChat}
|
|
/>
|
|
{activeSessionId && !activeSessionNeedsAgent && (
|
|
<QuickChatSessionView sessionId={activeSessionId} />
|
|
)}
|
|
{activeSessionNeedsAgent && (
|
|
<AgentPickerView onSelectAgent={handleSelectAgent} pendingAgentId={pendingAgentId} />
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<QuickChatDeleteDialog
|
|
sessionToDelete={sessionToClose}
|
|
onOpenChange={(open) => !open && setSessionToClose(null)}
|
|
onConfirm={handleConfirmClose}
|
|
/>
|
|
</>
|
|
);
|
|
});
|