diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go
index 405b1397e1..82f86fec87 100644
--- a/apps/backend/internal/backendapp/boot_state.go
+++ b/apps/backend/internal/backendapp/boot_state.go
@@ -21,6 +21,8 @@ const (
legacyOfficeWorkspaceCookie = "office-active-workspace"
bootStateKeySessionID = "sessionId"
bootStateKeyWorkspaceID = "workspaceId"
+ quickChatSessionKindChat = "chat"
+ quickChatSessionKindConfig = "config"
)
func bootInitialState(
@@ -387,13 +389,21 @@ func (b bootStateBuilder) quickChatSessions(ctx context.Context, workspaceID str
continue
}
items = append(items, quickChatBootSession{
- state: mapQuickChatSessionState(task, primary),
- createdAt: task.CreatedAt,
+ state: mapQuickChatSessionState(task, primary),
+ taskID: task.ID,
+ createdAt: task.CreatedAt,
+ lastActivityAt: quickChatLastActivityAt(task, primary),
})
taskSessions[primary.ID] = taskdto.FromTaskSession(primary)
}
sort.SliceStable(items, func(i, j int) bool {
- return items[i].createdAt.Before(items[j].createdAt)
+ if !items[i].lastActivityAt.Equal(items[j].lastActivityAt) {
+ return items[i].lastActivityAt.After(items[j].lastActivityAt)
+ }
+ if !items[i].createdAt.Equal(items[j].createdAt) {
+ return items[i].createdAt.Before(items[j].createdAt)
+ }
+ return items[i].taskID < items[j].taskID
})
result := make([]map[string]any, 0, len(items))
for _, item := range items {
@@ -406,7 +416,7 @@ func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID st
const pageSize = 1000
var all []*taskmodels.Task
for page := 1; ; page++ {
- tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, "", false, false, true, true)
+ tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, "", false, false, true, false)
if err != nil {
return nil, err
}
@@ -418,8 +428,17 @@ func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID st
}
type quickChatBootSession struct {
- state map[string]any
- createdAt time.Time
+ state map[string]any
+ taskID string
+ createdAt time.Time
+ lastActivityAt time.Time
+}
+
+func quickChatLastActivityAt(task *taskmodels.Task, primary *taskmodels.TaskSession) time.Time {
+ if primary.UpdatedAt.After(task.UpdatedAt) {
+ return primary.UpdatedAt
+ }
+ return task.UpdatedAt
}
type quickChatBootState struct {
@@ -474,6 +493,7 @@ func mapQuickChatSessionState(task *taskmodels.Task, primary *taskmodels.TaskSes
state := map[string]any{
bootStateKeySessionID: primary.ID,
bootStateKeyWorkspaceID: task.WorkspaceID,
+ "kind": quickChatSessionKind(task),
}
if task.Title != "" && task.Title != "Quick Chat" {
state["name"] = task.Title
@@ -484,6 +504,15 @@ func mapQuickChatSessionState(task *taskmodels.Task, primary *taskmodels.TaskSes
return state
}
+func quickChatSessionKind(task *taskmodels.Task) string {
+ if task != nil {
+ if configMode, ok := task.Metadata["config_mode"].(bool); ok && configMode {
+ return quickChatSessionKindConfig
+ }
+ }
+ return quickChatSessionKindChat
+}
+
func quickChatAgentProfileID(task *taskmodels.Task, primary *taskmodels.TaskSession) string {
if task != nil {
if value, ok := task.Metadata[taskmodels.MetaKeyAgentProfileID].(string); ok {
diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go
index 51c2a4edd4..6185fce2e4 100644
--- a/apps/backend/internal/backendapp/helpers_test.go
+++ b/apps/backend/internal/backendapp/helpers_test.go
@@ -996,6 +996,9 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-qc", Name: "Quick Chats"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
+ if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-other", Name: "Other"}); err != nil {
+ t.Fatalf("CreateWorkspace(other): %v", err)
+ }
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-qc", WorkspaceID: "ws-qc", Name: "Workflow"}); err != nil {
t.Fatalf("CreateWorkflow: %v", err)
}
@@ -1012,6 +1015,18 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
id: "task-config", title: "Config", updatedAt: base, sessionUpdatedAt: base,
agentProfileID: "agent-config", metadata: map[string]interface{}{"config_mode": true},
})
+ seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
+ id: "task-archived", title: "Archived", updatedAt: base, sessionUpdatedAt: base,
+ agentProfileID: "agent-archived", archived: true,
+ })
+ seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
+ id: "task-no-primary", title: "No Primary", updatedAt: base,
+ agentProfileID: "agent-no-primary", withoutPrimary: true,
+ })
+ seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
+ id: "task-other-workspace", workspaceID: "ws-other", title: "Foreign", updatedAt: base,
+ sessionUpdatedAt: base, agentProfileID: "agent-foreign",
+ })
seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
id: "task-automation", title: "Automation", updatedAt: base, sessionUpdatedAt: base,
agentProfileID: "agent-automation", origin: models.TaskOriginAutomationRun,
@@ -1040,8 +1055,10 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
WorkspaceID string `json:"workspaceId"`
Name string `json:"name"`
AgentProfileID string `json:"agentProfileId"`
+ Kind string `json:"kind"`
} `json:"sessions"`
- IsOpen bool `json:"isOpen"`
+ IsOpen bool `json:"isOpen"`
+ ActiveSessionID *string `json:"activeSessionId"`
} `json:"quickChat"`
TaskSessions struct {
Items map[string]struct {
@@ -1055,21 +1072,33 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
}
sessions := decoded.InitialState.QuickChat.Sessions
- if len(sessions) != 2 {
- t.Fatalf("quickChat sessions = %#v, want 2 restored sessions", sessions)
+ if len(sessions) != 3 {
+ t.Fatalf("quickChat sessions = %#v, want 3 restored sessions", sessions)
}
- if got := sessions[0].SessionID; got != "task-old-session" {
- t.Fatalf("first restored session = %q, want first-created task-old-session", got)
+ if got := sessions[0].SessionID; got != "task-config-session" {
+ t.Fatalf("first restored session = %q, want newest task-config-session", got)
}
- if sessions[0].AgentProfileID != "agent-old" || sessions[1].AgentProfileID != "agent-new" {
+ if sessions[0].Kind != "config" || sessions[1].Kind != "chat" || sessions[2].Kind != "chat" {
+ t.Fatalf("restored session kinds = %#v, want config, chat, chat", sessions)
+ }
+ if sessions[0].WorkspaceID != "ws-qc" || sessions[0].Name != "Config" {
+ t.Fatalf("config session identity = %#v, want workspace and task title preserved", sessions[0])
+ }
+ if sessions[0].AgentProfileID != "agent-config" || sessions[1].AgentProfileID != "agent-new" || sessions[2].AgentProfileID != "agent-old" {
t.Fatalf("agent profile IDs = %#v", sessions)
}
- if got := decoded.InitialState.TaskSessions.Items["task-new-session"].TaskID; got != "task-new" {
- t.Fatalf("quick chat task session task_id = %q, want task-new", got)
+ if got := decoded.InitialState.TaskSessions.Items["task-config-session"].TaskID; got != "task-config" {
+ t.Fatalf("config chat task session task_id = %q, want task-config", got)
+ }
+ if len(decoded.InitialState.TaskSessions.Items) != 3 {
+ t.Fatalf("taskSessions items = %#v, want only the 3 restored primary sessions", decoded.InitialState.TaskSessions.Items)
}
if decoded.InitialState.QuickChat.IsOpen {
t.Fatal("quick chat should hydrate closed")
}
+ if decoded.InitialState.QuickChat.ActiveSessionID != nil {
+ t.Fatalf("quick chat active session = %q, want nil", *decoded.InitialState.QuickChat.ActiveSessionID)
+ }
}
func TestBootPayloadRestoresQuickChatsFromTaskRouteWorkspace(t *testing.T) {
@@ -1133,8 +1162,8 @@ func TestBootPayloadRestoresQuickChatsFromTaskRouteWorkspace(t *testing.T) {
if len(sessions) != 2 {
t.Fatalf("quickChat sessions = %#v, want 2 task-workspace sessions", sessions)
}
- if sessions[0].SessionID != "task-route-first-session" || sessions[1].SessionID != "task-route-second-session" {
- t.Fatalf("quickChat sessions = %#v, want task-workspace creation order", sessions)
+ if sessions[0].SessionID != "task-route-second-session" || sessions[1].SessionID != "task-route-first-session" {
+ t.Fatalf("quickChat sessions = %#v, want task-workspace activity order", sessions)
}
for _, session := range sessions {
if session.WorkspaceID != "ws-task" {
@@ -1155,6 +1184,8 @@ type bootQuickChatFixture struct {
metadata map[string]interface{}
updatedAt time.Time
sessionUpdatedAt time.Time
+ archived bool
+ withoutPrimary bool
}
func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx context.Context, f bootQuickChatFixture) {
@@ -1186,6 +1217,14 @@ func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx co
); err != nil {
t.Fatalf("backdate task(%s): %v", f.id, err)
}
+ if f.archived {
+ if _, err := repo.DB().ExecContext(ctx, `UPDATE tasks SET archived_at = ? WHERE id = ?`, f.updatedAt, f.id); err != nil {
+ t.Fatalf("archive task(%s): %v", f.id, err)
+ }
+ }
+ if f.withoutPrimary {
+ return
+ }
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: f.id + "-session",
TaskID: f.id,
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 56692f7b3c..2f19cf0b0c 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -73,6 +73,7 @@ function mapQuickChatSessions(tasks: QuickChatTask[]): AppState["quickChat"]["se
// sorts by max(task/session updated_at) and is the authoritative SPA path.
.sort((a, b) => quickChatUpdatedAt(b) - quickChatUpdatedAt(a))
.map((task) => ({
+ kind: "chat" as const,
sessionId: task.primary_session_id!,
workspaceId: task.workspace_id,
name: task.title !== "Quick Chat" ? task.title : undefined,
diff --git a/apps/web/components/config-chat/config-chat-panel.tsx b/apps/web/components/config-chat/config-chat-panel.tsx
index 3388c8c209..5e1a01aa32 100644
--- a/apps/web/components/config-chat/config-chat-panel.tsx
+++ b/apps/web/components/config-chat/config-chat-panel.tsx
@@ -1,327 +1,162 @@
"use client";
-import { memo, useEffect, useRef, useState } from "react";
-import {
- IconLoader2,
- IconMessageCircle,
- IconPlus,
- IconSend2,
- IconSparkles,
- IconX,
-} from "@tabler/icons-react";
-import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
+import { memo, useCallback, useMemo, useState } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { IconArrowsMaximize, IconSparkles, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
-import { Textarea } from "@kandev/ui/textarea";
-import { QuickChatContent } from "@/components/quick-chat/quick-chat-content";
import { useAppStore } from "@/components/state-provider";
+import { QuickChatSessionView } from "@/components/quick-chat/quick-chat-session-view";
+import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
+import { ConfigChatSetup } from "./config-chat-setup";
import { useConfigChat } from "./use-config-chat";
-import type { ConfigChatSession } from "@/lib/state/slices/ui/types";
-const SUGGESTION_PROMPTS = [
- "Add a 'Code Review' step to my workflow",
- "Create a new agent profile with auto-approve enabled",
- "Show me the current workflow configuration",
- "Update the MCP servers for the default agent profile",
-];
-
-function SuggestionList() {
- return (
-
-
Try asking
- {SUGGESTION_PROMPTS.map((prompt) => (
-
- {prompt}
-
- ))}
-
+function useConfigChatPanelStore() {
+ return useAppStore(
+ useShallow((state) => ({
+ quickChatSessions: state.quickChat.sessions,
+ openQuickChat: state.openQuickChat,
+ setQuickChatInitialPrompt: state.setQuickChatInitialPrompt,
+ })),
);
}
-function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) {
- const profiles = useAppStore((s) => s.agentProfiles.items ?? []);
+function PanelHeader({ onExpand, onClose }: { onExpand: () => void; onClose: () => void }) {
return (
-
-
Select an agent profile
-
- {profiles.map((profile) => (
-
- ))}
+
- );
-}
-
-function ConfigChatTabs({
- sessions,
- activeSessionId,
- onTabChange,
- onTabClose,
- onNewChat,
-}: {
- sessions: ConfigChatSession[];
- activeSessionId: string | null;
- onTabChange: (sessionId: string) => void;
- onTabClose: (sessionId: string) => void;
- onNewChat: () => void;
-}) {
- if (sessions.length === 0) return null;
-
- return (
-
-
- {sessions.map((s, index) => {
- const isActive = s.sessionId === activeSessionId;
- const tabName = s.name || `Chat ${index + 1}`;
- return (
-
-
-
-
- );
- })}
-
-
-
- );
-}
-
-function LoadingState() {
- return (
-
-
-
Starting config chat...
-
- );
-}
-
-function ConfigChatEmptyState({
- defaultProfileId,
- onSelectPrompt,
- isStarting,
- error,
-}: {
- defaultProfileId: string | undefined;
- onSelectPrompt: (prompt: string, profileId: string) => void;
- isStarting: boolean;
- error: string | null;
-}) {
- const profileCount = useAppStore((s) => s.agentProfiles.items?.length ?? 0);
- const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId ?? "");
- const [inputValue, setInputValue] = useState("");
-
- const textareaRef = useRef
(null);
- const needsProfileSelection = !selectedProfileId && !defaultProfileId && profileCount > 0;
- const effectiveProfileId = selectedProfileId || defaultProfileId || "";
- const canSubmit = inputValue.trim().length > 0 && !!effectiveProfileId && !isStarting;
-
- useEffect(() => {
- if (!needsProfileSelection && !isStarting && textareaRef.current) {
- textareaRef.current.focus();
- }
- }, [needsProfileSelection, isStarting]);
-
- const handleSubmit = () => {
- const trimmed = inputValue.trim();
- if (!trimmed || !effectiveProfileId) return;
- onSelectPrompt(trimmed, effectiveProfileId);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- };
-
- if (isStarting) return ;
-
- return (
-
-
-
-
Configure Kandev with AI
-
- Manage workflows, agent profiles, and MCP configuration.
-
-
-
- {needsProfileSelection ? (
-
- ) : (
- <>
- {inputValue.length === 0 ?
:
}
-
-
+
+
Open in Quick Chat
+
+
+
+
);
}
-type ConfigChatPanelProps = {
- workspaceId: string;
- showFab?: boolean;
-};
+function useConfigChatPanelController(workspaceId: string) {
+ const chat = useConfigChat(workspaceId);
+ const store = useConfigChatPanelStore();
+ const [isOpen, setIsOpen] = useState(false);
+ const session = useMemo(
+ () =>
+ store.quickChatSessions.find(
+ (item) =>
+ item.workspaceId === workspaceId &&
+ item.kind === "config" &&
+ !isQuickChatSetupSessionId(item.sessionId),
+ ),
+ [store.quickChatSessions, workspaceId],
+ );
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) chat.reset();
+ setIsOpen(open);
+ },
+ [chat.reset],
+ );
+
+ const handleStart = useCallback(
+ (profileId: string, prompt: string) =>
+ chat.startSession(profileId, prompt, { openInQuickChat: false }),
+ [chat.startSession],
+ );
+
+ const handleExpand = useCallback(() => {
+ chat.reset();
+ if (session) {
+ store.openQuickChat(session.sessionId, workspaceId, session.agentProfileId, "config");
+ } else {
+ store.openQuickChat("", workspaceId, undefined, "config");
+ }
+ setIsOpen(false);
+ }, [chat.reset, session, store, workspaceId]);
+
+ return {
+ ...chat,
+ ...store,
+ session,
+ isOpen,
+ handleOpenChange,
+ handleStart,
+ handleExpand,
+ };
+}
export const ConfigChatPanel = memo(function ConfigChatPanel({
workspaceId,
- showFab = true,
-}: ConfigChatPanelProps) {
- const chat = useConfigChat(workspaceId);
- const showEmptyState = !chat.activeSessionId;
+}: {
+ workspaceId: string;
+}) {
+ const panel = useConfigChatPanelController(workspaceId);
return (
- {
- if (nextOpen) {
- chat.open();
- } else {
- chat.close();
- }
- }}
- >
- {showFab ? (
-
-
-
-
-
-
-
- Configuration Chat
- Configure Kandev with natural language
-
-
- ) : (
-
-
-
- )}
+
+
+
+
+
+
+
+
+ Configuration Chat
+ Configure Kandev with natural language
+
+
e.preventDefault()}
- className="w-[420px] max-h-[550px] h-[550px] p-0 gap-0 flex flex-col shadow-2xl"
+ onInteractOutside={(event) => event.preventDefault()}
+ data-testid="config-chat-popover"
+ className="flex h-[min(550px,calc(100dvh-6rem))] w-[min(420px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0 shadow-2xl"
>
-
-
-
- Configuration Chat
-
-
-
-
- {showEmptyState ? (
- chat.startSession(profileId, prompt)}
- isStarting={chat.isStarting}
- error={chat.error}
+ panel.handleOpenChange(false)} />
+ {panel.session ? (
+
+ panel.setQuickChatInitialPrompt(panel.session!.sessionId, undefined)
+ }
/>
) : (
-
)}
diff --git a/apps/web/components/config-chat/config-chat-provider.tsx b/apps/web/components/config-chat/config-chat-provider.tsx
index da1d8a7ab1..800922fdfd 100644
--- a/apps/web/components/config-chat/config-chat-provider.tsx
+++ b/apps/web/components/config-chat/config-chat-provider.tsx
@@ -17,9 +17,8 @@ function useIsMounted() {
/**
* Global provider for Config Chat functionality.
- * Renders the floating popover that can be opened from anywhere in the app.
- * The FAB trigger is only visible on settings pages; on other pages the popover
- * can be opened via the command panel (Cmd+K → "Configuration Chat").
+ * Renders the Settings FAB and floating configuration chat.
+ * Other pages use the command panel (Cmd+K -> "Configuration Chat").
*/
export function ConfigChatProvider({ children }: { children: React.ReactNode }) {
const activeWorkspace = useAppStore((s) => s.workspaces.activeId);
@@ -33,8 +32,8 @@ export function ConfigChatProvider({ children }: { children: React.ReactNode })
return (
<>
{children}
- {mounted && activeWorkspace && (
-
+ {mounted && activeWorkspace && isSettingsPage && (
+
)}
>
);
diff --git a/apps/web/components/config-chat/config-chat-setup.test.tsx b/apps/web/components/config-chat/config-chat-setup.test.tsx
new file mode 100644
index 0000000000..3c0cac49cc
--- /dev/null
+++ b/apps/web/components/config-chat/config-chat-setup.test.tsx
@@ -0,0 +1,110 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ConfigChatSetup } from "./config-chat-setup";
+
+const defaultProfiles = [
+ { id: "profile-config", label: "Config Agent", agent_name: "codex" },
+ { id: "profile-other", label: "Other Agent", agent_name: "claude" },
+];
+const profiles = [...defaultProfiles];
+const configPromptPlaceholder = "Ask anything about your configuration...";
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({ agentProfiles: { items: profiles } }),
+}));
+
+afterEach(() => {
+ cleanup();
+ profiles.splice(0, profiles.length, ...defaultProfiles);
+});
+
+describe("ConfigChatSetup", () => {
+ it("can switch a modal setup back to an ordinary quick chat", () => {
+ const onKindChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("switch", { name: "Configuration chat" }));
+
+ expect(onKindChange).toHaveBeenCalledWith("chat");
+ });
+
+ it("shows config-specific guidance and suggestions without repository controls", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("heading", { name: "Configuration Chat" })).toBeTruthy();
+ expect(
+ screen.getByText(/manage workflows, agent profiles, and MCP configuration/i),
+ ).toBeTruthy();
+ expect(screen.getByPlaceholderText(configPromptPlaceholder)).toBeTruthy();
+ expect(screen.queryByText(/repositories/i)).toBeNull();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Show me the current workflow configuration" }),
+ );
+ expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
+ "Show me the current workflow configuration",
+ );
+ });
+
+ it("requires a configuration profile before showing the prompt composer", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByPlaceholderText(configPromptPlaceholder)).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: /Config Agent codex/i }));
+ expect(screen.getByPlaceholderText(configPromptPlaceholder)).toBeTruthy();
+ });
+
+ it("hides suggestions and the prompt composer when no profiles are available", () => {
+ profiles.splice(0, profiles.length);
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/No agent profiles are available/i)).toBeTruthy();
+ expect(screen.queryByPlaceholderText(configPromptPlaceholder)).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Show me the current workflow configuration" }),
+ ).toBeNull();
+ });
+
+ it("ignores repeated and composing Enter keydowns", () => {
+ const onStart = vi.fn();
+ render(
+ ,
+ );
+ const input = screen.getByRole("textbox");
+ fireEvent.change(input, { target: { value: "Update my workflow" } });
+
+ fireEvent.keyDown(input, { key: "Enter", repeat: true });
+ fireEvent.keyDown(input, { key: "Enter", keyCode: 229, isComposing: true });
+
+ expect(onStart).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/components/config-chat/config-chat-setup.tsx b/apps/web/components/config-chat/config-chat-setup.tsx
new file mode 100644
index 0000000000..45d13d811f
--- /dev/null
+++ b/apps/web/components/config-chat/config-chat-setup.tsx
@@ -0,0 +1,370 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { IconLoader2, IconMessageCircle, IconSend2, IconSparkles } from "@tabler/icons-react";
+import { Button } from "@kandev/ui/button";
+import { Textarea } from "@kandev/ui/textarea";
+import { useAppStore } from "@/components/state-provider";
+import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
+import { ConfigurationChatToggle } from "@/components/quick-chat/configuration-chat-toggle";
+
+const SUGGESTION_PROMPTS = [
+ "Add a 'Code Review' step to my workflow",
+ "Create a new agent profile with auto-approve enabled",
+ "Show me the current workflow configuration",
+ "Update the MCP servers for the default agent profile",
+];
+
+type ConfigChatSetupBaseProps = {
+ defaultProfileId?: string;
+ isStarting: boolean;
+ error: string | null;
+ onStart: (profileId: string, prompt: string) => void;
+ onKindChange?: (kind: QuickChatSessionKind) => void;
+};
+
+type ConfigChatSetupProps = ConfigChatSetupBaseProps &
+ (
+ | { presentation: "floating"; onCancel?: never }
+ | { presentation?: "dialog"; onCancel: () => void }
+ );
+
+function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) {
+ const profiles = useAppStore((state) => state.agentProfiles.items ?? []);
+ return (
+
+
+
+ Configuration agent profile
+
+
+ Choose the agent with access to configuration tools. This becomes the workspace default.
+
+
+
+ {profiles.map((profile) => (
+
+ ))}
+
+
+ );
+}
+
+function Suggestions({ onSelect }: { onSelect: (prompt: string) => void }) {
+ return (
+
+
+ Try asking
+
+
+ {SUGGESTION_PROMPTS.map((prompt) => (
+
+ ))}
+
+
+ );
+}
+
+function ConfigChatFooter({
+ onCancel,
+ onStart,
+ disabled,
+ startDisabled,
+ isStarting,
+}: {
+ onCancel: () => void;
+ onStart: () => void;
+ disabled: boolean;
+ startDisabled: boolean;
+ isStarting: boolean;
+}) {
+ return (
+
+ );
+}
+
+type ConfigPromptProps = {
+ value: string;
+ error: string | null;
+ disabled: boolean;
+ canSubmit: boolean;
+ isStarting: boolean;
+ presentation: "dialog" | "floating";
+ textareaRef: React.RefObject;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+};
+
+function ConfigPrompt({
+ value,
+ error,
+ disabled,
+ canSubmit,
+ isStarting,
+ presentation,
+ textareaRef,
+ onChange,
+ onSubmit,
+}: ConfigPromptProps) {
+ return (
+
+
+
+ What would you like to configure?
+
+
+
+ {error && {error}
}
+
+
+ );
+}
+
+function ConfigGuidance({
+ presentation,
+ hasProfiles,
+ needsProfileSelection,
+ isStarting,
+ onSelectProfile,
+ onSelectSuggestion,
+ onKindChange,
+}: {
+ presentation: "dialog" | "floating";
+ hasProfiles: boolean;
+ needsProfileSelection: boolean;
+ isStarting: boolean;
+ onSelectProfile: (profileId: string) => void;
+ onSelectSuggestion: (prompt: string) => void;
+ onKindChange?: (kind: QuickChatSessionKind) => void;
+}) {
+ return (
+
+
+ {presentation === "dialog" && (
+ <>
+
+ {onKindChange && (
+
!checked && onKindChange("chat")}
+ />
+ )}
+ >
+ )}
+
+ {!hasProfiles && (
+
+ No agent profiles are available. Create one in Agent settings first.
+
+ )}
+
+ {hasProfiles && needsProfileSelection && }
+
+ {hasProfiles && !needsProfileSelection && }
+
+
+ );
+}
+
+function ConfigSetupActions({
+ showPrompt,
+ value,
+ error,
+ profileIsResolved,
+ canSubmit,
+ isStarting,
+ presentation,
+ onCancel,
+ textareaRef,
+ onChange,
+ onSubmit,
+}: {
+ showPrompt: boolean;
+ value: string;
+ error: string | null;
+ profileIsResolved: boolean;
+ canSubmit: boolean;
+ isStarting: boolean;
+ presentation: "dialog" | "floating";
+ onCancel?: () => void;
+ textareaRef: React.RefObject;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+}) {
+ return (
+ <>
+ {showPrompt && (
+
+ )}
+ {presentation === "dialog" && onCancel && (
+
+ )}
+ >
+ );
+}
+
+export function ConfigChatSetup({
+ presentation = "dialog",
+ defaultProfileId,
+ isStarting,
+ error,
+ onStart,
+ onCancel,
+ onKindChange,
+}: ConfigChatSetupProps) {
+ const profiles = useAppStore((state) => state.agentProfiles.items ?? []);
+ const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId ?? "");
+ const [inputValue, setInputValue] = useState("");
+ const textareaRef = useRef(null);
+ useEffect(() => {
+ if (!defaultProfileId) return;
+ setSelectedProfileId((current) => current || defaultProfileId);
+ }, [defaultProfileId]);
+
+ const effectiveProfileId = selectedProfileId || defaultProfileId || "";
+ const profileIsResolved = profiles.some((profile) => profile.id === effectiveProfileId);
+ const needsProfileSelection = profiles.length > 0 && !profileIsResolved;
+ const canSubmit = inputValue.trim().length > 0 && profileIsResolved && !isStarting;
+
+ useEffect(() => {
+ if (!needsProfileSelection && !isStarting) textareaRef.current?.focus();
+ }, [isStarting, needsProfileSelection]);
+
+ const handleSubmit = () => {
+ const prompt = inputValue.trim();
+ if (!prompt || !effectiveProfileId || isStarting) return;
+ onStart(effectiveProfileId, prompt);
+ };
+
+ return (
+
+ 0}
+ needsProfileSelection={needsProfileSelection}
+ isStarting={isStarting}
+ onSelectProfile={setSelectedProfileId}
+ onSelectSuggestion={setInputValue}
+ onKindChange={onKindChange}
+ />
+ 0 && !needsProfileSelection}
+ value={inputValue}
+ error={error}
+ profileIsResolved={profileIsResolved}
+ canSubmit={canSubmit}
+ isStarting={isStarting}
+ presentation={presentation}
+ onCancel={onCancel}
+ textareaRef={textareaRef}
+ onChange={setInputValue}
+ onSubmit={handleSubmit}
+ />
+
+ );
+}
diff --git a/apps/web/components/config-chat/use-config-chat.test.ts b/apps/web/components/config-chat/use-config-chat.test.ts
new file mode 100644
index 0000000000..67f0a7db2b
--- /dev/null
+++ b/apps/web/components/config-chat/use-config-chat.test.ts
@@ -0,0 +1,196 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const startConfigChat = vi.fn();
+const setTaskSession = vi.fn();
+const openQuickChat = vi.fn();
+const addQuickChatSession = vi.fn();
+const closeQuickChatSession = vi.fn();
+const renameQuickChatSession = vi.fn();
+const setQuickChatInitialPrompt = vi.fn();
+const deleteTask = vi.fn();
+const WORKSPACE_ID = "workspace-1";
+const CONFIG_PROFILE_ID = "profile-config";
+const PASSTHROUGH_PROFILE_ID = "profile-passthrough";
+const SESSION_ID = "session-config";
+const TASK_ID = "task-config";
+const PROMPT = "Show current workflows";
+
+const appState = {
+ openQuickChat,
+ addQuickChatSession,
+ closeQuickChatSession,
+ renameQuickChatSession,
+ setQuickChatInitialPrompt,
+ setTaskSession,
+ agentProfiles: {
+ items: [
+ { id: CONFIG_PROFILE_ID, cli_passthrough: false },
+ { id: PASSTHROUGH_PROFILE_ID, cli_passthrough: true },
+ ],
+ },
+ workspaces: {
+ items: [
+ {
+ id: WORKSPACE_ID,
+ default_config_agent_profile_id: CONFIG_PROFILE_ID,
+ default_agent_profile_id: "profile-default",
+ },
+ ],
+ },
+};
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: typeof appState) => unknown) => selector(appState),
+ useAppStoreApi: () => ({ getState: () => appState }),
+}));
+
+vi.mock("@/lib/api/domains/workspace-api", () => ({
+ startConfigChat: (...args: unknown[]) => startConfigChat(...args),
+}));
+
+vi.mock("@/app/actions/workspaces", () => ({ updateWorkspaceAction: vi.fn() }));
+
+import { useConfigChat } from "./use-config-chat";
+import { getQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ appState.agentProfiles.items = [
+ { id: CONFIG_PROFILE_ID, cli_passthrough: false },
+ { id: PASSTHROUGH_PROFILE_ID, cli_passthrough: true },
+ ];
+ startConfigChat.mockResolvedValue({ task_id: TASK_ID, session_id: SESSION_ID });
+});
+
+vi.mock("@/lib/api/domains/kanban-api", () => ({
+ deleteTask: (...args: unknown[]) => deleteTask(...args),
+}));
+
+describe("useConfigChat unified launch", () => {
+ it("seeds and opens a typed Quick Chat session with one pending initial prompt", async () => {
+ const { result } = renderHook(() => useConfigChat(WORKSPACE_ID));
+
+ await act(async () => {
+ await result.current.startSession(CONFIG_PROFILE_ID, PROMPT);
+ });
+
+ expect(startConfigChat).toHaveBeenCalledWith(WORKSPACE_ID, {
+ agent_profile_id: CONFIG_PROFILE_ID,
+ });
+ expect(setTaskSession).toHaveBeenCalledWith(
+ expect.objectContaining({ id: SESSION_ID, task_id: TASK_ID }),
+ );
+ expect(closeQuickChatSession).toHaveBeenCalledWith(
+ getQuickChatSetupSessionId(WORKSPACE_ID, "config"),
+ );
+ expect(openQuickChat).toHaveBeenCalledWith(
+ SESSION_ID,
+ WORKSPACE_ID,
+ CONFIG_PROFILE_ID,
+ "config",
+ );
+ expect(renameQuickChatSession).toHaveBeenCalledWith(SESSION_ID, PROMPT);
+ expect(setQuickChatInitialPrompt).toHaveBeenCalledWith(SESSION_ID, PROMPT);
+ });
+
+ it("registers a floating configuration session without opening the large dialog", async () => {
+ const { result } = renderHook(() => useConfigChat(WORKSPACE_ID));
+
+ await act(async () => {
+ await result.current.startSession(CONFIG_PROFILE_ID, PROMPT, { openInQuickChat: false });
+ });
+
+ expect(addQuickChatSession).toHaveBeenCalledWith(
+ SESSION_ID,
+ WORKSPACE_ID,
+ CONFIG_PROFILE_ID,
+ "config",
+ );
+ expect(openQuickChat).not.toHaveBeenCalled();
+ expect(setQuickChatInitialPrompt).toHaveBeenCalledWith(SESSION_ID, PROMPT);
+ });
+
+ it("starts a passthrough profile with its prompt instead of stranding it outside the terminal", async () => {
+ const { result } = renderHook(() => useConfigChat(WORKSPACE_ID));
+
+ await act(async () => {
+ await result.current.startSession(PASSTHROUGH_PROFILE_ID, PROMPT);
+ });
+
+ expect(startConfigChat).toHaveBeenCalledWith(WORKSPACE_ID, {
+ agent_profile_id: PASSTHROUGH_PROFILE_ID,
+ prompt: PROMPT,
+ });
+ expect(setQuickChatInitialPrompt).not.toHaveBeenCalled();
+ });
+
+ it("waits for the selected profile before deciding how to deliver the prompt", async () => {
+ appState.agentProfiles.items = [];
+ const { result } = renderHook(() => useConfigChat(WORKSPACE_ID));
+
+ await act(async () => {
+ await result.current.startSession(PASSTHROUGH_PROFILE_ID, PROMPT);
+ });
+
+ expect(startConfigChat).not.toHaveBeenCalled();
+ expect(result.current.error).toMatch(/profile/i);
+ });
+
+ it("deletes a task that resolves after the config start is superseded", async () => {
+ let resolveStart!: (value: { task_id: string; session_id: string }) => void;
+ startConfigChat.mockImplementationOnce(
+ () =>
+ new Promise<{ task_id: string; session_id: string }>((resolve) => {
+ resolveStart = resolve;
+ }),
+ );
+ deleteTask.mockResolvedValue(undefined);
+ const { result } = renderHook(() => useConfigChat(WORKSPACE_ID));
+
+ act(() => {
+ void result.current.startSession(CONFIG_PROFILE_ID, PROMPT);
+ });
+ act(() => result.current.reset());
+ await act(async () => {
+ resolveStart({ task_id: TASK_ID, session_id: SESSION_ID });
+ await Promise.resolve();
+ });
+
+ expect(deleteTask).toHaveBeenCalledWith(TASK_ID);
+ expect(openQuickChat).not.toHaveBeenCalled();
+ expect(setTaskSession).not.toHaveBeenCalled();
+ });
+});
+
+describe("useConfigChat launch serialization", () => {
+ it("serializes config starts across hook instances in the same workspace", async () => {
+ let resolveStart!: (value: { task_id: string; session_id: string }) => void;
+ startConfigChat.mockImplementationOnce(
+ () =>
+ new Promise<{ task_id: string; session_id: string }>((resolve) => {
+ resolveStart = resolve;
+ }),
+ );
+ const first = renderHook(() => useConfigChat(WORKSPACE_ID));
+ const second = renderHook(() => useConfigChat(WORKSPACE_ID));
+ let firstStart!: Promise;
+
+ act(() => {
+ firstStart = first.result.current.startSession(CONFIG_PROFILE_ID, PROMPT);
+ });
+ await act(async () => {
+ await second.result.current.startSession(CONFIG_PROFILE_ID, PROMPT);
+ });
+
+ expect(startConfigChat).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ resolveStart({ task_id: TASK_ID, session_id: SESSION_ID });
+ await firstStart;
+ await second.result.current.startSession(CONFIG_PROFILE_ID, PROMPT);
+ });
+
+ expect(startConfigChat).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/web/components/config-chat/use-config-chat.ts b/apps/web/components/config-chat/use-config-chat.ts
index 063f91a615..17c365680a 100644
--- a/apps/web/components/config-chat/use-config-chat.ts
+++ b/apps/web/components/config-chat/use-config-chat.ts
@@ -1,150 +1,212 @@
"use client";
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
-import { startConfigChat } from "@/lib/api/domains/workspace-api";
import { updateWorkspaceAction } from "@/app/actions/workspaces";
+import { startConfigChat } from "@/lib/api/domains/workspace-api";
+import { getQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
import {
agentProfileId as toAgentProfileId,
sessionId as toSessionId,
taskId as toTaskId,
} from "@/lib/types/ids";
+type StartConfigChatOptions = {
+ openInQuickChat?: boolean;
+};
+
function useConfigChatStore() {
return useAppStore(
- useShallow((s) => ({
- isOpen: s.configChat.isOpen,
- sessions: s.configChat.sessions,
- activeSessionId: s.configChat.activeSessionId,
- workspaceId: s.configChat.workspaceId,
- openConfigChat: s.openConfigChat,
- startNewConfigChat: s.startNewConfigChat,
- closeConfigChat: s.closeConfigChat,
- closeConfigChatSession: s.closeConfigChatSession,
- setActiveConfigChatSession: s.setActiveConfigChatSession,
- renameConfigChatSession: s.renameConfigChatSession,
+ useShallow((state) => ({
+ openQuickChat: state.openQuickChat,
+ addQuickChatSession: state.addQuickChatSession,
+ closeQuickChatSession: state.closeQuickChatSession,
+ renameQuickChatSession: state.renameQuickChatSession,
+ setQuickChatInitialPrompt: state.setQuickChatInitialPrompt,
})),
);
}
+type ConfigChatStore = ReturnType;
+type AppStoreApi = ReturnType;
+type StartedConfigChat = Awaited>;
+
+const activeWorkspaceStarts = new Map();
+
+type RegisterStartedSessionParams = {
+ store: ConfigChatStore;
+ storeApi: AppStoreApi;
+ response: StartedConfigChat;
+ workspaceId: string;
+ agentProfileId: string;
+ prompt: string;
+ isPassthrough: boolean;
+ openInQuickChat: boolean;
+};
+
function useUpdateWorkspaceInStore() {
const storeApi = useAppStoreApi();
- return (workspaceId: string, updates: Record) => {
- const { workspaces, setWorkspaces } = storeApi.getState();
- setWorkspaces(workspaces.items.map((w) => (w.id === workspaceId ? { ...w, ...updates } : w)));
- };
+ return useCallback(
+ (workspaceId: string, updates: Record) => {
+ const { workspaces, setWorkspaces } = storeApi.getState();
+ setWorkspaces(workspaces.items.map((w) => (w.id === workspaceId ? { ...w, ...updates } : w)));
+ },
+ [storeApi],
+ );
+}
+
+async function deleteSupersededConfigChatTask(taskId: string) {
+ const { deleteTask } = await import("@/lib/api/domains/kanban-api");
+ deleteTask(taskId).catch((error) =>
+ console.error("Failed to clean up superseded config chat task:", error),
+ );
+}
+
+function registerStartedSession({
+ store,
+ storeApi,
+ response,
+ workspaceId,
+ agentProfileId,
+ prompt,
+ isPassthrough,
+ openInQuickChat,
+}: RegisterStartedSessionParams) {
+ const now = new Date().toISOString();
+ storeApi.getState().setTaskSession({
+ id: toSessionId(response.session_id),
+ task_id: toTaskId(response.task_id),
+ state: "CREATED",
+ started_at: now,
+ updated_at: now,
+ agent_profile_id: toAgentProfileId(agentProfileId),
+ });
+ if (openInQuickChat) {
+ store.closeQuickChatSession(getQuickChatSetupSessionId(workspaceId, "config"));
+ store.openQuickChat(response.session_id, workspaceId, agentProfileId, "config");
+ } else {
+ store.addQuickChatSession(response.session_id, workspaceId, agentProfileId, "config");
+ }
+ store.renameQuickChatSession(response.session_id, prompt.slice(0, 40) || "Config Chat");
+ if (!isPassthrough) store.setQuickChatInitialPrompt(response.session_id, prompt);
+}
+
+async function saveDefaultConfigProfile(
+ workspaceId: string,
+ agentProfileId: string,
+ alreadyConfigured: boolean,
+ updateWorkspaceInStore: (workspaceId: string, updates: Record) => void,
+) {
+ if (alreadyConfigured) return;
+ try {
+ const updates = { default_config_agent_profile_id: agentProfileId };
+ await updateWorkspaceAction(workspaceId, updates);
+ updateWorkspaceInStore(workspaceId, updates);
+ } catch {
+ // The chat is usable even when saving the future default fails.
+ }
}
export function useConfigChat(workspaceId: string) {
const store = useConfigChatStore();
const storeApi = useAppStoreApi();
+ const updateWorkspaceInStore = useUpdateWorkspaceInStore();
const [isStarting, setIsStarting] = useState(false);
const [error, setError] = useState(null);
- const [pendingPrompt, setPendingPrompt] = useState(null);
- const updateWorkspaceInStore = useUpdateWorkspaceInStore();
-
+ const latestRequestId = useRef(0);
+ const activeRequestId = useRef(null);
const workspace = useAppStore(
- (s) => s.workspaces.items.find((w) => w.id === workspaceId) ?? null,
+ (state) => state.workspaces.items.find((item) => item.id === workspaceId) ?? null,
);
-
const defaultProfileId =
workspace?.default_config_agent_profile_id ?? workspace?.default_agent_profile_id ?? undefined;
- const open = useCallback(() => {
- if (store.activeSessionId && store.workspaceId === workspaceId) {
- store.openConfigChat(store.activeSessionId, workspaceId);
- return;
- }
- store.startNewConfigChat(workspaceId);
- }, [workspaceId, store]);
+ const reset = useCallback(() => {
+ latestRequestId.current += 1;
+ activeRequestId.current = null;
+ setIsStarting(false);
+ setError(null);
+ }, []);
const startSession = useCallback(
- async (agentProfileId: string, prompt?: string) => {
- if (isStarting) return;
+ async (
+ agentProfileId: string,
+ prompt: string,
+ options: StartConfigChatOptions = {},
+ ): Promise => {
+ if (activeRequestId.current !== null) return undefined;
+ const profile = storeApi
+ .getState()
+ .agentProfiles.items.find((item) => item.id === agentProfileId);
+ if (!profile) {
+ setError("The selected agent profile is not available yet. Try again shortly.");
+ return undefined;
+ }
+ if (activeWorkspaceStarts.has(workspaceId)) return undefined;
+ const requestId = ++latestRequestId.current;
+ const workspaceStart = Symbol(workspaceId);
+ activeRequestId.current = requestId;
+ activeWorkspaceStarts.set(workspaceId, workspaceStart);
setIsStarting(true);
setError(null);
try {
- // Don't send the prompt to the backend — it will be sent via WS
- // message.add by QuickChatContent after the WS subscription is
- // established. This avoids a race condition where the agent completes
- // before the frontend subscribes and events are lost.
+ const isPassthrough = profile.cli_passthrough === true;
+ // ACP chats send through the subscribed shell so a fast turn cannot
+ // finish before WS attaches. Passthrough chats render only a terminal.
const response = await startConfigChat(workspaceId, {
agent_profile_id: agentProfileId,
+ ...(isPassthrough ? { prompt } : {}),
});
-
- // Seed the task session in the main store so QuickChatContent can find it
- // immediately. The WS event will merge on top when it arrives.
- storeApi.getState().setTaskSession({
- id: toSessionId(response.session_id),
- task_id: toTaskId(response.task_id),
- state: "CREATED",
- started_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- agent_profile_id: toAgentProfileId(agentProfileId),
- });
-
- // Store the prompt so QuickChatContent can send it via WS.
- setPendingPrompt(prompt ?? null);
-
- store.openConfigChat(response.session_id, workspaceId);
- store.renameConfigChatSession(response.session_id, prompt?.slice(0, 40) || "Config Chat");
-
- // Save the selected profile as the workspace default for future sessions
- if (!workspace?.default_config_agent_profile_id) {
- try {
- await updateWorkspaceAction(workspaceId, {
- default_config_agent_profile_id: agentProfileId,
- });
- updateWorkspaceInStore(workspaceId, {
- default_config_agent_profile_id: agentProfileId,
- });
- } catch {
- // Non-critical — don't fail the chat start for this
- }
+ if (latestRequestId.current !== requestId) {
+ await deleteSupersededConfigChatTask(response.task_id);
+ return undefined;
}
+ registerStartedSession({
+ store,
+ storeApi,
+ response,
+ workspaceId,
+ agentProfileId,
+ prompt,
+ isPassthrough,
+ openInQuickChat: options.openInQuickChat !== false,
+ });
+ await saveDefaultConfigProfile(
+ workspaceId,
+ agentProfileId,
+ Boolean(workspace?.default_config_agent_profile_id),
+ updateWorkspaceInStore,
+ );
+ return response.session_id;
} catch (err) {
- const message = err instanceof Error ? err.message : "Unknown error";
- setError(message);
+ if (latestRequestId.current !== requestId) return undefined;
+ setError(err instanceof Error ? err.message : "Unknown error");
+ return undefined;
} finally {
- setIsStarting(false);
+ if (activeWorkspaceStarts.get(workspaceId) === workspaceStart) {
+ activeWorkspaceStarts.delete(workspaceId);
+ }
+ if (latestRequestId.current === requestId) {
+ activeRequestId.current = null;
+ setIsStarting(false);
+ }
}
},
[
- workspaceId,
- isStarting,
store,
storeApi,
- workspace?.default_config_agent_profile_id,
updateWorkspaceInStore,
+ workspace?.default_config_agent_profile_id,
+ workspaceId,
],
);
- const close = useCallback(() => {
- store.closeConfigChat();
- }, [store]);
-
- const newChat = useCallback(() => {
- store.startNewConfigChat(workspaceId);
- }, [store, workspaceId]);
-
- const clearPendingPrompt = useCallback(() => setPendingPrompt(null), []);
-
return {
- isOpen: store.isOpen,
- sessions: store.sessions,
- activeSessionId: store.activeSessionId,
isStarting,
error,
- workspace,
defaultProfileId,
- pendingPrompt,
- clearPendingPrompt,
- open,
+ reset,
startSession,
- close,
- closeSession: store.closeConfigChatSession,
- setActiveSession: store.setActiveConfigChatSession,
- newChat,
};
}
diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx
index 6597690e40..82accb1421 100644
--- a/apps/web/components/global-commands.tsx
+++ b/apps/web/components/global-commands.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, useCallback } from "react";
+import { useMemo } from "react";
import { useRouter } from "@/lib/routing/client-router";
import { useTheme } from "@/components/theme/app-theme";
import {
@@ -21,6 +21,7 @@ import { useRegisterCommands } from "@/hooks/use-register-commands";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { useAppShortcuts } from "@/hooks/use-app-shortcuts";
import { useAppStore } from "@/components/state-provider";
+import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import type { CommandItem } from "@/lib/commands/types";
@@ -136,51 +137,8 @@ export function GlobalCommands() {
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
- const quickChatSessions = useAppStore((s) => s.quickChat.sessions);
- const openQuickChat = useAppStore((s) => s.openQuickChat);
- const startNewConfigChat = useAppStore((s) => s.startNewConfigChat);
- const openConfigChat = useAppStore((s) => s.openConfigChat);
- const configChatSessions = useAppStore((s) => s.configChat.sessions);
- const configChatActiveSessionId = useAppStore((s) => s.configChat.activeSessionId);
- const configChatWorkspaceId = useAppStore((s) => s.configChat.workspaceId);
-
- const handleOpenQuickChat = useCallback(() => {
- if (!activeWorkspaceId) {
- return;
- }
-
- // If there's an existing session for this workspace, open it
- const existingSession = quickChatSessions.find((s) => s.workspaceId === activeWorkspaceId);
- if (existingSession) {
- openQuickChat(existingSession.sessionId, activeWorkspaceId);
- } else {
- // Open modal without a session - will show agent picker
- openQuickChat("", activeWorkspaceId);
- }
- }, [activeWorkspaceId, quickChatSessions, openQuickChat]);
-
- const handleOpenConfigChat = useCallback(() => {
- if (!activeWorkspaceId) return;
- // Reuse existing session if one is active for this workspace
- if (configChatActiveSessionId && configChatWorkspaceId === activeWorkspaceId) {
- openConfigChat(configChatActiveSessionId, activeWorkspaceId);
- return;
- }
- // Check for a persisted session for this workspace
- const existingSession = configChatSessions.find((s) => s.workspaceId === activeWorkspaceId);
- if (existingSession) {
- openConfigChat(existingSession.sessionId, activeWorkspaceId);
- return;
- }
- startNewConfigChat(activeWorkspaceId);
- }, [
- activeWorkspaceId,
- configChatSessions,
- configChatActiveSessionId,
- configChatWorkspaceId,
- openConfigChat,
- startNewConfigChat,
- ]);
+ const handleOpenQuickChat = useQuickChatLauncher(activeWorkspaceId);
+ const handleOpenConfigChat = useQuickChatLauncher(activeWorkspaceId, "config");
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts);
diff --git a/apps/web/components/quick-chat/configuration-chat-toggle.tsx b/apps/web/components/quick-chat/configuration-chat-toggle.tsx
new file mode 100644
index 0000000000..565ec1e4d3
--- /dev/null
+++ b/apps/web/components/quick-chat/configuration-chat-toggle.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Switch } from "@kandev/ui/switch";
+
+export function ConfigurationChatToggle({
+ checked,
+ disabled,
+ onCheckedChange,
+}: {
+ checked: boolean;
+ disabled?: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+
+
+ Configuration chat
+
+
+ Let the agent update Kandev settings, workflows, agent profiles, and MCP configuration.
+
+
+
+
+ );
+}
diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx
index 6d85fa7ed5..f0d2292182 100644
--- a/apps/web/components/quick-chat/quick-chat-content.tsx
+++ b/apps/web/components/quick-chat/quick-chat-content.tsx
@@ -1,6 +1,8 @@
"use client";
-import { memo, useCallback, useEffect, useRef, useState } from "react";
+import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
+import { IconChevronDown, IconChevronUp, IconMessageQuestion } from "@tabler/icons-react";
+import { Button } from "@kandev/ui/button";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container";
import { MessageList } from "@/components/task/chat/message-list";
@@ -13,6 +15,7 @@ import {
import { ClarificationInputOverlay } from "@/components/task/chat/clarification-input-overlay";
import { ResizeHandle } from "@/components/task/chat/resize-handle";
import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay";
+import type { Message } from "@/lib/types/http";
type QuickChatContentProps = {
sessionId: string;
@@ -22,6 +25,88 @@ type QuickChatContentProps = {
onInitialPromptSent?: () => void;
};
+type QuickChatClarificationSectionProps = {
+ pending: boolean;
+ messages: readonly Message[] | null | undefined;
+ onResolved: () => void;
+};
+
+function QuickChatClarificationSection({
+ pending,
+ messages,
+ onResolved,
+}: QuickChatClarificationSectionProps) {
+ const [collapsed, setCollapsed] = useState(false);
+ const contentId = useId();
+ const { height, containerRef, resetHeight, resizeHandleProps } =
+ useResizableClarificationOverlay();
+
+ // A newly opened clarification starts expanded and auto-sized. Collapsing
+ // an active one leaves its form state and user-selected height intact.
+ useEffect(() => {
+ if (!pending) {
+ setCollapsed(false);
+ resetHeight();
+ }
+ }, [pending, resetHeight]);
+
+ if (!pending) return null;
+
+ const actionLabel = collapsed ? "Expand clarification" : "Collapse clarification";
+
+ return (
+
+ {!collapsed &&
}
+
+
+
+
+ Clarification needed
+
+
+
+
+
+
+
+
+ );
+}
+
function useQuickChatState(sessionId: string) {
const chatInputRef = useRef(null);
@@ -51,7 +136,7 @@ export const QuickChatContent = memo(function QuickChatContent({
onInitialPromptSent,
}: QuickChatContentProps) {
const [clarificationKey, setClarificationKey] = useState(0);
- const initialPromptSent = useRef(false);
+ const initialPromptSentFor = useRef(null);
const state = useQuickChatState(sessionId);
const { chatInputRef, panelState, isSending, handleSubmit, handleCancelTurn } = state;
const { taskId, pendingClarification, pendingClarificationGroup } = panelState;
@@ -62,25 +147,13 @@ export const QuickChatContent = memo(function QuickChatContent({
}, [chatInputRef]);
useEffect(() => {
- if (!initialPrompt || !taskId || initialPromptSent.current) return;
- initialPromptSent.current = true;
+ if (!initialPrompt || !taskId || initialPromptSentFor.current === sessionId) return;
+ initialPromptSentFor.current = sessionId;
handleSubmit(initialPrompt);
onInitialPromptSent?.();
- }, [initialPrompt, taskId, handleSubmit, onInitialPromptSent]);
+ }, [initialPrompt, taskId, handleSubmit, onInitialPromptSent, sessionId]);
const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []);
- const {
- height: clarificationHeight,
- containerRef: clarificationContainerRef,
- resetHeight: clarificationResetHeight,
- resizeHandleProps: clarificationResizeProps,
- } = useResizableClarificationOverlay();
-
- // Reset the dragged height when the overlay closes so a fresh
- // clarification starts auto-sized instead of inheriting a stale value.
- useEffect(() => {
- if (!pendingClarification) clarificationResetHeight();
- }, [pendingClarification, clarificationResetHeight]);
return (
@@ -99,22 +172,12 @@ export const QuickChatContent = memo(function QuickChatContent({
onOpenFile={undefined}
/>
- {pendingClarification && (
-
- )}
+
{
});
describe("Quick Chat workspace isolation", () => {
- it("replaces a blank setup tab when opening a different workspace", () => {
+ it("keeps typed setup tabs isolated by workspace", () => {
const store = makeStore();
store.getState().openQuickChat("", "ws-a");
store.getState().openQuickChat("", "ws-b");
- expect(store.getState().quickChat.sessions).toEqual([{ sessionId: "", workspaceId: "ws-b" }]);
+ expect(store.getState().quickChat.sessions).toEqual([
+ {
+ sessionId: getQuickChatSetupSessionId("ws-a", "chat"),
+ workspaceId: "ws-a",
+ kind: "chat",
+ },
+ {
+ sessionId: getQuickChatSetupSessionId("ws-b", "chat"),
+ workspaceId: "ws-b",
+ kind: "chat",
+ },
+ ]);
+ expect(store.getState().quickChat.activeSessionId).toBe(
+ getQuickChatSetupSessionId("ws-b", "chat"),
+ );
});
it("falls back to a tab from the closed session workspace", () => {
diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx
index bcac3e0d4f..a57ad053d6 100644
--- a/apps/web/components/quick-chat/quick-chat-modal.tsx
+++ b/apps/web/components/quick-chat/quick-chat-modal.tsx
@@ -1,23 +1,30 @@
"use client";
import { memo, type CSSProperties } from "react";
-import { useShallow } from "zustand/react/shallow";
import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { 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 { QuickChatSessionView } from "./quick-chat-session-view";
import { QuickChatTabItem } from "./quick-chat-tab-item";
import { QuickChatSetup } from "./quick-chat-setup";
import { useQuickChatModal } from "./use-quick-chat-modal";
import { useQuickChatWidth } from "@/hooks/use-quick-chat-width";
+import { ConfigChatSetup } from "@/components/config-chat/config-chat-setup";
+import { useConfigChat } from "@/components/config-chat/use-config-chat";
+import type { QuickChatSession } from "@/lib/state/slices/ui/types";
+import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
type QuickChatModalProps = {
workspaceId: string;
};
+function quickChatTabName(session: QuickChatSession, index: number) {
+ if (!isQuickChatSetupSessionId(session.sessionId)) return session.name || `Chat ${index + 1}`;
+ return session.kind === "config" ? "Configuration Chat" : "New Chat";
+}
+
function QuickChatTabs({
sessions,
activeSessionId,
@@ -27,7 +34,7 @@ function QuickChatTabs({
onRename,
onCloseModal,
}: {
- sessions: Array<{ sessionId: string; workspaceId: string; name?: string }>;
+ sessions: QuickChatSession[];
activeSessionId: string;
onTabChange: (sessionId: string) => void;
onTabClose: (sessionId: string) => void;
@@ -41,14 +48,14 @@ function QuickChatTabs({
{sessions.map((s, index) => {
- // Show "New Chat" for empty session IDs (agent picker tabs)
- const tabName = s.sessionId === "" ? "New Chat" : s.name || `Chat ${index + 1}`;
+ const tabName = quickChatTabName(s, index);
return (
onTabChange(s.sessionId)}
onClose={() => onTabClose(s.sessionId)}
onRename={(name) => onRename(s.sessionId, name)}
@@ -56,13 +63,13 @@ function QuickChatTabs({
);
})}
{/* Touch devices have no Escape key or visible overlay to dismiss the
@@ -70,7 +77,7 @@ function QuickChatTabs({