feat(quick-chat): eager-init agent on profile selection (#747)
* 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)
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
)
|
||||
@@ -89,6 +90,11 @@ func (a *mockAgent) NewSession(_ context.Context, req acp.NewSessionRequest) (ac
|
||||
// This bridges ACP protocol MCP config to the mock agent's MCP client.
|
||||
registerACPMcpServers(req.McpServers)
|
||||
|
||||
// Emit available commands asynchronously after the session/new response
|
||||
// flushes. Real ACP agents (OpenCode, Claude) emit available_commands_update
|
||||
// here, which lets clients populate slash menus before the first prompt.
|
||||
go a.emitAvailableCommandsAfterDelay(sid)
|
||||
|
||||
return acp.NewSessionResponse{
|
||||
SessionId: sid,
|
||||
Models: mockSessionModels(),
|
||||
@@ -131,8 +137,15 @@ func mockSessionModes() *acp.SessionModeState {
|
||||
func (a *mockAgent) LoadSession(_ context.Context, req acp.LoadSessionRequest) (acp.LoadSessionResponse, error) {
|
||||
a.mu.Lock()
|
||||
a.sessions[req.SessionId] = true
|
||||
// Reset emit state so the resume re-advertises commands (matches real
|
||||
// agents which re-emit on session/load).
|
||||
delete(a.commandsEmitted, req.SessionId)
|
||||
a.mu.Unlock()
|
||||
_, _ = fmt.Fprintf(logOutput, "mock-agent[%d]: resumed session %s\n", os.Getpid(), req.SessionId)
|
||||
|
||||
// Re-emit available commands after the session/load response flushes.
|
||||
go a.emitAvailableCommandsAfterDelay(req.SessionId)
|
||||
|
||||
return acp.LoadSessionResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -162,9 +175,9 @@ func (a *mockAgent) SetSessionConfigOption(_ context.Context, _ acp.SetSessionCo
|
||||
return acp.SetSessionConfigOptionResponse{}, nil
|
||||
}
|
||||
|
||||
// emitAvailableCommandsOnce sends the available commands list once per session,
|
||||
// on the first prompt. Emitting during Prompt (not NewSession) avoids writing
|
||||
// notifications to stdout before the JSON-RPC session response.
|
||||
// emitAvailableCommandsOnce sends the available commands list once per session.
|
||||
// Idempotent — guarded by `commandsEmitted` so callers (NewSession, LoadSession,
|
||||
// first Prompt) can call it freely.
|
||||
func (a *mockAgent) emitAvailableCommandsOnce(ctx context.Context, sid acp.SessionId) {
|
||||
a.mu.Lock()
|
||||
if a.commandsEmitted[sid] {
|
||||
@@ -184,6 +197,16 @@ func (a *mockAgent) emitAvailableCommandsOnce(ctx context.Context, sid acp.Sessi
|
||||
})
|
||||
}
|
||||
|
||||
// emitAvailableCommandsAfterDelay defers the emit so the JSON-RPC response for
|
||||
// session/new or session/load is written to stdout first. Real ACP agents
|
||||
// (OpenCode, Claude) emit available_commands_update notifications immediately
|
||||
// after these handshakes, so kandev clients can populate slash menus before
|
||||
// the first prompt — eager-init for quick chat depends on this.
|
||||
func (a *mockAgent) emitAvailableCommandsAfterDelay(sid acp.SessionId) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
a.emitAvailableCommandsOnce(context.Background(), sid)
|
||||
}
|
||||
|
||||
// mockAvailableCommands returns the slash commands supported by the mock agent.
|
||||
func mockAvailableCommands() []acp.AvailableCommand {
|
||||
hint := func(h string) *acp.AvailableCommandInput {
|
||||
|
||||
@@ -978,22 +978,30 @@ func (h *TaskHandlers) httpStartQuickChat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Eager-init: launch the agent process up-front so ACP `initialize` + `session/new`
|
||||
// fire and the agent emits available_commands/modes/models. This populates the
|
||||
// slash menu, mode dropdown, and model selector before the user sends any prompt.
|
||||
resp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
|
||||
TaskID: task.ID,
|
||||
Intent: orchestrator.IntentPrepare,
|
||||
AgentProfileID: params.agentProfileID,
|
||||
ExecutorID: params.executorID,
|
||||
LaunchWorkspace: true,
|
||||
TaskID: task.ID,
|
||||
Intent: orchestrator.IntentStart,
|
||||
AgentProfileID: params.agentProfileID,
|
||||
ExecutorID: params.executorID,
|
||||
})
|
||||
if err != nil {
|
||||
// Rollback: delete the ephemeral task to prevent orphans
|
||||
if deleteErr := h.service.DeleteTask(ctx, task.ID); deleteErr != nil {
|
||||
// Rollback: delete the ephemeral task to prevent orphans. Use a fresh
|
||||
// background context — the request context may already be cancelled
|
||||
// (e.g. client aborted, deadline exceeded), and we still want cleanup
|
||||
// to run. TaskDeleteTimeout matches the other DeleteTask call sites
|
||||
// in this file so a future change to the constant covers this path too.
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout)
|
||||
defer cancel()
|
||||
if deleteErr := h.service.DeleteTask(rollbackCtx, task.ID); deleteErr != nil {
|
||||
h.logger.Error("failed to rollback quick chat task",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.Error(deleteErr))
|
||||
}
|
||||
h.logger.Error("failed to prepare quick chat session", zap.Error(err), zap.String("task_id", task.ID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare session"})
|
||||
h.logger.Error("failed to start quick chat session", zap.Error(err), zap.String("task_id", task.ID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start session"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react";
|
||||
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";
|
||||
@@ -104,8 +104,15 @@ function QuickChatSessionView({ sessionId }: { sessionId: string }) {
|
||||
return <QuickChatContent sessionId={sessionId} />;
|
||||
}
|
||||
|
||||
function AgentPickerView({ onSelectAgent }: { onSelectAgent: (agentId: string) => void }) {
|
||||
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">
|
||||
@@ -117,23 +124,33 @@ function AgentPickerView({ onSelectAgent }: { onSelectAgent: (agentId: string) =
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{agentProfiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => onSelectAgent(profile.id)}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
{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>
|
||||
<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">{profile.agent_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,6 +164,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
|
||||
activeSessionId,
|
||||
sessionToClose,
|
||||
activeSessionNeedsAgent,
|
||||
pendingAgentId,
|
||||
setActiveQuickChatSession,
|
||||
setSessionToClose,
|
||||
handleOpenChange,
|
||||
@@ -175,7 +193,9 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
|
||||
{activeSessionId && !activeSessionNeedsAgent && (
|
||||
<QuickChatSessionView sessionId={activeSessionId} />
|
||||
)}
|
||||
{activeSessionNeedsAgent && <AgentPickerView onSelectAgent={handleSelectAgent} />}
|
||||
{activeSessionNeedsAgent && (
|
||||
<AgentPickerView onSelectAgent={handleSelectAgent} pendingAgentId={pendingAgentId} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// Mocks must be declared before importing the hook so vi.mock hoists correctly.
|
||||
const mockToast = vi.fn();
|
||||
const mockStartQuickChat = vi.fn();
|
||||
const mockDeleteTask = vi.fn();
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: mockToast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/workspace-api", () => ({
|
||||
startQuickChat: (...args: unknown[]) => mockStartQuickChat(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/kanban-api", () => ({
|
||||
deleteTask: (...args: unknown[]) => mockDeleteTask(...args),
|
||||
}));
|
||||
|
||||
import { useAgentSelection } from "./use-quick-chat-modal";
|
||||
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
|
||||
type MockStore = Parameters<typeof useAgentSelection>[1];
|
||||
|
||||
function makeStore(overrides: Partial<MockStore> = {}): MockStore {
|
||||
return {
|
||||
isOpen: true,
|
||||
sessions: [],
|
||||
activeSessionId: "",
|
||||
closeQuickChat: vi.fn(),
|
||||
closeQuickChatSession: vi.fn(),
|
||||
setActiveQuickChatSession: vi.fn(),
|
||||
renameQuickChatSession: vi.fn(),
|
||||
openQuickChat: vi.fn(),
|
||||
agentProfiles: [
|
||||
{ id: "agent-a", label: "Agent A", agent_id: "a", agent_name: "Agent A" },
|
||||
{ id: "agent-b", label: "Agent B", agent_id: "b", agent_name: "Agent B" },
|
||||
] as MockStore["agentProfiles"],
|
||||
taskSessions: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useAgentSelection — happy path", () => {
|
||||
it("opens the chat and clears pending state when the request resolves", async () => {
|
||||
const store = makeStore();
|
||||
mockStartQuickChat.mockResolvedValue({ task_id: "task-a", session_id: "sess-a" });
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectAgent("agent-a", onSuccess);
|
||||
});
|
||||
|
||||
expect(store.openQuickChat).toHaveBeenCalledWith("sess-a", WORKSPACE_ID, "agent-a");
|
||||
expect(store.renameQuickChatSession).toHaveBeenCalledWith("sess-a", expect.any(String));
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteTask).not.toHaveBeenCalled();
|
||||
expect(result.current.pendingAgentId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAgentSelection — supersession", () => {
|
||||
it("rapid-pick: a newer pick deletes the older orphan task", async () => {
|
||||
const store = makeStore();
|
||||
let resolveFirst!: (v: { task_id: string; session_id: string }) => void;
|
||||
const firstPromise = new Promise<{ task_id: string; session_id: string }>((r) => {
|
||||
resolveFirst = r;
|
||||
});
|
||||
mockStartQuickChat
|
||||
.mockImplementationOnce(() => firstPromise)
|
||||
.mockResolvedValueOnce({ task_id: "task-b", session_id: "sess-b" });
|
||||
|
||||
const onSuccessA = vi.fn();
|
||||
const onSuccessB = vi.fn();
|
||||
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
|
||||
|
||||
// Click A — request hangs.
|
||||
act(() => {
|
||||
void result.current.handleSelectAgent("agent-a", onSuccessA);
|
||||
});
|
||||
expect(result.current.pendingAgentId).toBe("agent-a");
|
||||
|
||||
// Click B — supersedes A.
|
||||
await act(async () => {
|
||||
await result.current.handleSelectAgent("agent-b", onSuccessB);
|
||||
});
|
||||
expect(onSuccessB).toHaveBeenCalledTimes(1);
|
||||
expect(store.openQuickChat).toHaveBeenCalledWith("sess-b", WORKSPACE_ID, "agent-b");
|
||||
|
||||
// Now A resolves — its onSuccess must NOT fire and the orphan task is deleted.
|
||||
await act(async () => {
|
||||
resolveFirst({ task_id: "task-a", session_id: "sess-a" });
|
||||
await flushPromises();
|
||||
});
|
||||
expect(onSuccessA).not.toHaveBeenCalled();
|
||||
expect(mockDeleteTask).toHaveBeenCalledWith("task-a");
|
||||
expect(store.openQuickChat).not.toHaveBeenCalledWith(
|
||||
"sess-a",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("reset() during in-flight request: resolved task is deleted, onSuccess not called", async () => {
|
||||
const store = makeStore();
|
||||
let resolveStart!: (v: { task_id: string; session_id: string }) => void;
|
||||
mockStartQuickChat.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ task_id: string; session_id: string }>((r) => {
|
||||
resolveStart = r;
|
||||
}),
|
||||
);
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSelectAgent("agent-a", onSuccess);
|
||||
});
|
||||
expect(result.current.pendingAgentId).toBe("agent-a");
|
||||
|
||||
// User does something that supersedes the in-flight pick (handleNewChat, tab switch, etc.).
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
expect(result.current.pendingAgentId).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveStart({ task_id: "task-a", session_id: "sess-a" });
|
||||
await flushPromises();
|
||||
});
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(store.openQuickChat).not.toHaveBeenCalled();
|
||||
expect(mockDeleteTask).toHaveBeenCalledWith("task-a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAgentSelection — error handling", () => {
|
||||
it("does not toast when a superseded request rejects (avoid noise from races)", async () => {
|
||||
const store = makeStore();
|
||||
let rejectStart!: (e: Error) => void;
|
||||
mockStartQuickChat.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ task_id: string; session_id: string }>((_resolve, reject) => {
|
||||
rejectStart = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSelectAgent("agent-a", vi.fn());
|
||||
});
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectStart(new Error("network blew up"));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts when the current (non-superseded) request rejects", async () => {
|
||||
const store = makeStore();
|
||||
mockStartQuickChat.mockRejectedValueOnce(new Error("server exploded"));
|
||||
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectAgent("agent-a", vi.fn());
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to start quick chat",
|
||||
description: "server exploded",
|
||||
variant: "error",
|
||||
}),
|
||||
);
|
||||
expect(result.current.pendingAgentId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
@@ -28,65 +28,131 @@ function useQuickChatStore() {
|
||||
);
|
||||
}
|
||||
|
||||
export function useQuickChatModal(workspaceId: string) {
|
||||
type QuickChatStore = ReturnType<typeof useQuickChatStore>;
|
||||
|
||||
/** POSTs to start a quick-chat session and returns the response. */
|
||||
async function startQuickChatForAgent(workspaceId: string, agentId: string, store: QuickChatStore) {
|
||||
const agent = store.agentProfiles.find((p) => p.id === agentId);
|
||||
const sessionCount = store.sessions.filter((s) => s.sessionId !== "").length + 1;
|
||||
const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`;
|
||||
const response = await startQuickChat(workspaceId, {
|
||||
agent_profile_id: agentId,
|
||||
title: initialName,
|
||||
});
|
||||
return { sessionId: response.session_id, name: initialName, taskId: response.task_id };
|
||||
}
|
||||
|
||||
/** Manages the eager agent-init lifecycle for the picker.
|
||||
*
|
||||
* Eager init means the backend boots a real agent process before responding.
|
||||
* Aborting the fetch on a rapid second click would NOT stop the backend agent
|
||||
* (it's already running by the time the abort lands), and we'd never see the
|
||||
* task_id on the FE — orphaning the task. Instead we let every request run
|
||||
* to completion and reconcile by request id: if a newer pick superseded this
|
||||
* one before the response arrived, we delete the now-orphaned ephemeral task.
|
||||
*
|
||||
* Exported for unit testing — see `use-quick-chat-modal.test.ts`. */
|
||||
export function useAgentSelection(workspaceId: string, store: QuickChatStore) {
|
||||
const { toast } = useToast();
|
||||
const store = useQuickChatStore();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
||||
const [sessionToClose, setSessionToClose] = useState<string | null>(null);
|
||||
const [pendingAgentId, setPendingAgentId] = useState<string | null>(null);
|
||||
// Monotonic request id; the latest click "wins" — older responses get
|
||||
// cleaned up if the backend already started their agent.
|
||||
const latestRequestId = useRef(0);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
store.closeQuickChat();
|
||||
setShowAgentPicker(false);
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
store.openQuickChat("", workspaceId);
|
||||
}, [store, workspaceId]);
|
||||
const reset = useCallback(() => {
|
||||
latestRequestId.current += 1;
|
||||
setPendingAgentId(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (isCreating) return;
|
||||
setIsCreating(true);
|
||||
async (agentId: string, onSuccess: () => void) => {
|
||||
const requestId = ++latestRequestId.current;
|
||||
setPendingAgentId(agentId);
|
||||
try {
|
||||
const agent = store.agentProfiles.find((p) => p.id === agentId);
|
||||
const sessionCount = store.sessions.filter((s) => s.sessionId !== "").length + 1;
|
||||
const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`;
|
||||
const response = await startQuickChat(workspaceId, {
|
||||
agent_profile_id: agentId,
|
||||
title: initialName,
|
||||
});
|
||||
const result = await startQuickChatForAgent(workspaceId, agentId, store);
|
||||
if (latestRequestId.current !== requestId) {
|
||||
// A newer pick superseded us — the backend already booted this
|
||||
// agent, so delete the orphan task. Best-effort: ignore failures.
|
||||
deleteQuickChatTask(result.taskId).catch((err) =>
|
||||
console.error("Failed to clean up superseded quick chat task:", err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (store.activeSessionId === "") store.closeQuickChatSession("");
|
||||
store.openQuickChat(response.session_id, workspaceId, agentId);
|
||||
store.renameQuickChatSession(response.session_id, initialName);
|
||||
setShowAgentPicker(false);
|
||||
store.openQuickChat(result.sessionId, workspaceId, agentId);
|
||||
store.renameQuickChatSession(result.sessionId, result.name);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (latestRequestId.current !== requestId) return;
|
||||
toast({
|
||||
title: "Failed to start quick chat",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
if (latestRequestId.current === requestId) {
|
||||
setPendingAgentId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[workspaceId, isCreating, store, toast],
|
||||
[workspaceId, store, toast],
|
||||
);
|
||||
|
||||
return { pendingAgentId, reset, handleSelectAgent };
|
||||
}
|
||||
|
||||
export function useQuickChatModal(workspaceId: string) {
|
||||
const { toast } = useToast();
|
||||
const store = useQuickChatStore();
|
||||
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
||||
const [sessionToClose, setSessionToClose] = useState<string | null>(null);
|
||||
const {
|
||||
pendingAgentId,
|
||||
reset,
|
||||
handleSelectAgent: doSelectAgent,
|
||||
} = useAgentSelection(workspaceId, store);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) return;
|
||||
reset();
|
||||
store.closeQuickChat();
|
||||
setShowAgentPicker(false);
|
||||
},
|
||||
[store, reset],
|
||||
);
|
||||
|
||||
// Any picker-bypassing user action while a pick is pending should supersede
|
||||
// the in-flight start, so the resolved request cleans up its orphan task
|
||||
// instead of yanking the user back to that session.
|
||||
const handleNewChat = useCallback(() => {
|
||||
reset();
|
||||
store.openQuickChat("", workspaceId);
|
||||
}, [reset, store, workspaceId]);
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => doSelectAgent(agentId, () => setShowAgentPicker(false)),
|
||||
[doSelectAgent],
|
||||
);
|
||||
|
||||
const setActiveQuickChatSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
reset();
|
||||
store.setActiveQuickChatSession(sessionId);
|
||||
},
|
||||
[reset, store],
|
||||
);
|
||||
|
||||
const handleCloseTab = useCallback(
|
||||
(sessionId: string) => {
|
||||
reset();
|
||||
if (sessionId === "") {
|
||||
store.closeQuickChatSession(sessionId);
|
||||
return;
|
||||
}
|
||||
setSessionToClose(sessionId);
|
||||
},
|
||||
[store],
|
||||
[reset, store],
|
||||
);
|
||||
|
||||
const handleConfirmClose = useCallback(async () => {
|
||||
@@ -95,17 +161,16 @@ export function useQuickChatModal(workspaceId: string) {
|
||||
setSessionToClose(null);
|
||||
const taskId = store.taskSessions[sessionId]?.task_id;
|
||||
store.closeQuickChatSession(sessionId);
|
||||
if (taskId) {
|
||||
try {
|
||||
await deleteQuickChatTask(taskId);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete quick chat task:", error);
|
||||
toast({
|
||||
title: "Failed to delete quick chat",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
if (!taskId) return;
|
||||
try {
|
||||
await deleteQuickChatTask(taskId);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete quick chat task:", error);
|
||||
toast({
|
||||
title: "Failed to delete quick chat",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}, [sessionToClose, store, toast]);
|
||||
|
||||
@@ -115,7 +180,8 @@ export function useQuickChatModal(workspaceId: string) {
|
||||
activeSessionId: store.activeSessionId,
|
||||
sessionToClose,
|
||||
activeSessionNeedsAgent: store.activeSessionId === "" || showAgentPicker,
|
||||
setActiveQuickChatSession: store.setActiveQuickChatSession,
|
||||
pendingAgentId,
|
||||
setActiveQuickChatSession,
|
||||
setSessionToClose,
|
||||
handleOpenChange,
|
||||
handleNewChat,
|
||||
|
||||
@@ -60,7 +60,12 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
|
||||
await expect(agentCard).toBeVisible({ timeout: 5_000 });
|
||||
await agentCard.click();
|
||||
|
||||
await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 15_000 });
|
||||
// Wait for chat input to appear AND become editable. Eager init means the
|
||||
// agent starts during the picker → tab transition; the input is briefly
|
||||
// disabled while the FE store catches up to the RUNNING session state.
|
||||
const editor = dialog.locator(".tiptap.ProseMirror");
|
||||
await expect(editor).toBeVisible({ timeout: 15_000 });
|
||||
await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,23 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
|
||||
await expect(agentCard).toBeVisible({ timeout: 5_000 });
|
||||
await agentCard.click();
|
||||
|
||||
// Wait for chat input to appear (session created and content rendered).
|
||||
await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 15_000 });
|
||||
// Wait for chat input to appear AND become editable. Eager init means the
|
||||
// agent starts during the HTTP request, so the input is briefly disabled
|
||||
// while the FE store catches up to the RUNNING session state.
|
||||
const editor = dialog.locator(".tiptap.ProseMirror");
|
||||
await expect(editor).toBeVisible({ timeout: 15_000 });
|
||||
await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 });
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function sendQuickChatMessage(dialog: Locator, page: Page, text: string) {
|
||||
const editor = dialog.locator(".tiptap.ProseMirror");
|
||||
// Wait for the editor to be editable. With eager init, the agent boots
|
||||
// during the picker → tab transition and the input is briefly disabled
|
||||
// while the FE store catches up to RUNNING. Multi-tab scenarios share an
|
||||
// agent slot so the second boot can take longer.
|
||||
await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 });
|
||||
await editor.click();
|
||||
await editor.fill(text);
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
@@ -106,6 +115,72 @@ test.describe("Quick Chat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("slash command menu populates before first message (eager agent init)", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
// Picking an agent in quick chat should boot the agent process eagerly,
|
||||
// so available_commands_update fires from session/new — the slash menu is
|
||||
// populated before the user sends their first prompt. Mock-agent emits
|
||||
// /slow, /error, /thinking, etc. on session/new (parity with real ACP
|
||||
// agents like OpenCode and Claude).
|
||||
// Hook WebSocket BEFORE navigating to capture all session.available_commands
|
||||
// frames into window.__wsAvailableCommands for assertion.
|
||||
await testPage.addInitScript(() => {
|
||||
const w = window as unknown as {
|
||||
__wsAvailableCommands: unknown[];
|
||||
__wsAll: unknown[];
|
||||
};
|
||||
w.__wsAvailableCommands = [];
|
||||
w.__wsAll = [];
|
||||
const OrigWS = window.WebSocket;
|
||||
const Hooked = function (this: WebSocket, url: string | URL, protocols?: string | string[]) {
|
||||
const ws = new OrigWS(url, protocols);
|
||||
w.__wsAll.push({ event: "open", url: String(url) });
|
||||
ws.addEventListener("message", (ev: MessageEvent) => {
|
||||
try {
|
||||
const m = JSON.parse(String(ev.data));
|
||||
w.__wsAll.push({ event: "msg", action: m.action });
|
||||
if (m.action === "session.available_commands") {
|
||||
w.__wsAvailableCommands.push({
|
||||
session_id: m.payload?.session_id,
|
||||
count: m.payload?.available_commands?.length ?? 0,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
return ws;
|
||||
} as unknown as typeof WebSocket;
|
||||
Hooked.prototype = OrigWS.prototype;
|
||||
Object.assign(Hooked, OrigWS);
|
||||
window.WebSocket = Hooked;
|
||||
});
|
||||
|
||||
const dialog = await openQuickChatWithAgent(testPage);
|
||||
|
||||
// Wait for the available_commands WS frame to land. Eager init kicks off
|
||||
// session/new during the HTTP request, but the agent emits commands
|
||||
// asynchronously after the response flushes — so the frame can arrive
|
||||
// moments after openQuickChatWithAgent resolves.
|
||||
await testPage.waitForFunction(
|
||||
() => {
|
||||
const w = window as unknown as { __wsAvailableCommands?: unknown[] };
|
||||
return (w.__wsAvailableCommands?.length ?? 0) > 0;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
const editor = dialog.locator(".tiptap.ProseMirror");
|
||||
await editor.click();
|
||||
await editor.pressSequentially("/");
|
||||
|
||||
// SlashCommandMenu renders into a portal at document root, so query at page level.
|
||||
await expect(testPage.getByText("Commands").first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText("/slow")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(testPage.getByText("/error")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("supports multiple chat tabs and switching between them", async ({ testPage }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
@@ -138,7 +213,9 @@ test.describe("Quick Chat", () => {
|
||||
|
||||
// Send a message in the second tab using script mode.
|
||||
await sendQuickChatMessage(dialog, testPage, 'e2e:message("second tab response")');
|
||||
await expect(dialog.getByText("second tab response", { exact: false })).toBeVisible({
|
||||
// The user message bubble also contains "second tab response" — match only
|
||||
// the agent reply (the rendered text without the surrounding script call).
|
||||
await expect(dialog.getByText("second tab response", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user