diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go
index bc20719a52..de6567f4c9 100644
--- a/apps/backend/cmd/mock-agent/main.go
+++ b/apps/backend/cmd/mock-agent/main.go
@@ -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 {
diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go
index aa976da81f..f07a437490 100644
--- a/apps/backend/internal/task/handlers/task_http_handlers.go
+++ b/apps/backend/internal/task/handlers/task_http_handlers.go
@@ -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
}
diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx
index 2034190530..23c25d60fc 100644
--- a/apps/web/components/quick-chat/quick-chat-modal.tsx
+++ b/apps/web/components/quick-chat/quick-chat-modal.tsx
@@ -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 ;
}
-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 (
@@ -117,23 +124,33 @@ function AgentPickerView({ onSelectAgent }: { onSelectAgent: (agentId: string) =
- {agentProfiles.map((profile) => (
-
@@ -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 && (
)}
- {activeSessionNeedsAgent && }
+ {activeSessionNeedsAgent && (
+
+ )}
diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.test.ts b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts
new file mode 100644
index 0000000000..3713545ca8
--- /dev/null
+++ b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts
@@ -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[1];
+
+function makeStore(overrides: Partial = {}): 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();
+ });
+});
diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts
index bf91eab647..b59ccff41a 100644
--- a/apps/web/components/quick-chat/use-quick-chat-modal.ts
+++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts
@@ -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;
+
+/** 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(null);
+ const [pendingAgentId, setPendingAgentId] = useState(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(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,
diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts
index 751fe8a38e..c647c929b4 100644
--- a/apps/web/e2e/tests/chat/message-queue.spec.ts
+++ b/apps/web/e2e/tests/chat/message-queue.spec.ts
@@ -60,7 +60,12 @@ async function openQuickChatWithAgent(page: Page): Promise {
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;
}
diff --git a/apps/web/e2e/tests/chat/quick-chat.spec.ts b/apps/web/e2e/tests/chat/quick-chat.spec.ts
index 3cf2de62b3..158723a27c 100644
--- a/apps/web/e2e/tests/chat/quick-chat.spec.ts
+++ b/apps/web/e2e/tests/chat/quick-chat.spec.ts
@@ -32,14 +32,23 @@ async function openQuickChatWithAgent(page: Page): Promise {
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,
});