From 7e8b4bb30e40812d7f0bda7ecd000ccdefa43360 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Almeida?=
<57680069+zeval@users.noreply.github.com>
Date: Fri, 1 May 2026 00:21:53 +0100
Subject: [PATCH] feat(quick-chat): eager-init agent on profile selection
(#747)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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)
---
apps/backend/cmd/mock-agent/main.go | 29 ++-
.../task/handlers/task_http_handlers.go | 26 ++-
.../quick-chat/quick-chat-modal.tsx | 58 ++++--
.../quick-chat/use-quick-chat-modal.test.ts | 196 ++++++++++++++++++
.../quick-chat/use-quick-chat-modal.ts | 160 +++++++++-----
apps/web/e2e/tests/chat/message-queue.spec.ts | 7 +-
apps/web/e2e/tests/chat/quick-chat.spec.ts | 83 +++++++-
7 files changed, 477 insertions(+), 82 deletions(-)
create mode 100644 apps/web/components/quick-chat/use-quick-chat-modal.test.ts
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 (