feat: unify configuration chat sessions (#1695)

This commit is contained in:
Carlos Florêncio
2026-07-18 22:58:18 +01:00
committed by GitHub
parent ae4bf5dd33
commit 8fae44fb12
55 changed files with 2990 additions and 987 deletions
+35 -6
View File
@@ -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 {
@@ -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,
+1
View File
@@ -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,
@@ -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 (
<div className="flex-1 flex flex-col justify-end space-y-1.5 mb-3">
<p className="text-xs text-muted-foreground font-medium">Try asking</p>
{SUGGESTION_PROMPTS.map((prompt) => (
<p key={prompt} className="text-xs text-muted-foreground/70 py-0.5">
{prompt}
</p>
))}
</div>
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 (
<div className="flex-1 flex flex-col">
<p className="text-xs font-medium mb-3">Select an agent profile</p>
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
{profiles.map((profile) => (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile.id)}
className="w-full flex items-center gap-2.5 rounded-md border p-2.5 text-left transition-colors cursor-pointer hover:border-primary/50 hover:bg-accent/50"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
<IconMessageCircle className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium truncate">{profile.label}</p>
<p className="text-[11px] text-muted-foreground truncate">{profile.agent_name}</p>
</div>
</button>
))}
<header className="flex h-12 shrink-0 items-center justify-between border-b bg-muted/30 pl-3">
<div className="flex min-w-0 items-center gap-2">
<IconSparkles className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">Configuration Chat</span>
</div>
<p className="text-[11px] text-muted-foreground mt-3">
This will be saved as your default. Change it in Agent settings.
</p>
</div>
);
}
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 (
<div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/20">
<div className="flex items-center gap-1 overflow-x-auto flex-1 scrollbar-hide">
{sessions.map((s, index) => {
const isActive = s.sessionId === activeSessionId;
const tabName = s.name || `Chat ${index + 1}`;
return (
<div
key={s.sessionId}
className={`flex items-center gap-1 rounded transition-colors whitespace-nowrap ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted"
}`}
>
<button
type="button"
onClick={() => onTabChange(s.sessionId)}
className="flex items-center px-2.5 py-1 text-xs cursor-pointer"
>
<span className="truncate max-w-[100px]">{tabName}</span>
</button>
<button
type="button"
aria-label={`Close ${tabName}`}
className="p-1 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => onTabClose(s.sessionId)}
>
<IconX className="h-3 w-3" />
</button>
</div>
);
})}
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0"
onClick={onNewChat}
aria-label="Start new config chat"
>
<IconPlus className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function LoadingState() {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3">
<IconLoader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-xs text-muted-foreground">Starting config chat...</p>
</div>
);
}
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<HTMLTextAreaElement>(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 <LoadingState />;
return (
<div className="flex-1 flex flex-col p-3">
<div className="text-center space-y-1 mb-3">
<div className="flex justify-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full border bg-muted">
<IconSparkles className="h-4 w-4 text-muted-foreground" />
</div>
</div>
<h3 className="text-sm font-medium">Configure Kandev with AI</h3>
<p className="text-xs text-muted-foreground">
Manage workflows, agent profiles, and MCP configuration.
</p>
</div>
{needsProfileSelection ? (
<ProfileSelector onSelect={setSelectedProfileId} />
) : (
<>
{inputValue.length === 0 ? <SuggestionList /> : <div className="flex-1" />}
<div className="flex items-end gap-2">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything about your configuration..."
disabled={!effectiveProfileId || isStarting}
className="min-h-[40px] max-h-[120px] flex-1 resize-none text-xs"
/>
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className="h-[40px] w-[40px] shrink-0 cursor-pointer"
variant="ghost"
className="h-11 w-11 cursor-pointer rounded-none"
onClick={onExpand}
aria-label="Open in Quick Chat"
>
<IconSend2 className="h-4 w-4" />
<IconArrowsMaximize className="h-4 w-4" />
</Button>
</div>
{error && <p className="text-xs text-destructive mt-2">{error}</p>}
</>
)}
{profileCount === 0 && (
<p className="text-xs text-center text-muted-foreground mt-2">
No agent profiles found. Create one in the Agents settings first.
</p>
)}
</div>
</TooltipTrigger>
<TooltipContent>Open in Quick Chat</TooltipContent>
</Tooltip>
<Button
size="icon"
variant="ghost"
className="h-11 w-11 cursor-pointer rounded-none"
onClick={onClose}
aria-label="Close configuration chat"
>
<IconX className="h-4 w-4" />
</Button>
</div>
</header>
);
}
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 (
<Popover
open={chat.isOpen}
onOpenChange={(nextOpen) => {
if (nextOpen) {
chat.open();
} else {
chat.close();
}
}}
>
{showFab ? (
<Tooltip open={chat.isOpen ? false : undefined}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="icon"
className="fixed bottom-6 right-6 z-50 h-12 w-12 rounded-full shadow-lg cursor-pointer"
>
<IconSparkles className="h-8 w-8" />
<span className="sr-only">Configuration Chat</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="left">
<p className="font-medium">Configuration Chat</p>
<p className="text-xs text-muted-foreground">Configure Kandev with natural language</p>
</TooltipContent>
</Tooltip>
) : (
<PopoverTrigger asChild>
<span className="fixed bottom-6 right-6 h-0 w-0" aria-hidden />
</PopoverTrigger>
)}
<Popover open={panel.isOpen} onOpenChange={panel.handleOpenChange}>
<Tooltip open={panel.isOpen ? false : undefined}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="icon"
className="fixed bottom-6 right-6 z-50 h-12 w-12 cursor-pointer rounded-full shadow-lg"
aria-label="Configuration Chat"
>
<IconSparkles className="h-6 w-6" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="left">
<p className="font-medium">Configuration Chat</p>
<p className="text-xs text-muted-foreground">Configure Kandev with natural language</p>
</TooltipContent>
</Tooltip>
<PopoverContent
side="top"
align="end"
sideOffset={8}
onInteractOutside={(e) => 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"
>
<div className="flex items-center justify-between px-3 py-1.5 border-b bg-muted/30">
<div className="flex items-center gap-1.5">
<IconSparkles className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">Configuration Chat</span>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer"
onClick={chat.close}
aria-label="Close config chat"
>
<IconX className="h-3.5 w-3.5" />
</Button>
</div>
<ConfigChatTabs
sessions={chat.sessions}
activeSessionId={chat.activeSessionId}
onTabChange={chat.setActiveSession}
onTabClose={chat.closeSession}
onNewChat={chat.newChat}
/>
{showEmptyState ? (
<ConfigChatEmptyState
defaultProfileId={chat.defaultProfileId}
onSelectPrompt={(prompt, profileId) => chat.startSession(profileId, prompt)}
isStarting={chat.isStarting}
error={chat.error}
<PanelHeader onExpand={panel.handleExpand} onClose={() => panel.handleOpenChange(false)} />
{panel.session ? (
<QuickChatSessionView
session={panel.session}
onInitialPromptSent={() =>
panel.setQuickChatInitialPrompt(panel.session!.sessionId, undefined)
}
/>
) : (
<QuickChatContent
sessionId={chat.activeSessionId!}
minimalToolbar
placeholderOverride="Ask anything about your configuration..."
initialPrompt={chat.pendingPrompt ?? undefined}
onInitialPromptSent={chat.clearPendingPrompt}
<ConfigChatSetup
presentation="floating"
defaultProfileId={panel.defaultProfileId}
isStarting={panel.isStarting}
error={panel.error}
onStart={panel.handleStart}
/>
)}
</PopoverContent>
@@ -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 && (
<ConfigChatPanel workspaceId={activeWorkspace} showFab={isSettingsPage} />
{mounted && activeWorkspace && isSettingsPage && (
<ConfigChatPanel workspaceId={activeWorkspace} />
)}
</>
);
@@ -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(
<ConfigChatSetup
defaultProfileId="profile-config"
isStarting={false}
error={null}
onStart={vi.fn()}
onCancel={vi.fn()}
onKindChange={onKindChange}
/>,
);
fireEvent.click(screen.getByRole("switch", { name: "Configuration chat" }));
expect(onKindChange).toHaveBeenCalledWith("chat");
});
it("shows config-specific guidance and suggestions without repository controls", () => {
render(
<ConfigChatSetup
defaultProfileId="profile-config"
isStarting={false}
error={null}
onStart={vi.fn()}
onCancel={vi.fn()}
/>,
);
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(
<ConfigChatSetup isStarting={false} error={null} onStart={vi.fn()} onCancel={vi.fn()} />,
);
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(
<ConfigChatSetup isStarting={false} error={null} onStart={vi.fn()} onCancel={vi.fn()} />,
);
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(
<ConfigChatSetup
defaultProfileId="profile-config"
isStarting={false}
error={null}
onStart={onStart}
onCancel={vi.fn()}
/>,
);
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();
});
});
@@ -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 (
<section className="space-y-3" aria-labelledby="config-chat-agent-label">
<div>
<h3 id="config-chat-agent-label" className="text-sm font-medium">
Configuration agent profile
</h3>
<p className="text-xs text-muted-foreground">
Choose the agent with access to configuration tools. This becomes the workspace default.
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{profiles.map((profile) => (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile.id)}
className="flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/50 hover:bg-accent/50"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-background">
<IconMessageCircle className="h-4 w-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{profile.label}</span>
<span className="block truncate text-xs text-muted-foreground">
{profile.agent_name}
</span>
</span>
</button>
))}
</div>
</section>
);
}
function Suggestions({ onSelect }: { onSelect: (prompt: string) => void }) {
return (
<section className="space-y-2" aria-labelledby="config-chat-suggestions-label">
<h3 id="config-chat-suggestions-label" className="text-xs font-medium text-muted-foreground">
Try asking
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{SUGGESTION_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onSelect(prompt)}
className="min-h-11 rounded-md border px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-accent/50 hover:text-foreground"
>
{prompt}
</button>
))}
</div>
</section>
);
}
function ConfigChatFooter({
onCancel,
onStart,
disabled,
startDisabled,
isStarting,
}: {
onCancel: () => void;
onStart: () => void;
disabled: boolean;
startDisabled: boolean;
isStarting: boolean;
}) {
return (
<footer className="flex shrink-0 items-center justify-end gap-2 border-t bg-popover px-4 py-3 sm:px-8">
<Button variant="outline" onClick={onCancel} disabled={disabled} className="cursor-pointer">
Cancel
</Button>
<Button
onClick={onStart}
disabled={startDisabled}
className="min-w-28 cursor-pointer"
aria-label="Start configuration chat"
data-dialog-default-action
>
{isStarting ? <IconLoader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
{isStarting ? "Starting chat..." : "Start chat"}
</Button>
</footer>
);
}
type ConfigPromptProps = {
value: string;
error: string | null;
disabled: boolean;
canSubmit: boolean;
isStarting: boolean;
presentation: "dialog" | "floating";
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
onChange: (value: string) => void;
onSubmit: () => void;
};
function ConfigPrompt({
value,
error,
disabled,
canSubmit,
isStarting,
presentation,
textareaRef,
onChange,
onSubmit,
}: ConfigPromptProps) {
return (
<div
className="shrink-0 border-t bg-popover px-4 py-4 sm:px-8"
data-testid="config-chat-composer"
>
<section
className="mx-auto w-full max-w-2xl space-y-2"
aria-labelledby="config-chat-prompt-label"
>
<h3 id="config-chat-prompt-label" className="text-sm font-medium">
What would you like to configure?
</h3>
<div className="flex items-end gap-2">
<Textarea
ref={textareaRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.repeat &&
!event.nativeEvent.isComposing &&
event.keyCode !== 229
) {
event.preventDefault();
onSubmit();
}
}}
placeholder="Ask anything about your configuration..."
disabled={disabled}
className="min-h-20 max-h-32 resize-y"
/>
{presentation === "floating" && (
<Button
size="icon"
onClick={onSubmit}
disabled={!canSubmit}
className="h-11 w-11 shrink-0 cursor-pointer"
aria-label="Start configuration chat"
>
{isStarting ? (
<IconLoader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<IconSend2 className="h-4 w-4" aria-hidden />
)}
</Button>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</section>
</div>
);
}
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 (
<div
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-8 sm:py-8"
data-testid="config-chat-guidance"
>
<div className="mx-auto w-full max-w-2xl space-y-7">
{presentation === "dialog" && (
<>
<header className="space-y-2">
<div className="flex items-center gap-2">
<IconSparkles className="h-5 w-5 text-primary" aria-hidden />
<h2 className="text-lg font-semibold">Configuration Chat</h2>
</div>
<p className="text-sm text-muted-foreground">
Ask an agent to manage workflows, agent profiles, and MCP configuration.
</p>
</header>
{onKindChange && (
<ConfigurationChatToggle
checked
disabled={isStarting}
onCheckedChange={(checked) => !checked && onKindChange("chat")}
/>
)}
</>
)}
{!hasProfiles && (
<p className="text-sm text-muted-foreground">
No agent profiles are available. Create one in Agent settings first.
</p>
)}
{hasProfiles && needsProfileSelection && <ProfileSelector onSelect={onSelectProfile} />}
{hasProfiles && !needsProfileSelection && <Suggestions onSelect={onSelectSuggestion} />}
</div>
</div>
);
}
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<HTMLTextAreaElement | null>;
onChange: (value: string) => void;
onSubmit: () => void;
}) {
return (
<>
{showPrompt && (
<ConfigPrompt
value={value}
error={error}
disabled={!profileIsResolved || isStarting}
canSubmit={canSubmit}
isStarting={isStarting}
presentation={presentation}
textareaRef={textareaRef}
onChange={onChange}
onSubmit={onSubmit}
/>
)}
{presentation === "dialog" && onCancel && (
<ConfigChatFooter
onCancel={onCancel}
onStart={onSubmit}
disabled={isStarting}
startDisabled={!canSubmit}
isStarting={isStarting}
/>
)}
</>
);
}
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<HTMLTextAreaElement>(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 (
<div className="flex min-h-0 flex-1 flex-col bg-popover" data-testid="config-chat-setup">
<ConfigGuidance
presentation={presentation}
hasProfiles={profiles.length > 0}
needsProfileSelection={needsProfileSelection}
isStarting={isStarting}
onSelectProfile={setSelectedProfileId}
onSelectSuggestion={setInputValue}
onKindChange={onKindChange}
/>
<ConfigSetupActions
showPrompt={profiles.length > 0 && !needsProfileSelection}
value={inputValue}
error={error}
profileIsResolved={profileIsResolved}
canSubmit={canSubmit}
isStarting={isStarting}
presentation={presentation}
onCancel={onCancel}
textareaRef={textareaRef}
onChange={setInputValue}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -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<string | undefined>;
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);
});
});
@@ -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<typeof useConfigChatStore>;
type AppStoreApi = ReturnType<typeof useAppStoreApi>;
type StartedConfigChat = Awaited<ReturnType<typeof startConfigChat>>;
const activeWorkspaceStarts = new Map<string, symbol>();
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<string, unknown>) => {
const { workspaces, setWorkspaces } = storeApi.getState();
setWorkspaces(workspaces.items.map((w) => (w.id === workspaceId ? { ...w, ...updates } : w)));
};
return useCallback(
(workspaceId: string, updates: Record<string, unknown>) => {
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<string, unknown>) => 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<string | null>(null);
const [pendingPrompt, setPendingPrompt] = useState<string | null>(null);
const updateWorkspaceInStore = useUpdateWorkspaceInStore();
const latestRequestId = useRef(0);
const activeRequestId = useRef<number | null>(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<string | undefined> => {
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,
};
}
+4 -46
View File
@@ -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);
@@ -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 (
<section
className="flex min-h-11 items-center justify-between gap-4"
aria-labelledby="config-chat-mode-label"
>
<div className="min-w-0">
<h3 id="config-chat-mode-label" className="text-sm font-medium">
Configuration chat
</h3>
<p className="text-xs text-muted-foreground">
Let the agent update Kandev settings, workflows, agent profiles, and MCP configuration.
</p>
</div>
<Switch
aria-label="Configuration chat"
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
className="shrink-0 cursor-pointer"
/>
</section>
);
}
@@ -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 (
<div className="relative flex-shrink-0 border-t border-sky-400/30 bg-card">
{!collapsed && <ResizeHandle {...resizeHandleProps} />}
<div
ref={containerRef}
data-testid="clarification-overlay-container"
className={
collapsed
? "h-11"
: "flex min-h-[7.5rem] max-h-[35vh] flex-col overflow-hidden overscroll-contain"
}
style={!collapsed && height !== null ? { height } : undefined}
>
<div className="flex h-11 flex-shrink-0 items-center justify-between gap-2 pl-4">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
<IconMessageQuestion className="h-4 w-4 flex-shrink-0 text-blue-500" />
<span className="truncate">Clarification needed</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 flex-shrink-0 cursor-pointer rounded-none"
aria-label={actionLabel}
aria-expanded={!collapsed}
aria-controls={contentId}
title={actionLabel}
data-testid="clarification-collapse-toggle"
onClick={() => setCollapsed((current) => !current)}
>
{collapsed ? (
<IconChevronUp className="h-4 w-4" />
) : (
<IconChevronDown className="h-4 w-4" />
)}
</Button>
</div>
<div
id={contentId}
data-testid="clarification-scroll-region"
className={collapsed ? "hidden" : "min-h-0 flex-1 overflow-y-auto px-1"}
>
<ClarificationInputOverlay
messages={messages}
onResolved={onResolved}
keyboardShortcutsEnabled={!collapsed}
/>
</div>
</div>
</div>
);
}
function useQuickChatState(sessionId: string) {
const chatInputRef = useRef<ChatInputContainerHandle>(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<string | null>(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 (
<div className="flex flex-col flex-1 min-h-0">
@@ -99,22 +172,12 @@ export const QuickChatContent = memo(function QuickChatContent({
onOpenFile={undefined}
/>
</div>
{pendingClarification && (
<div className="relative flex-shrink-0 border-t border-sky-400/30 bg-card">
<ResizeHandle {...clarificationResizeProps} />
<div
ref={clarificationContainerRef}
data-testid="clarification-overlay-container"
className="px-1 overflow-y-scroll overscroll-contain max-h-[50vh]"
style={clarificationHeight === null ? undefined : { height: clarificationHeight }}
>
<ClarificationInputOverlay
messages={pendingClarificationGroup}
onResolved={handleClarificationResolved}
/>
</div>
</div>
)}
<QuickChatClarificationSection
key={sessionId}
pending={Boolean(pendingClarification)}
messages={pendingClarificationGroup}
onResolved={handleClarificationResolved}
/>
<ChatInputArea
chatInputRef={chatInputRef}
clarificationKey={clarificationKey}
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createUISlice } from "@/lib/state/slices/ui/ui-slice";
import { getQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
import { getStoredQuickChatNames } from "@/lib/local-storage";
import type { UISlice } from "@/lib/state/slices/ui/types";
@@ -51,12 +52,26 @@ describe("openQuickChat agentProfileId persistence", () => {
});
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", () => {
@@ -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({
<div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/20">
<div className="flex items-center gap-1 overflow-x-auto flex-1 scrollbar-hide">
{sessions.map((s, index) => {
// 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 (
<QuickChatTabItem
key={s.sessionId || `new-${index}`}
name={tabName}
isActive={s.sessionId === activeSessionId}
isRenameable={s.sessionId !== ""}
isRenameable={!isQuickChatSetupSessionId(s.sessionId)}
kind={s.kind}
onActivate={() => onTabChange(s.sessionId)}
onClose={() => onTabClose(s.sessionId)}
onRename={(name) => onRename(s.sessionId, name)}
@@ -56,13 +63,13 @@ function QuickChatTabs({
);
})}
<Button
size="sm"
size="icon"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0"
className="h-11 w-11 shrink-0 cursor-pointer sm:h-6 sm:w-6"
onClick={onNewChat}
aria-label="Start new chat"
>
<IconPlus className="h-3.5 w-3.5" />
<IconPlus className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</Button>
</div>
{/* Touch devices have no Escape key or visible overlay to dismiss the
@@ -70,7 +77,7 @@ function QuickChatTabs({
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0 sm:hidden"
className="h-11 w-11 shrink-0 cursor-pointer p-0 sm:hidden"
onClick={onCloseModal}
aria-label="Close quick chat"
data-testid="quick-chat-close"
@@ -108,37 +115,13 @@ function QuickChatResizeHandle({
);
}
function useIsQuickChatPassthrough(sessionId: string) {
return useAppStore(
useShallow((s) => {
const session = s.taskSessions.items[sessionId];
if (typeof session?.is_passthrough === "boolean") return session.is_passthrough;
const profileId =
session?.agent_profile_id ??
s.quickChat.sessions.find((qs) => qs.sessionId === sessionId)?.agentProfileId;
if (!profileId) return false;
return s.agentProfiles.items.find((p) => p.id === profileId)?.cli_passthrough === true;
}),
);
}
function QuickChatSessionView({ sessionId }: { sessionId: string }) {
const isPassthrough = useIsQuickChatPassthrough(sessionId);
if (isPassthrough) {
return (
<div className="flex-1 min-h-0 overflow-hidden">
<PassthroughTerminal key={sessionId} sessionId={sessionId} mode="agent" />
</div>
);
}
return <QuickChatContent sessionId={sessionId} />;
}
export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: QuickChatModalProps) {
const configChat = useConfigChat(workspaceId);
const {
isOpen,
sessions,
activeSessionId,
activeSession,
sessionToClose,
setupKey,
activeSessionNeedsAgent,
@@ -147,14 +130,17 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
setSessionToClose,
handleOpenChange,
handleNewChat,
handleSetupKindChange,
handleSelectAgent,
handleCloseTab,
handleConfirmClose,
handleRename,
} = useQuickChatModal(workspaceId);
} = useQuickChatModal(workspaceId, configChat.reset);
const setQuickChatInitialPrompt = useAppStore((state) => state.setQuickChatInitialPrompt);
const { width, leftResizeHandleProps, rightResizeHandleProps } = useQuickChatWidth();
const hasCreatedChat = sessions.some((session) => session.sessionId !== "");
const canCreateConfigurationChat = !sessions.some((session) => session.kind === "config");
const setupKind =
activeSession && isQuickChatSetupSessionId(activeSession.sessionId) ? activeSession.kind : null;
return (
<>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
@@ -176,17 +162,32 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
onRename={handleRename}
onCloseModal={() => handleOpenChange(false)}
/>
{activeSessionId && !activeSessionNeedsAgent && (
<QuickChatSessionView sessionId={activeSessionId} />
{activeSessionId && activeSession && !activeSessionNeedsAgent && (
<QuickChatSessionView
session={activeSession}
onInitialPromptSent={() => setQuickChatInitialPrompt(activeSessionId, undefined)}
/>
)}
{activeSessionNeedsAgent && (
{activeSessionNeedsAgent && setupKind === "chat" && (
<QuickChatSetup
key={`${workspaceId}:${setupKey}`}
workspaceId={workspaceId}
showIntroduction={!hasCreatedChat}
canCreateConfigurationChat={canCreateConfigurationChat}
pendingAgentId={pendingAgentId}
onStart={handleSelectAgent}
onCancel={() => handleOpenChange(false)}
onKindChange={handleSetupKindChange}
/>
)}
{activeSessionNeedsAgent && setupKind === "config" && (
<ConfigChatSetup
key={`${workspaceId}:config:${setupKey}`}
defaultProfileId={configChat.defaultProfileId}
isStarting={configChat.isStarting}
error={configChat.error}
onStart={(profileId, prompt) => configChat.startSession(profileId, prompt)}
onCancel={() => handleOpenChange(false)}
onKindChange={handleSetupKindChange}
/>
)}
</DialogContent>
@@ -0,0 +1,44 @@
"use client";
import { useAppStore } from "@/components/state-provider";
import { PassthroughTerminal } from "@/components/task/passthrough-terminal";
import type { QuickChatSession } from "@/lib/state/slices/ui/types";
import { QuickChatContent } from "./quick-chat-content";
function useIsQuickChatPassthrough(sessionId: string) {
return useAppStore((state) => {
const session = state.taskSessions.items[sessionId];
if (typeof session?.is_passthrough === "boolean") return session.is_passthrough;
const profileId =
session?.agent_profile_id ??
state.quickChat.sessions.find((item) => item.sessionId === sessionId)?.agentProfileId;
if (!profileId) return false;
return state.agentProfiles.items.find((profile) => profile.id === profileId)?.cli_passthrough;
});
}
type QuickChatSessionViewProps = {
session: QuickChatSession;
onInitialPromptSent?: () => void;
};
export function QuickChatSessionView({ session, onInitialPromptSent }: QuickChatSessionViewProps) {
const isPassthrough = useIsQuickChatPassthrough(session.sessionId);
if (isPassthrough) {
return (
<div className="min-h-0 flex-1 overflow-hidden">
<PassthroughTerminal key={session.sessionId} sessionId={session.sessionId} mode="agent" />
</div>
);
}
const isConfig = session.kind === "config";
return (
<QuickChatContent
sessionId={session.sessionId}
minimalToolbar={isConfig}
placeholderOverride={isConfig ? "Ask anything about your configuration..." : undefined}
initialPrompt={session.initialPrompt}
onInitialPromptSent={onInitialPromptSent}
/>
);
}
@@ -66,10 +66,11 @@ vi.mock("@/hooks/domains/workspace/use-repositories", () => ({
const props = {
workspaceId: "workspace-1",
showIntroduction: true,
canCreateConfigurationChat: true,
pendingAgentId: null,
onStart: vi.fn(),
onCancel: vi.fn(),
onKindChange: vi.fn(),
};
beforeEach(() => {
@@ -80,6 +81,27 @@ beforeEach(() => {
afterEach(cleanup);
describe("QuickChatSetup default agent", () => {
it("explains when to use Quick Chat instead of a task", () => {
render(<QuickChatSetup {...props} />);
expect(screen.getByText(/idea, question, or codebase/i)).toBeTruthy();
expect(screen.getByText(/outside your task board/i)).toBeTruthy();
});
it("offers configuration mode in the setup panel", () => {
render(<QuickChatSetup {...props} />);
fireEvent.click(screen.getByRole("switch", { name: "Configuration chat" }));
expect(props.onKindChange).toHaveBeenCalledWith("config");
});
it("hides configuration mode when the workspace already has one", () => {
render(<QuickChatSetup {...props} canCreateConfigurationChat={false} />);
expect(screen.queryByRole("switch", { name: "Configuration chat" })).toBeNull();
});
it("renders the agent selector with a visible field border", () => {
render(<QuickChatSetup {...props} />);
@@ -13,13 +13,16 @@ import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
import type { QuickChatRepositoryInput } from "@/lib/api/domains/workspace-api";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { Repository } from "@/lib/types/http";
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
import { ConfigurationChatToggle } from "./configuration-chat-toggle";
type QuickChatSetupProps = {
workspaceId: string;
showIntroduction: boolean;
canCreateConfigurationChat: boolean;
pendingAgentId: string | null;
onStart: (agentId: string, repositories: QuickChatRepositoryInput[]) => void;
onCancel: () => void;
onKindChange: (kind: QuickChatSessionKind) => void;
};
function repositoryAddState(isLoading: boolean, repositoryCount: number, rowCount: number) {
@@ -33,8 +36,7 @@ function repositoryAddState(isLoading: boolean, repositoryCount: number, rowCoun
return { canAddMore: true, addHint: undefined };
}
function QuickChatIntroduction({ show }: { show: boolean }) {
if (!show) return null;
function QuickChatIntroduction() {
return (
<div className="space-y-1" data-testid="quick-chat-introduction">
<p className="text-sm text-foreground">
@@ -183,10 +185,11 @@ function SetupFooter({
export function QuickChatSetup({
workspaceId,
showIntroduction,
canCreateConfigurationChat,
pendingAgentId,
onStart,
onCancel,
onKindChange,
}: QuickChatSetupProps) {
const agentProfiles = useAppStore((state) => state.agentProfiles.items ?? []);
const defaultAgentId = useAppStore(
@@ -232,8 +235,15 @@ export function QuickChatSetup({
<div className="mx-auto w-full max-w-2xl space-y-7">
<header className="space-y-1">
<h2 className="text-lg font-semibold">Quick Chat</h2>
<QuickChatIntroduction show={showIntroduction} />
<QuickChatIntroduction />
</header>
{canCreateConfigurationChat && (
<ConfigurationChatToggle
checked={false}
disabled={isStarting}
onCheckedChange={(checked) => checked && onKindChange("config")}
/>
)}
<AgentField
profiles={agentProfiles}
value={agentProfileId}
@@ -23,6 +23,17 @@ function startEditing(label: HTMLElement) {
}
describe("QuickChatTabItem rename", () => {
it("renders an accessible configuration indicator", () => {
const { getByLabelText } = render(
<QuickChatTabItem
{...makeProps()}
{...({ kind: "config" } as Partial<Parameters<typeof QuickChatTabItem>[0]>)}
/>,
);
expect(getByLabelText("Configuration chat")).toBeTruthy();
});
it("commits the rename on Enter, calling onRename exactly once", () => {
const onRename = vi.fn();
const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />);
@@ -1,12 +1,14 @@
"use client";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { IconX } from "@tabler/icons-react";
import { IconSparkles, IconX } from "@tabler/icons-react";
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
type QuickChatTabItemProps = {
name: string;
isActive: boolean;
isRenameable: boolean;
kind?: QuickChatSessionKind;
onActivate: () => void;
onClose: () => void;
onRename: (name: string) => void;
@@ -17,6 +19,7 @@ export const QuickChatTabItem = memo(function QuickChatTabItem({
name,
isActive,
isRenameable,
kind = "chat",
onActivate,
onClose,
onRename,
@@ -101,6 +104,11 @@ export const QuickChatTabItem = memo(function QuickChatTabItem({
title={isRenameable ? "Double-click to rename" : undefined}
className="flex items-center px-2.5 py-1 text-xs cursor-pointer"
>
{kind === "config" && (
<span role="img" aria-label="Configuration chat" className="mr-1.5 shrink-0">
<IconSparkles className="h-3 w-3" aria-hidden />
</span>
)}
<span className="truncate max-w-[160px]">{name}</span>
</button>
)}
@@ -25,8 +25,10 @@ vi.mock("@/lib/api/domains/kanban-api", () => ({
}));
import { useAgentSelection, useQuickChatModal } from "./use-quick-chat-modal";
import { getQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
const WORKSPACE_ID = "ws-1";
const CHAT_SETUP_ID = getQuickChatSetupSessionId(WORKSPACE_ID, "chat");
type MockStore = Parameters<typeof useAgentSelection>[1];
@@ -34,7 +36,11 @@ function makeAppState() {
return {
quickChat: {
isOpen: true,
sessions: [] as Array<{ sessionId: string; workspaceId: string }>,
sessions: [] as Array<{
sessionId: string;
workspaceId: string;
kind: "chat" | "config";
}>,
activeSessionId: "",
},
closeQuickChat: vi.fn(),
@@ -43,7 +49,7 @@ function makeAppState() {
renameQuickChatSession: vi.fn(),
openQuickChat: vi.fn(),
agentProfiles: { items: [] },
taskSessions: { items: {} },
taskSessions: { items: {} as Record<string, { task_id: string }> },
};
}
@@ -78,15 +84,15 @@ beforeEach(() => {
describe("useQuickChatModal — setup lifecycle", () => {
it("removes a blank placeholder when dismissed from an active session", () => {
mockAppState.quickChat.sessions = [
{ sessionId: "", workspaceId: WORKSPACE_ID },
{ sessionId: "session-1", workspaceId: WORKSPACE_ID },
{ sessionId: CHAT_SETUP_ID, workspaceId: WORKSPACE_ID, kind: "chat" },
{ sessionId: "session-1", workspaceId: WORKSPACE_ID, kind: "chat" },
];
mockAppState.quickChat.activeSessionId = "session-1";
const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID));
act(() => result.current.handleOpenChange(false));
expect(mockAppState.closeQuickChatSession).toHaveBeenCalledWith("");
expect(mockAppState.closeQuickChatSession).toHaveBeenCalledWith(CHAT_SETUP_ID);
expect(mockAppState.closeQuickChat).toHaveBeenCalledTimes(1);
});
@@ -97,19 +103,86 @@ describe("useQuickChatModal — setup lifecycle", () => {
act(() => result.current.handleNewChat());
expect(result.current.setupKey).toBe(1);
expect(mockAppState.openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID);
expect(mockAppState.openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID, undefined, "chat");
});
it("switches an ordinary setup to configuration mode", () => {
mockAppState.quickChat.sessions = [
{ sessionId: CHAT_SETUP_ID, workspaceId: WORKSPACE_ID, kind: "chat" },
];
mockAppState.quickChat.activeSessionId = CHAT_SETUP_ID;
const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID));
act(() => result.current.handleSetupKindChange("config"));
expect(mockAppState.closeQuickChatSession).toHaveBeenCalledWith(CHAT_SETUP_ID);
expect(mockAppState.openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID, undefined, "config");
});
it("supersedes an in-flight config start when the user changes tabs", () => {
const resetConfigStart = vi.fn();
mockAppState.quickChat.sessions = [
{ sessionId: "session-1", workspaceId: WORKSPACE_ID, kind: "chat" },
];
mockAppState.quickChat.activeSessionId = "session-1";
const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID, resetConfigStart));
act(() => result.current.setActiveQuickChatSession("session-1"));
expect(resetConfigStart).toHaveBeenCalledTimes(1);
expect(mockAppState.setActiveQuickChatSession).toHaveBeenCalledWith("session-1", WORKSPACE_ID);
});
});
describe("useQuickChatModal — persisted config lifecycle", () => {
it("deletes the backing task only after config-tab close is confirmed", async () => {
const configSessionId = "config-session";
mockAppState.quickChat.sessions = [
{ sessionId: configSessionId, workspaceId: WORKSPACE_ID, kind: "config" },
];
mockAppState.quickChat.activeSessionId = configSessionId;
mockAppState.taskSessions.items = { [configSessionId]: { task_id: "config-task" } };
mockDeleteTask.mockResolvedValue(undefined);
const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID));
act(() => result.current.handleCloseTab(configSessionId));
expect(mockDeleteTask).not.toHaveBeenCalled();
await act(async () => result.current.handleConfirmClose());
expect(mockAppState.closeQuickChatSession).toHaveBeenCalledWith(configSessionId);
expect(mockDeleteTask).toHaveBeenCalledWith("config-task");
});
it("keeps the session open when backing-task deletion fails", async () => {
const configSessionId = "config-session";
mockAppState.quickChat.sessions = [
{ sessionId: configSessionId, workspaceId: WORKSPACE_ID, kind: "config" },
];
mockAppState.quickChat.activeSessionId = configSessionId;
mockAppState.taskSessions.items = { [configSessionId]: { task_id: "config-task" } };
mockDeleteTask.mockRejectedValueOnce(new Error("delete failed"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID));
act(() => result.current.handleCloseTab(configSessionId));
await act(async () => result.current.handleConfirmClose());
expect(mockAppState.closeQuickChatSession).not.toHaveBeenCalled();
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
consoleError.mockRestore();
});
it("exposes only sessions from the hydrated workspace", () => {
mockAppState.quickChat.sessions = [
{ sessionId: "session-a", workspaceId: WORKSPACE_ID },
{ sessionId: "session-b", workspaceId: "ws-2" },
{ sessionId: "session-a", workspaceId: WORKSPACE_ID, kind: "chat" },
{ sessionId: "session-b", workspaceId: "ws-2", kind: "chat" },
];
const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID));
expect(result.current.sessions).toEqual([
{ sessionId: "session-a", workspaceId: WORKSPACE_ID },
{ sessionId: "session-a", workspaceId: WORKSPACE_ID, kind: "chat" },
]);
});
});
@@ -149,6 +222,29 @@ describe("useAgentSelection — happy path", () => {
expect.objectContaining({ repositories }),
);
});
it("numbers chats using only ordinary sessions in the current workspace", async () => {
const store = makeStore({
sessions: [
{ sessionId: "local-chat", workspaceId: WORKSPACE_ID, kind: "chat" },
{ sessionId: "local-config", workspaceId: WORKSPACE_ID, kind: "config" },
{ sessionId: "other-chat", workspaceId: "ws-2", kind: "chat" },
{ sessionId: CHAT_SETUP_ID, workspaceId: WORKSPACE_ID, kind: "chat" },
],
});
mockStartQuickChat.mockResolvedValue({ task_id: "task-a", session_id: "sess-a" });
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
await act(async () => {
await result.current.handleSelectAgent("agent-a");
});
expect(mockStartQuickChat).toHaveBeenCalledWith(
WORKSPACE_ID,
expect.objectContaining({ title: "Agent A - Chat 2" }),
);
});
});
describe("useAgentSelection — supersession", () => {
@@ -1,10 +1,14 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { startQuickChat, type QuickChatRepositoryInput } from "@/lib/api/domains/workspace-api";
import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
const noop = () => {};
async function deleteQuickChatTask(taskId: string) {
const { deleteTask } = await import("@/lib/api/domains/kanban-api");
@@ -37,6 +41,17 @@ function useQuickChatStore(workspaceId: string) {
type QuickChatStore = ReturnType<typeof useQuickChatStore>;
function useWorkspaceQuickChat(store: QuickChatStore) {
const sessions = store.sessions;
const activeSession = sessions.find((session) => session.sessionId === store.activeSessionId);
useEffect(() => {
if (store.isOpen && store.activeSessionId !== null && !activeSession) {
store.closeQuickChat();
}
}, [activeSession, store.isOpen, store.activeSessionId, store.closeQuickChat]);
return { sessions, activeSession };
}
/** POSTs to start a quick-chat session and returns the response. */
async function startQuickChatForAgent(
workspaceId: string,
@@ -45,7 +60,13 @@ async function startQuickChatForAgent(
repositories: QuickChatRepositoryInput[],
) {
const agent = store.agentProfiles.find((p) => p.id === agentId);
const sessionCount = store.sessions.filter((s) => s.sessionId !== "").length + 1;
const sessionCount =
store.sessions.filter(
(session) =>
session.workspaceId === workspaceId &&
(session.kind ?? "chat") === "chat" &&
!isQuickChatSetupSessionId(session.sessionId),
).length + 1;
const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`;
const response = await startQuickChat(workspaceId, {
agent_profile_id: agentId,
@@ -80,6 +101,7 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) {
const handleSelectAgent = useCallback(
async (agentId: string, repositories: QuickChatRepositoryInput[] = []) => {
const requestId = ++latestRequestId.current;
const setupSessionId = store.activeSessionId;
setPendingAgentId(agentId);
try {
const result = await startQuickChatForAgent(workspaceId, agentId, store, repositories);
@@ -91,7 +113,9 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) {
);
return;
}
if (store.activeSessionId === "") store.closeQuickChatSession("");
if (setupSessionId && isQuickChatSetupSessionId(setupSessionId)) {
store.closeQuickChatSession(setupSessionId);
}
store.openQuickChat(result.sessionId, workspaceId, agentId);
store.renameQuickChatSession(result.sessionId, result.name);
} catch (error) {
@@ -113,37 +137,92 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) {
return { pendingAgentId, reset, handleSelectAgent };
}
export function useQuickChatModal(workspaceId: string) {
function useQuickChatSessionClose(store: QuickChatStore, resetPendingStarts: () => void) {
const { toast } = useToast();
const store = useQuickChatStore(workspaceId);
const [setupKey, setSetupKey] = useState(0);
const [sessionToClose, setSessionToClose] = useState<string | null>(null);
const handleCloseTab = useCallback(
(sessionId: string) => {
resetPendingStarts();
if (isQuickChatSetupSessionId(sessionId)) {
store.closeQuickChatSession(sessionId);
return;
}
setSessionToClose(sessionId);
},
[resetPendingStarts, store],
);
const handleConfirmClose = useCallback(async () => {
if (!sessionToClose) return;
const sessionId = sessionToClose;
setSessionToClose(null);
const taskId = store.taskSessions[sessionId]?.task_id;
if (!taskId) {
store.closeQuickChatSession(sessionId);
return;
}
try {
await deleteQuickChatTask(taskId);
store.closeQuickChatSession(sessionId);
} 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]);
return { sessionToClose, setSessionToClose, handleCloseTab, handleConfirmClose };
}
export function useQuickChatModal(workspaceId: string, onSupersedeConfigStart = noop) {
const store = useQuickChatStore(workspaceId);
const { sessions, activeSession } = useWorkspaceQuickChat(store);
const [setupKey, setSetupKey] = useState(0);
const {
pendingAgentId,
reset,
handleSelectAgent: doSelectAgent,
} = useAgentSelection(workspaceId, store);
const resetPendingStarts = useCallback(() => {
reset();
onSupersedeConfigStart();
}, [onSupersedeConfigStart, reset]);
const { sessionToClose, setSessionToClose, handleCloseTab, handleConfirmClose } =
useQuickChatSessionClose(store, resetPendingStarts);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) return;
reset();
if (store.sessions.some((session) => session.sessionId === "")) {
store.closeQuickChatSession("");
}
resetPendingStarts();
sessions
.filter((session) => isQuickChatSetupSessionId(session.sessionId))
.forEach((session) => store.closeQuickChatSession(session.sessionId));
store.closeQuickChat();
},
[store, reset],
[resetPendingStarts, sessions, store],
);
// 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();
resetPendingStarts();
setSetupKey((key) => key + 1);
store.openQuickChat("", workspaceId);
}, [reset, store, workspaceId]);
store.openQuickChat("", workspaceId, undefined, "chat");
}, [resetPendingStarts, store, workspaceId]);
const handleSetupKindChange = useCallback(
(kind: QuickChatSessionKind) => {
resetPendingStarts();
if (activeSession && isQuickChatSetupSessionId(activeSession.sessionId)) {
store.closeQuickChatSession(activeSession.sessionId);
}
setSetupKey((key) => key + 1);
store.openQuickChat("", workspaceId, undefined, kind);
},
[activeSession, resetPendingStarts, store, workspaceId],
);
const handleSelectAgent = useCallback(
(agentId: string, repositories: QuickChatRepositoryInput[] = []) =>
@@ -153,22 +232,10 @@ export function useQuickChatModal(workspaceId: string) {
const setActiveQuickChatSession = useCallback(
(sessionId: string) => {
reset();
store.setActiveQuickChatSession(sessionId);
resetPendingStarts();
store.setActiveQuickChatSession(sessionId, workspaceId);
},
[reset, store],
);
const handleCloseTab = useCallback(
(sessionId: string) => {
reset();
if (sessionId === "") {
store.closeQuickChatSession(sessionId);
return;
}
setSessionToClose(sessionId);
},
[reset, store],
[resetPendingStarts, store, workspaceId],
);
const handleRename = useCallback(
@@ -179,37 +246,22 @@ export function useQuickChatModal(workspaceId: string) {
[store],
);
const handleConfirmClose = useCallback(async () => {
if (!sessionToClose) return;
const sessionId = sessionToClose;
setSessionToClose(null);
const taskId = store.taskSessions[sessionId]?.task_id;
store.closeQuickChatSession(sessionId);
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]);
return {
isOpen: store.isOpen,
sessions: store.sessions,
activeSessionId: store.activeSessionId,
sessions,
activeSessionId: activeSession?.sessionId ?? null,
activeSession,
sessionToClose,
setupKey,
activeSessionNeedsAgent: store.activeSessionId === "",
activeSessionNeedsAgent: Boolean(
activeSession && isQuickChatSetupSessionId(activeSession.sessionId),
),
pendingAgentId,
setActiveQuickChatSession,
setSessionToClose,
handleOpenChange,
handleNewChat,
handleSetupKindChange,
handleSelectAgent,
handleCloseTab,
handleConfirmClose,
@@ -22,6 +22,7 @@ import {
type ClarificationInputOverlayProps = {
messages: readonly Message[] | null | undefined;
onResolved: () => void;
keyboardShortcutsEnabled?: boolean;
};
type SingleQuestionMeta = {
@@ -53,6 +54,17 @@ function sortMessagesByQuestionIndex(messages: Message[]): Message[] {
});
}
function isQuestionAnsweredAt(
messages: readonly Message[],
answers: Record<string, ClarificationAnswer>,
index: number,
): boolean {
const message = messages[index];
if (!message) return false;
const questionId = readSingleQuestionMeta(message)?.questionId;
return questionId ? Boolean(answers[questionId]) : false;
}
type CardProps = {
meta: SingleQuestionMeta;
index: number;
@@ -156,6 +168,7 @@ function useResolveCallback(
}
type CarouselShortcutArgs = {
enabled: boolean;
meta: SingleQuestionMeta;
activeIndex: number;
total: number;
@@ -198,10 +211,12 @@ function tryHandleMetaEnter(e: KeyboardEvent, canSubmit: boolean, onSubmit: () =
}
function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) {
const { enabled } = args;
const optionsCount = args.meta.question.options.length;
const isLast = args.activeIndex === args.total - 1;
const { canSubmit, onPick, onPrev, onNext, onSkip, onSubmit } = args;
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if (tryHandleMetaEnter(e, canSubmit, onSubmit)) return;
if (shouldIgnoreShortcut(e)) return;
@@ -229,7 +244,7 @@ function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [optionsCount, isLast, canSubmit, onPick, onPrev, onNext, onSkip, onSubmit]);
}, [enabled, optionsCount, isLast, canSubmit, onPick, onPrev, onNext, onSkip, onSubmit]);
return null;
}
@@ -242,6 +257,7 @@ type CarouselBodyProps = {
setCustomDrafts: React.Dispatch<React.SetStateAction<Record<string, string>>>;
allAnswered: boolean;
isSubmitting: boolean;
keyboardShortcutsEnabled: boolean;
onSubmit: () => void;
};
@@ -346,6 +362,7 @@ function ClarificationCarouselBody({
setCustomDrafts,
allAnswered,
isSubmitting,
keyboardShortcutsEnabled,
onSubmit,
}: CarouselBodyProps) {
const total = sortedMessages.length;
@@ -401,6 +418,7 @@ function ClarificationCarouselBody({
/>
)}
<CarouselKeyboardShortcuts
enabled={keyboardShortcutsEnabled}
meta={meta}
activeIndex={activeIndex}
total={total}
@@ -418,6 +436,7 @@ function ClarificationCarouselBody({
export function ClarificationInputOverlay({
messages,
onResolved,
keyboardShortcutsEnabled = true,
}: ClarificationInputOverlayProps) {
const sortedMessages = useMemo(
() => sortMessagesByQuestionIndex(resolveQuestionMessages(messages)),
@@ -450,13 +469,6 @@ export function ClarificationInputOverlay({
if (sortedMessages.length === 0) return null;
const isSubmitting = group.submitState === "submitting";
const isAnsweredAt = (index: number) => {
const m = sortedMessages[index];
if (!m) return false;
const id = readSingleQuestionMeta(m)?.questionId;
return id ? Boolean(group.answers[id]) : false;
};
return (
<div className="relative" data-testid="clarification-overlay">
<div className="flex items-center justify-between gap-3 px-4 pt-2 pb-1">
@@ -466,7 +478,7 @@ export function ClarificationInputOverlay({
<ClarificationStepper
total={total}
activeIndex={activeIndex}
isAnswered={isAnsweredAt}
isAnswered={(index) => isQuestionAnsweredAt(sortedMessages, group.answers, index)}
onJump={setActiveIndex}
isSubmitting={isSubmitting}
/>
@@ -519,6 +531,7 @@ export function ClarificationInputOverlay({
setCustomDrafts={setCustomDrafts}
allAnswered={allAnswered}
isSubmitting={isSubmitting}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
onSubmit={handleSubmit}
/>
</div>
@@ -231,6 +231,7 @@ function buildEditorProps(args: {
}) {
return {
attributes: {
"data-testid": "chat-input-editor",
class: cn(
"w-full h-full resize-none bg-transparent px-2 py-2 overflow-y-auto",
TIPTAP_EDITOR_TEXT_SIZE_CLASS,
@@ -94,20 +94,15 @@ test.describe("Clarification overlay resizable layout", () => {
const container = testPage.getByTestId("clarification-overlay-container");
await expect(container).toBeVisible();
const scrollRegion = container;
const initial = await container.evaluate((el) => {
const computed = window.getComputedStyle(el);
return {
overflowY: computed.overflowY,
height: el.getBoundingClientRect().height,
// Inline style is what disambiguates "auto-sized" from "user-dragged".
inlineHeight: (el as HTMLElement).style.height,
};
});
// overflow-y: scroll reserves a permanent scrollbar gutter so users
// discover scrollable content (and the in-overlay Submit) without having
// to wait for macOS auto-hide scrollbars to fade in on hover.
expect(initial.overflowY).toBe("scroll");
await expect(scrollRegion).toHaveCSS("overflow-y", "scroll");
// Default state: no inline height → container sizes to its content.
expect(initial.inlineHeight).toBe("");
// Sanity check: content-sized overlay is at least tall enough for the
@@ -118,10 +113,10 @@ test.describe("Clarification overlay resizable layout", () => {
const handle = container.locator("xpath=..").locator("button[aria-label='Resize']");
await expect(handle).toBeVisible();
// Drag the handle upward by 120px → overlay should grow by ~120px.
// Drag the handle upward by 40px; the overlay should grow by roughly the same amount.
const handleBox = await handle.boundingBox();
expect(handleBox).not.toBeNull();
const dragDistance = 120;
const dragDistance = 40;
const startX = handleBox!.x + handleBox!.width / 2;
const startY = handleBox!.y + handleBox!.height / 2;
await testPage.mouse.move(startX, startY);
@@ -34,6 +34,7 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
const setup = dialog.getByTestId("quick-chat-setup");
if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) {
await dialog.getByLabel("Start new chat").click();
await page.getByRole("menu", { name: "New chat" }).getByText("Quick chat").click();
}
await expect(setup).toBeVisible({ timeout: 5_000 });
@@ -30,6 +30,19 @@ test.describe("Quick Chat entry points on mobile", () => {
await expect(dialog).not.toBeVisible();
});
test("chooses configuration mode from the setup panel", async ({ testPage }) => {
await testPage.goto("/");
await testPage.getByTestId("mobile-quick-chat-button").tap();
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
const setup = dialog.getByTestId("quick-chat-setup");
await expect(setup.getByText(/quick chats stay outside your task board/i)).toBeVisible();
await setup.getByRole("switch", { name: "Configuration chat" }).tap();
await expect(dialog.getByTestId("config-chat-setup")).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage);
});
test("opens from the task switcher sheet on a session page", async ({
testPage,
apiClient,
@@ -55,7 +55,7 @@ test.describe("Quick Chat repository context on mobile", () => {
const restoredDialog = testPage.getByRole("dialog", { name: "Quick Chat" });
const restoredTabs = restoredDialog.getByTestId("quick-chat-tab");
await expect(restoredTabs).toHaveCount(2);
await expect(restoredTabs.locator("span")).toHaveText(originalNames);
await expect(restoredTabs.locator("span")).toHaveText([...originalNames].reverse());
await expect(restoredDialog.getByTestId("quick-chat-setup")).not.toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage);
});
+49 -6
View File
@@ -96,6 +96,46 @@ async function waitForQuickChatWidth(dialog: Locator) {
}
test.describe("Quick Chat", () => {
test("offers configuration chat in setup and hides it once one exists", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.updateWorkspace(seedData.workspaceId, {
default_config_agent_profile_id: seedData.agentProfileId,
});
const dialog = await openQuickChatSetup(testPage);
const setup = dialog.getByTestId("quick-chat-setup");
await expect(setup.getByText(/quick chats stay outside your task board/i)).toBeVisible();
await setup.getByRole("switch", { name: "Configuration chat" }).click();
const configSetup = dialog.getByTestId("config-chat-setup");
await expect(configSetup).toBeVisible();
await expect(configSetup.getByRole("switch", { name: "Configuration chat" })).toBeChecked();
await configSetup
.getByPlaceholder("Ask anything about your configuration...")
.fill("/e2e:simple-message");
await configSetup.getByRole("button", { name: "Start configuration chat" }).click();
await expect(configSetup).not.toBeVisible({ timeout: 15_000 });
await testPage.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${modifier}+Shift+q`);
await expect(dialog).toBeVisible({ timeout: 10_000 });
const configTab = dialog
.getByTestId("quick-chat-tab")
.filter({ has: testPage.getByRole("img", { name: "Configuration chat" }) });
await configTab.getByRole("button").first().click();
await expect(configTab).toHaveClass(/bg-background/);
await dialog.getByRole("button", { name: "Start new chat" }).click();
const newSetup = dialog.getByTestId("quick-chat-setup");
await expect(newSetup).toBeVisible();
await expect(newSetup.getByRole("switch", { name: "Configuration chat" })).toHaveCount(0);
});
test("resizes from either edge, restores width, and keeps tab actions adjacent", async ({
testPage,
}) => {
@@ -209,6 +249,9 @@ test.describe("Quick Chat", () => {
await expect(dialog.getByTestId("quick-chat-introduction")).toContainText(
"Chat with an agent about an idea, question, or codebase.",
);
await expect(dialog.getByTestId("quick-chat-introduction")).toContainText(
"Quick chats stay outside your task board.",
);
await expect(
dialog.getByText("Add repository context to focus on specific code and branches."),
).toBeVisible();
@@ -353,7 +396,7 @@ test.describe("Quick Chat", () => {
});
});
test("restores multiple chat tabs in creation order after reload", async ({
test("restores multiple chat tabs in newest-activity order after reload", async ({
testPage,
apiClient,
seedData,
@@ -398,9 +441,9 @@ test.describe("Quick Chat", () => {
const newChatBtn = dialog.getByLabel("Start new chat");
await newChatBtn.click();
// Setup should appear without the first-use introduction once a chat exists.
// Setup guidance remains visible for every new chat.
await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByTestId("quick-chat-introduction")).not.toBeVisible();
await expect(dialog.getByTestId("quick-chat-introduction")).toBeVisible();
await startQuickChatFromSetup(dialog, testPage);
// Send a message in the second tab using script mode.
@@ -443,16 +486,16 @@ test.describe("Quick Chat", () => {
const restoredTabs = restoredDialog.getByTestId("quick-chat-tab");
await expect(restoredTabs).toHaveCount(2);
await expect(restoredTabs.locator("span")).toHaveText(originalNames);
await expect(restoredTabs.locator("span")).toHaveText([...originalNames].reverse());
await expect(restoredDialog.getByTestId("quick-chat-setup")).not.toBeVisible();
await restoredTabs.nth(0).locator("button").first().click();
await expect(restoredDialog.getByText("first tab response", { exact: true })).toBeVisible({
await expect(restoredDialog.getByText("second tab response", { exact: true })).toBeVisible({
timeout: 10_000,
});
await restoredTabs.nth(1).locator("button").first().click();
await expect(restoredDialog.getByText("second tab response", { exact: true })).toBeVisible({
await expect(restoredDialog.getByText("first tab response", { exact: true })).toBeVisible({
timeout: 10_000,
});
});
@@ -68,6 +68,7 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
const setup = dialog.getByTestId("quick-chat-setup");
if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) {
await dialog.getByLabel("Start new chat").click();
await page.getByRole("menu", { name: "New chat" }).getByText("Quick chat").click();
}
await expect(setup).toBeVisible({ timeout: 5_000 });
@@ -1,95 +1,165 @@
import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
/**
* Config chat popover UI tests.
*/
async function openConfigChatFromSettings(page: Page): Promise<Locator> {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/settings/agents");
await page.waitForLoadState("networkidle");
expect(pageErrors, "settings page should render without client errors").toEqual([]);
const fab = page.getByRole("button", { name: "Configuration Chat" });
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const popover = page.getByTestId("config-chat-popover");
await expect(popover).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("dialog", { name: "Quick Chat" })).not.toBeVisible();
return popover;
}
test.describe("Config chat popover", () => {
test("opens popover, sends prompt, and shows agent response", async ({
testPage,
apiClient,
seedData,
}) => {
// Set the mock agent profile as the default config chat profile.
async function startConfigChat(dialog: Locator, prompt: string) {
const setup = dialog.getByTestId("config-chat-setup");
await expect(setup).toBeVisible({ timeout: 10_000 });
await expect(setup.getByText(/repositories/i)).toHaveCount(0);
const input = setup.getByPlaceholder("Ask anything about your configuration...");
await input.fill(prompt);
await setup.getByRole("button", { name: "Start configuration chat" }).click();
await expect(setup).not.toBeVisible({ timeout: 15_000 });
await expect(dialog.getByTestId("chat-input-editor")).toBeVisible({ timeout: 15_000 });
}
async function sendMessage(dialog: Locator, text: string) {
const editor = dialog.getByTestId("chat-input-editor");
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 });
await editor.fill(text);
await editor.press(`${modifier}+Enter`);
}
test.describe("Configuration Chat", () => {
test.beforeEach(async ({ apiClient, seedData }) => {
await apiClient.updateWorkspace(seedData.workspaceId, {
default_config_agent_profile_id: seedData.agentProfileId,
});
});
// Navigate to settings page.
await testPage.goto("/settings/agents");
await testPage.waitForLoadState("networkidle");
// Open the config chat popover.
const fab = testPage.getByRole("button", { name: "Configuration Chat" });
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const popover = testPage.locator("[data-radix-popper-content-wrapper]");
await expect(popover).toBeVisible({ timeout: 5_000 });
// Select profile if needed (appears when no default is set).
const profileCard = popover.getByText("Mock Default", { exact: false });
const textarea = popover.locator("textarea");
if (await profileCard.isVisible()) {
await profileCard.click();
await expect(textarea).toBeVisible({ timeout: 5_000 });
}
// Submit prompt.
await textarea.fill("Show me the current configuration");
await textarea.press("Enter");
// Wait for the tab to appear (means session was created).
// Use the tab bar specifically to avoid matching message text.
const tabBar = popover.locator(".scrollbar-hide");
await expect(tabBar.getByText("Show me the cu", { exact: false })).toBeVisible({
timeout: 15_000,
test("starts floating, expands the same session, restores, continues, and deletes", async ({
testPage,
}) => {
await testPage.setViewportSize({ width: 900, height: 520 });
const popover = await openConfigChatFromSettings(testPage);
const viewport = testPage.viewportSize();
const box = await popover.boundingBox();
expect(box).not.toBeNull();
expect(viewport).not.toBeNull();
expect(box!.width).toBeLessThan(viewport!.width * 0.7);
const setup = popover.getByTestId("config-chat-setup");
const input = setup.getByPlaceholder("Ask anything about your configuration...");
const scrollRegion = setup.getByTestId("config-chat-guidance");
await expect.poll(() => scrollRegion.evaluate((element) => element.scrollTop)).toBe(0);
await expect(setup.getByRole("heading", { name: "Configuration Chat" })).toHaveCount(0);
await expect(setup.getByRole("button", { name: "Cancel" })).toHaveCount(0);
await expect(setup.locator("footer")).toHaveCount(0);
const inputBox = await input.boundingBox();
expect(inputBox).not.toBeNull();
await expect(setup.getByRole("button", { name: "Start configuration chat" })).toBeInViewport({
ratio: 1,
});
// Wait for the agent response to appear in the chat area.
const chatArea = popover.locator(".chat-message-list");
await expect(chatArea.getByText("completed the analysis", { exact: false })).toBeVisible({
await startConfigChat(popover, "/e2e:simple-message");
await expect(popover.getByTestId("quick-chat-tab")).toHaveCount(0);
await expect(popover.getByRole("button", { name: "Start new configuration chat" })).toHaveCount(
0,
);
await expect(
popover.getByText("simple mock response for e2e testing", { exact: false }),
).toBeVisible({ timeout: 30_000 });
await popover.getByRole("button", { name: "Open in Quick Chat" }).click();
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 10_000 });
await expect(
dialog.getByText("simple mock response for e2e testing", { exact: false }),
).toBeVisible();
await testPage.reload();
await testPage.waitForLoadState("networkidle");
await expect(testPage.getByRole("dialog", { name: "Quick Chat" })).not.toBeVisible();
await testPage.getByRole("button", { name: "Configuration Chat" }).click();
const restored = testPage.getByTestId("config-chat-popover");
await expect(restored.getByTestId("chat-input-editor")).toBeVisible({ timeout: 10_000 });
await expect(
restored.getByText("simple mock response for e2e testing", { exact: false }),
).toBeVisible({ timeout: 20_000 });
await sendMessage(restored, 'e2e:message("continued config response")');
await expect(restored.getByText("continued config response", { exact: true })).toBeVisible({
timeout: 30_000,
});
await restored.getByRole("button", { name: "Open in Quick Chat" }).click();
const restoredDialog = testPage.getByRole("dialog", { name: "Quick Chat" });
await restoredDialog
.getByTestId("quick-chat-tab")
.getByRole("button", { name: /^Close / })
.click();
const deleteDialog = testPage.getByRole("alertdialog");
await expect(deleteDialog).toContainText("Delete Quick Chat?");
const deleteResponse = testPage.waitForResponse(
(response) => response.request().method() === "DELETE" && response.ok(),
);
await deleteDialog.getByRole("button", { name: "Delete" }).click();
await deleteResponse;
await expect(restoredDialog).not.toBeVisible();
await testPage.reload();
await testPage.waitForLoadState("networkidle");
await testPage.getByRole("button", { name: "Configuration Chat" }).click();
await expect(
testPage.getByTestId("config-chat-popover").getByTestId("config-chat-setup"),
).toBeVisible({ timeout: 10_000 });
});
test("opens config chat via command panel (Cmd+K)", async ({ testPage, apiClient, seedData }) => {
// Set the mock agent profile as the default config chat profile.
await apiClient.updateWorkspace(seedData.workspaceId, {
default_config_agent_profile_id: seedData.agentProfileId,
test("opens the same typed setup from the command palette", async ({ testPage }) => {
await testPage.goto("/");
await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 15_000 });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${modifier}+k`);
const palette = testPage.getByRole("dialog", { name: "Command Palette" });
await expect(palette).toBeVisible({ timeout: 5_000 });
await palette.locator("input").fill("Configuration Chat");
await palette.getByText("Configuration Chat", { exact: true }).click();
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog.getByTestId("config-chat-setup")).toBeVisible({ timeout: 10_000 });
await expect(dialog.getByRole("img", { name: "Configuration chat" })).toBeVisible();
});
test("keeps conversation context visible around an inline clarification", async ({
testPage,
}) => {
const popover = await openConfigChatFromSettings(testPage);
await startConfigChat(popover, 'e2e:message("context before clarification")');
const messageList = popover.locator(".chat-message-list");
await expect(
messageList.getByText("context before clarification", { exact: true }),
).toBeVisible({
timeout: 30_000,
});
// Navigate to the kanban page (not settings) — FAB is hidden here,
// but the command panel should still open config chat globally.
await testPage.goto("/");
await testPage.waitForLoadState("networkidle");
// Wait for the kanban board to render — networkidle alone fires before
// React hydration finishes, so the global Cmd+K listener may not be
// wired up yet under CI load.
await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 15_000 });
await sendMessage(popover, "/ask-single");
const clarification = popover.getByTestId("clarification-overlay-container");
await expect(clarification).toContainText("Which database", { timeout: 30_000 });
const historyBox = await messageList.boundingBox();
expect(historyBox).not.toBeNull();
expect(historyBox!.height).toBeGreaterThan(40);
await expect(
messageList.getByText("context before clarification", { exact: true }),
).toBeVisible();
// Open the command panel via Cmd+K. Retry once if the dialog doesn't
// appear: the listener may register a beat after hydration.
const modifier = process.platform === "darwin" ? "Meta" : "Control";
const cmdPanel = testPage.getByRole("dialog", { name: "Command Palette" });
await testPage.keyboard.press(`${modifier}+k`);
try {
await expect(cmdPanel).toBeVisible({ timeout: 5_000 });
} catch {
await testPage.keyboard.press(`${modifier}+k`);
await expect(cmdPanel).toBeVisible({ timeout: 5_000 });
}
// Type to find the config chat command.
await cmdPanel.locator("input").fill("Configuration Chat");
await expect(cmdPanel.getByText("Configuration Chat")).toBeVisible({ timeout: 5_000 });
// Select the command.
await testPage.keyboard.press("Enter");
// Command panel should close and config chat popover should open.
await expect(cmdPanel).not.toBeVisible({ timeout: 3_000 });
const popover = testPage.locator("[data-radix-popper-content-wrapper]");
await expect(popover).toBeVisible({ timeout: 5_000 });
await popover.getByRole("button", { name: "Collapse clarification" }).click();
await expect(clarification.getByText("Which database", { exact: false })).not.toBeVisible();
await popover.getByRole("button", { name: "Expand clarification" }).click();
await expect(clarification.getByText("Which database", { exact: false })).toBeVisible();
await clarification.getByText("PostgreSQL", { exact: true }).click();
await expect(clarification).not.toBeVisible({ timeout: 30_000 });
});
});
@@ -0,0 +1,60 @@
import { test, expect } from "../../fixtures/test-base";
test.describe("Configuration Chat on mobile", () => {
test("starts floating, expands full-screen, and answers a clarification", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.updateWorkspace(seedData.workspaceId, {
default_config_agent_profile_id: seedData.agentProfileId,
});
await testPage.goto("/settings/agents");
await testPage.getByRole("button", { name: "Configuration Chat" }).tap();
const popover = testPage.getByTestId("config-chat-popover");
await expect(popover).toBeVisible({ timeout: 10_000 });
const viewport = testPage.viewportSize();
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
expect(viewport).not.toBeNull();
expect(popoverBox!.width).toBeLessThanOrEqual(viewport!.width);
expect(popoverBox!.height).toBeLessThan(viewport!.height);
const setup = popover.getByTestId("config-chat-setup");
const input = setup.getByPlaceholder("Ask anything about your configuration...");
await expect(setup.getByRole("heading", { name: "Configuration Chat" })).toHaveCount(0);
await expect(setup.getByRole("button", { name: "Cancel" })).toHaveCount(0);
await expect(input).toBeInViewport({ ratio: 1 });
await expect(setup.getByRole("button", { name: "Start configuration chat" })).toBeInViewport({
ratio: 1,
});
await input.fill("/ask-single");
await setup.getByRole("button", { name: "Start configuration chat" }).tap();
await popover.getByRole("button", { name: "Open in Quick Chat" }).tap();
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 10_000 });
const box = await dialog.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(viewport!.width * 0.95);
expect(box!.height).toBeGreaterThanOrEqual(viewport!.height * 0.95);
await expect(dialog.getByRole("img", { name: "Configuration chat" })).toBeVisible();
const clarification = dialog.getByTestId("clarification-overlay-container");
await expect(clarification).toContainText("Which database", { timeout: 30_000 });
const collapse = dialog.getByRole("button", { name: "Collapse clarification" });
await expect(collapse).toBeVisible();
await collapse.tap();
await expect(dialog.getByRole("button", { name: "Expand clarification" })).toBeVisible();
await dialog.getByRole("button", { name: "Expand clarification" }).tap();
const answer = clarification.getByText("PostgreSQL", { exact: true });
await expect(answer).toBeVisible();
const answerBox = await answer.boundingBox();
expect(answerBox).not.toBeNull();
expect(answerBox!.x + answerBox!.width).toBeLessThanOrEqual(viewport!.width);
await answer.tap();
await expect(clarification).not.toBeVisible({ timeout: 30_000 });
});
});
@@ -0,0 +1,78 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const openQuickChat = vi.fn();
const WORKSPACE_ID = "workspace-1";
const ACTIVE_CONFIG_ID = "config-active";
let activeSessionId: string | null = null;
let sessions: Array<{
sessionId: string;
workspaceId: string;
kind: "chat" | "config";
}> = [];
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ openQuickChat, quickChat: { sessions, activeSessionId } }),
}));
import { useQuickChatLauncher } from "./use-quick-chat-launcher";
beforeEach(() => {
sessions = [];
activeSessionId = null;
openQuickChat.mockReset();
});
describe("useQuickChatLauncher typed sessions", () => {
it("opens an ordinary session from the generic quick chat launcher", () => {
sessions = [
{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat" },
{ sessionId: ACTIVE_CONFIG_ID, workspaceId: WORKSPACE_ID, kind: "config" },
];
activeSessionId = ACTIVE_CONFIG_ID;
const { result } = renderHook(() => useQuickChatLauncher(WORKSPACE_ID));
act(() => result.current());
expect(openQuickChat).toHaveBeenCalledWith("chat-1", WORKSPACE_ID, undefined, "chat");
});
it("opens the matching ordinary session when config and chat tabs coexist", () => {
sessions = [
{ sessionId: "config-1", workspaceId: WORKSPACE_ID, kind: "config" },
{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat" },
];
const { result } = renderHook(() =>
(useQuickChatLauncher as (...args: unknown[]) => () => void)(WORKSPACE_ID, "chat"),
);
act(() => result.current());
expect(openQuickChat).toHaveBeenCalledWith("chat-1", WORKSPACE_ID, undefined, "chat");
});
it("opens a typed config setup when no config session exists", () => {
sessions = [{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat" }];
const { result } = renderHook(() =>
(useQuickChatLauncher as (...args: unknown[]) => () => void)(WORKSPACE_ID, "config"),
);
act(() => result.current());
expect(openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID, undefined, "config");
});
it("prefers the active matching session over the first restored session", () => {
sessions = [
{ sessionId: "config-newest", workspaceId: WORKSPACE_ID, kind: "config" },
{ sessionId: ACTIVE_CONFIG_ID, workspaceId: WORKSPACE_ID, kind: "config" },
];
activeSessionId = ACTIVE_CONFIG_ID;
const { result } = renderHook(() => useQuickChatLauncher(WORKSPACE_ID, "config"));
act(() => result.current());
expect(openQuickChat).toHaveBeenCalledWith(ACTIVE_CONFIG_ID, WORKSPACE_ID, undefined, "config");
});
});
+20 -5
View File
@@ -1,26 +1,41 @@
import { useCallback } from "react";
import { useAppStore } from "@/components/state-provider";
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
/**
* Hook to handle opening quick chat.
* Just opens the modal - the user will select an agent from the picker.
*/
export function useQuickChatLauncher(workspaceId?: string | null) {
export function useQuickChatLauncher(
workspaceId?: string | null,
kind: QuickChatSessionKind = "chat",
) {
const openQuickChat = useAppStore((state) => state.openQuickChat);
const quickChatSessions = useAppStore((state) => state.quickChat.sessions);
const activeSessionId = useAppStore((state) => state.quickChat.activeSessionId);
const handleOpenQuickChat = useCallback(() => {
if (!workspaceId) return;
// If there's an existing session, open it. Otherwise just open the modal with agent picker
const existingSession = quickChatSessions.find((s) => s.workspaceId === workspaceId);
const matchingSessions = quickChatSessions.filter(
(session) => session.workspaceId === workspaceId && (session.kind ?? "chat") === kind,
);
const existingSession =
matchingSessions.find((session) => session.sessionId === activeSessionId) ??
matchingSessions[0];
if (existingSession) {
openQuickChat(existingSession.sessionId, workspaceId);
openQuickChat(
existingSession.sessionId,
workspaceId,
undefined,
existingSession.kind ?? "chat",
);
} else {
// Open modal without a session - will show agent picker
openQuickChat("", workspaceId);
openQuickChat("", workspaceId, undefined, kind);
}
}, [workspaceId, quickChatSessions, openQuickChat]);
}, [workspaceId, kind, quickChatSessions, activeSessionId, openQuickChat]);
return handleOpenQuickChat;
}
+5 -1
View File
@@ -143,7 +143,11 @@ function mergeQuickChatState(initialState: Partial<DefaultState>): DefaultState[
...quickChat,
sessions: initialState.quickChat.sessions.map((session) => {
const localName = storedNames[session.sessionId];
return localName ? { ...session, name: localName } : session;
return {
...session,
kind: session.kind ?? "chat",
...(localName ? { name: localName } : {}),
};
}),
};
}
+68 -7
View File
@@ -32,7 +32,9 @@ describe("hydrateUI — quick chat name overlay", () => {
quickChat: {
isOpen: false,
activeSessionId: null,
sessions: [{ sessionId: "sess-1", workspaceId: "ws-1", name: "Agent A - Chat 1" }],
sessions: [
{ sessionId: "sess-1", workspaceId: "ws-1", name: "Agent A - Chat 1", kind: "chat" },
],
},
});
});
@@ -46,13 +48,60 @@ describe("hydrateUI — quick chat name overlay", () => {
quickChat: {
isOpen: false,
activeSessionId: null,
sessions: [{ sessionId: "sess-2", workspaceId: "ws-1", name: "Agent A - Chat 1" }],
sessions: [
{ sessionId: "sess-2", workspaceId: "ws-1", name: "Agent A - Chat 1", kind: "chat" },
],
},
});
});
expect(result.quickChat.sessions[0].name).toBe("Agent A - Chat 1");
});
});
describe("hydrateUI — typed quick chat sessions", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("normalizes a legacy session without kind to an ordinary chat", () => {
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
hydrateUI(draft, {
quickChat: {
isOpen: false,
activeSessionId: null,
sessions: [
{
sessionId: "legacy",
workspaceId: "ws-1",
} as unknown as (typeof draft.quickChat.sessions)[number],
],
},
});
});
expect((result.quickChat.sessions[0] as { kind?: string }).kind).toBe("chat");
});
it("preserves a restored configuration session kind", () => {
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
hydrateUI(draft, {
quickChat: {
isOpen: false,
activeSessionId: null,
sessions: [
{
sessionId: "config-session",
workspaceId: "ws-1",
kind: "config",
} as (typeof draft.quickChat.sessions)[number],
],
},
});
});
expect((result.quickChat.sessions[0] as { kind?: string }).kind).toBe("config");
});
it("only overlays sessions that have a stored rename, leaving siblings untouched", () => {
window.localStorage.setItem(
@@ -66,8 +115,8 @@ describe("hydrateUI — quick chat name overlay", () => {
isOpen: false,
activeSessionId: null,
sessions: [
{ sessionId: "sess-a", workspaceId: "ws-1", name: "Original A" },
{ sessionId: "sess-b", workspaceId: "ws-1", name: "Original B" },
{ sessionId: "sess-a", workspaceId: "ws-1", name: "Original A", kind: "chat" },
{ sessionId: "sess-b", workspaceId: "ws-1", name: "Original B", kind: "chat" },
],
},
});
@@ -81,7 +130,9 @@ describe("hydrateUI — quick chat name overlay", () => {
draft.quickChat = {
isOpen: true,
activeSessionId: "stale-session",
sessions: [{ sessionId: "stale-session", workspaceId: "ws-1", name: "Stale" }],
sessions: [
{ sessionId: "stale-session", workspaceId: "ws-1", name: "Stale", kind: "chat" },
],
};
hydrateUI(draft, {
quickChat: {
@@ -114,8 +165,18 @@ describe("mergeInitialState — quick chat name overlay", () => {
isOpen: false,
activeSessionId: null,
sessions: [
{ sessionId: "sess-boot", workspaceId: "ws-1", name: "Backend task title" },
{ sessionId: "sess-other", workspaceId: "ws-1", name: "Other title" },
{
sessionId: "sess-boot",
workspaceId: "ws-1",
name: "Backend task title",
kind: "chat",
},
{
sessionId: "sess-other",
workspaceId: "ws-1",
name: "Other title",
kind: "chat",
},
],
},
});
+1 -1
View File
@@ -237,7 +237,7 @@ export function hydrateUI(draft: Draft<AppState>, state: Partial<AppState>): voi
const storedNames = getStoredQuickChatNames();
draft.quickChat.sessions = state.quickChat.sessions.map((s) => {
const local = storedNames[s.sessionId];
return local ? { ...s, name: local } : s;
return { ...s, kind: s.kind ?? "chat", ...(local ? { name: local } : {}) };
});
// Validate activeSessionId exists in sessions after merge
if (
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createUISlice } from "./ui-slice";
import { getQuickChatSetupSessionId } from "./quick-chat-session";
import type { UISlice } from "./types";
const WORKSPACE_A = "workspace-a";
const WORKSPACE_B = "workspace-b";
const SESSION_A = "session-a";
const SESSION_B = "session-b";
function makeStore() {
return create<UISlice>()(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
immer((...args) => ({ ...(createUISlice as any)(...args) })),
);
}
describe("typed quick chat sessions", () => {
it("registers a shared session without opening the Quick Chat dialog", () => {
const store = makeStore();
store.getState().addQuickChatSession(SESSION_A, WORKSPACE_A, "agent-a", "config");
store.getState().setQuickChatInitialPrompt(SESSION_A, "Configure my workflow");
expect(store.getState().quickChat).toMatchObject({
isOpen: false,
activeSessionId: SESSION_A,
sessions: [
{
sessionId: SESSION_A,
workspaceId: WORKSPACE_A,
kind: "config",
initialPrompt: "Configure my workflow",
},
],
});
});
it("opens a configuration setup tab in the unified quick chat store", () => {
const store = makeStore();
const setupId = getQuickChatSetupSessionId(WORKSPACE_A, "config");
store.getState().openQuickChat("", WORKSPACE_A, undefined, "config");
expect(store.getState().quickChat).toMatchObject({
isOpen: true,
activeSessionId: setupId,
sessions: [{ sessionId: setupId, workspaceId: WORKSPACE_A, kind: "config" }],
});
});
it("keeps ordinary and configuration sessions together without cross-workspace activation", () => {
const store = makeStore();
store.getState().openQuickChat("chat-a", WORKSPACE_A, "agent-a", "chat");
store.getState().openQuickChat("config-a", WORKSPACE_A, "agent-config", "config");
store.getState().openQuickChat("chat-b", WORKSPACE_B, "agent-b", "chat");
store.getState().setActiveQuickChatSession("config-a", WORKSPACE_B);
expect(store.getState().quickChat.sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ sessionId: "chat-a", kind: "chat" }),
expect.objectContaining({ sessionId: "config-a", kind: "config" }),
]),
);
expect(store.getState().quickChat.activeSessionId).toBe("chat-b");
});
it("keeps setup tabs distinct by workspace and kind", () => {
const store = makeStore();
store.getState().openQuickChat("", WORKSPACE_A, undefined, "config");
store.getState().openQuickChat("", WORKSPACE_A, undefined, "chat");
store.getState().openQuickChat("", WORKSPACE_B, undefined, "config");
const setupTabs = store.getState().quickChat.sessions;
expect(setupTabs).toHaveLength(3);
expect(new Set(setupTabs.map((session) => session.sessionId)).size).toBe(3);
expect(setupTabs).toEqual(
expect.arrayContaining([
expect.objectContaining({ workspaceId: WORKSPACE_A, kind: "config" }),
expect.objectContaining({ workspaceId: WORKSPACE_A, kind: "chat" }),
expect.objectContaining({ workspaceId: WORKSPACE_B, kind: "config" }),
]),
);
});
it("does not open the modal for an existing session from another workspace", () => {
const store = makeStore();
store.getState().openQuickChat(SESSION_A, WORKSPACE_A, undefined, "chat");
store.getState().closeQuickChat();
store.getState().openQuickChat(SESSION_A, WORKSPACE_B, undefined, "chat");
expect(store.getState().quickChat.isOpen).toBe(false);
expect(store.getState().quickChat.activeSessionId).toBe(SESSION_A);
});
it("does not activate a registered session from another workspace while quick chat is open", () => {
const store = makeStore();
store.getState().openQuickChat(SESSION_B, WORKSPACE_B, undefined, "chat");
store.getState().addQuickChatSession(SESSION_A, WORKSPACE_A, "agent-a", "config");
expect(store.getState().quickChat).toMatchObject({
isOpen: true,
activeSessionId: SESSION_B,
sessions: expect.arrayContaining([
expect.objectContaining({ sessionId: SESSION_A, workspaceId: WORKSPACE_A }),
expect.objectContaining({ sessionId: SESSION_B, workspaceId: WORKSPACE_B }),
]),
});
});
it("does not activate another workspace after the last local tab closes", () => {
const store = makeStore();
store.getState().openQuickChat(SESSION_B, WORKSPACE_B, undefined, "chat");
store.getState().openQuickChat(SESSION_A, WORKSPACE_A, undefined, "config");
store.getState().closeQuickChatSession(SESSION_A);
expect(store.getState().quickChat).toMatchObject({
isOpen: false,
activeSessionId: null,
sessions: [expect.objectContaining({ sessionId: SESSION_B })],
});
});
});
describe("configuration quick chat uniqueness", () => {
it("opens the existing configuration session instead of creating another setup", () => {
const store = makeStore();
store.getState().addQuickChatSession(SESSION_A, WORKSPACE_A, "agent-a", "config");
store.getState().openQuickChat("", WORKSPACE_A, undefined, "config");
expect(store.getState().quickChat).toMatchObject({
isOpen: true,
activeSessionId: SESSION_A,
sessions: [expect.objectContaining({ sessionId: SESSION_A, kind: "config" })],
});
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { getQuickChatSetupSessionId, isQuickChatSetupSessionId } from "./quick-chat-session";
describe("quick chat setup session IDs", () => {
it.each(["chat", "config"] as const)("recognizes a generated %s setup ID", (kind) => {
expect(isQuickChatSetupSessionId(getQuickChatSetupSessionId("workspace-1", kind))).toBe(true);
});
it.each([
"quick-chat-setup:",
"quick-chat-setup:persisted-session",
"quick-chat-setup:workspace-1:other",
"quick-chat-setup::chat",
])("does not classify persisted session ID %s as setup", (sessionId) => {
expect(isQuickChatSetupSessionId(sessionId)).toBe(false);
});
});
@@ -0,0 +1,15 @@
import type { QuickChatSessionKind } from "./types";
const SETUP_SESSION_PREFIX = "quick-chat-setup:";
const SETUP_SESSION_ID_PATTERN = /^quick-chat-setup:.+:(?:chat|config)$/;
export function getQuickChatSetupSessionId(
workspaceId: string,
kind: QuickChatSessionKind,
): string {
return `${SETUP_SESSION_PREFIX}${workspaceId}:${kind}`;
}
export function isQuickChatSetupSessionId(sessionId: string): boolean {
return SETUP_SESSION_ID_PATTERN.test(sessionId);
}
+21 -23
View File
@@ -68,28 +68,21 @@ export type SystemHealthState = {
loading: boolean;
};
export type QuickChatState = {
isOpen: boolean;
sessions: Array<{
sessionId: string;
workspaceId: string;
name?: string;
agentProfileId?: string;
}>;
activeSessionId: string | null;
};
export type QuickChatSessionKind = "chat" | "config";
export type ConfigChatSession = {
export type QuickChatSession = {
kind: QuickChatSessionKind;
sessionId: string;
workspaceId: string;
name?: string;
agentProfileId?: string;
initialPrompt?: string;
};
export type ConfigChatState = {
export type QuickChatState = {
isOpen: boolean;
sessions: ConfigChatSession[];
sessions: QuickChatSession[];
activeSessionId: string | null;
workspaceId: string | null;
};
export type SessionFailureNotification = {
@@ -152,7 +145,6 @@ export type UISliceState = {
documentPanel: DocumentPanelState;
systemHealth: SystemHealthState;
quickChat: QuickChatState;
configChat: ConfigChatState;
sessionFailureNotification: SessionFailureNotification | null;
/** Set when the focused task is deleted live, so a toast can explain why. */
taskDeletedNotification: TaskDeletedNotification | null;
@@ -200,17 +192,23 @@ export type UISliceActions = {
setSystemHealth: (response: SystemHealthResponse) => void;
setSystemHealthLoading: (loading: boolean) => void;
invalidateSystemHealth: () => void;
openQuickChat: (sessionId: string, workspaceId: string, agentProfileId?: string) => void;
openQuickChat: (
sessionId: string,
workspaceId: string,
agentProfileId?: string,
kind?: QuickChatSessionKind,
) => void;
addQuickChatSession: (
sessionId: string,
workspaceId: string,
agentProfileId?: string,
kind?: QuickChatSessionKind,
) => void;
closeQuickChat: () => void;
closeQuickChatSession: (sessionId: string) => void;
setActiveQuickChatSession: (sessionId: string) => void;
setActiveQuickChatSession: (sessionId: string, workspaceId: string) => void;
renameQuickChatSession: (sessionId: string, name: string) => void;
openConfigChat: (sessionId: string, workspaceId: string) => void;
startNewConfigChat: (workspaceId: string) => void;
closeConfigChat: () => void;
closeConfigChatSession: (sessionId: string) => void;
setActiveConfigChatSession: (sessionId: string) => void;
renameConfigChatSession: (sessionId: string, name: string) => void;
setQuickChatInitialPrompt: (sessionId: string, prompt?: string) => void;
setSessionFailureNotification: (n: SessionFailureNotification | null) => void;
setTaskDeletedNotification: (n: TaskDeletedNotification | null) => void;
toggleBottomTerminal: () => void;
+74 -74
View File
@@ -18,6 +18,7 @@ import { DEFAULT_VIEW } from "./sidebar-view-builtins";
import type { SidebarView, SortSpec } from "./sidebar-view-types";
import type { SystemHealthResponse } from "@/lib/types/health";
import type { ActiveDocument, UISlice, UISliceState } from "./types";
import { getQuickChatSetupSessionId } from "./quick-chat-session";
function createDefaultSidebarState(): UISliceState["sidebarViews"] {
return { views: [DEFAULT_VIEW], activeViewId: DEFAULT_VIEW.id, draft: null, syncError: null };
@@ -76,7 +77,6 @@ export const defaultUIState: UISliceState = {
documentPanel: { activeDocumentBySessionId: {} },
systemHealth: { issues: [], checks: [], healthy: true, loaded: false, loading: false },
quickChat: { isOpen: false, sessions: [], activeSessionId: null },
configChat: { isOpen: false, sessions: [], activeSessionId: null, workspaceId: null },
sessionFailureNotification: null,
taskDeletedNotification: null,
bottomTerminal: { isOpen: false, pendingCommand: null },
@@ -229,58 +229,6 @@ function buildCollapsedSubtaskActions(set: ImmerSet, get: () => UISlice) {
};
}
function buildConfigChatActions(set: ImmerSet) {
return {
openConfigChat: (sessionId: string, workspaceId: string) =>
set((draft) => {
draft.configChat.isOpen = true;
draft.configChat.workspaceId = workspaceId;
const exists = draft.configChat.sessions.some((s) => s.sessionId === sessionId);
if (!exists) {
draft.configChat.sessions.push({ sessionId, workspaceId });
}
draft.configChat.activeSessionId = sessionId;
}),
startNewConfigChat: (workspaceId: string) =>
set((draft) => {
draft.configChat.isOpen = true;
draft.configChat.activeSessionId = null;
draft.configChat.workspaceId = workspaceId;
}),
closeConfigChat: () =>
set((draft) => {
draft.configChat.isOpen = false;
}),
closeConfigChatSession: (sessionId: string) =>
set((draft) => {
draft.configChat.sessions = draft.configChat.sessions.filter(
(s) => s.sessionId !== sessionId,
);
if (draft.configChat.activeSessionId === sessionId) {
if (draft.configChat.sessions.length > 0) {
const next = draft.configChat.sessions[0];
draft.configChat.activeSessionId = next.sessionId;
draft.configChat.workspaceId = next.workspaceId;
} else {
draft.configChat.activeSessionId = null;
draft.configChat.workspaceId = null;
}
}
}),
setActiveConfigChatSession: (sessionId: string) =>
set((draft) => {
draft.configChat.activeSessionId = sessionId;
}),
renameConfigChatSession: (sessionId: string, name: string) =>
set((draft) => {
const session = draft.configChat.sessions.find((s) => s.sessionId === sessionId);
if (session) {
session.name = name;
}
}),
};
}
function buildNotificationActions(set: ImmerSet) {
return {
setSessionFailureNotification: (n: UISlice["sessionFailureNotification"]) =>
@@ -294,40 +242,86 @@ function buildNotificationActions(set: ImmerSet) {
};
}
function buildQuickChatActions(set: ImmerSet) {
return {
openQuickChat: (sessionId: string, workspaceId: string, agentProfileId?: string) =>
set((draft) => {
draft.quickChat.isOpen = true;
if (!sessionId) {
// Blank tabs have no stable ID, so keep one scoped to the workspace being opened.
draft.quickChat.sessions = draft.quickChat.sessions.filter(
(session) => session.sessionId !== "" || session.workspaceId === workspaceId,
);
const emptyTabExists = draft.quickChat.sessions.some(
(session) => session.sessionId === "" && session.workspaceId === workspaceId,
);
if (!emptyTabExists) draft.quickChat.sessions.push({ sessionId: "", workspaceId });
draft.quickChat.activeSessionId = "";
function findWorkspaceConfigSession(
sessions: UISliceState["quickChat"]["sessions"],
workspaceId: string,
) {
return sessions.find(
(session) => session.workspaceId === workspaceId && session.kind === "config",
);
}
function buildOpenQuickChatAction(set: ImmerSet) {
return (
sessionId: string,
workspaceId: string,
agentProfileId?: string,
kind: "chat" | "config" = "chat",
) =>
set((draft) => {
if (!sessionId) {
const existingConfigSession =
kind === "config"
? findWorkspaceConfigSession(draft.quickChat.sessions, workspaceId)
: undefined;
if (existingConfigSession) {
draft.quickChat.isOpen = true;
draft.quickChat.activeSessionId = existingConfigSession.sessionId;
return;
}
const setupSessionId = getQuickChatSetupSessionId(workspaceId, kind);
if (!draft.quickChat.sessions.some((session) => session.sessionId === setupSessionId)) {
draft.quickChat.sessions.push({ sessionId: setupSessionId, workspaceId, kind });
}
draft.quickChat.isOpen = true;
draft.quickChat.activeSessionId = setupSessionId;
return;
}
const existing = draft.quickChat.sessions.find((session) => session.sessionId === sessionId);
if (existing) {
if (existing.workspaceId !== workspaceId) return;
if (agentProfileId) existing.agentProfileId = agentProfileId;
} else {
draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId, kind });
}
draft.quickChat.isOpen = true;
draft.quickChat.activeSessionId = sessionId;
});
}
function buildQuickChatActions(set: ImmerSet) {
return {
addQuickChatSession: (
sessionId: string,
workspaceId: string,
agentProfileId?: string,
kind: "chat" | "config" = "chat",
) =>
set((draft) => {
const activeWorkspaceId = draft.quickChat.sessions.find(
(session) => session.sessionId === draft.quickChat.activeSessionId,
)?.workspaceId;
const shouldActivate =
!draft.quickChat.isOpen || !activeWorkspaceId || activeWorkspaceId === workspaceId;
const existing = draft.quickChat.sessions.find(
(session) => session.sessionId === sessionId,
);
if (existing) {
if (existing.workspaceId !== workspaceId) return;
if (agentProfileId) existing.agentProfileId = agentProfileId;
} else {
draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId });
draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId, kind });
}
draft.quickChat.activeSessionId = sessionId;
if (shouldActivate) draft.quickChat.activeSessionId = sessionId;
}),
openQuickChat: buildOpenQuickChatAction(set),
closeQuickChat: () =>
set((draft) => {
draft.quickChat.isOpen = false;
}),
closeQuickChatSession: (sessionId: string) =>
set((draft) => {
const closedSession = draft.quickChat.sessions.find(
const closingSession = draft.quickChat.sessions.find(
(session) => session.sessionId === sessionId,
);
draft.quickChat.sessions = draft.quickChat.sessions.filter(
@@ -335,13 +329,15 @@ function buildQuickChatActions(set: ImmerSet) {
);
if (draft.quickChat.activeSessionId !== sessionId) return;
const nextSession = draft.quickChat.sessions.find(
(session) => session.workspaceId === closedSession?.workspaceId,
(session) => session.workspaceId === closingSession?.workspaceId,
);
draft.quickChat.activeSessionId = nextSession?.sessionId ?? null;
if (!nextSession) draft.quickChat.isOpen = false;
}),
setActiveQuickChatSession: (sessionId: string) =>
setActiveQuickChatSession: (sessionId: string, workspaceId: string) =>
set((draft) => {
const session = draft.quickChat.sessions.find((item) => item.sessionId === sessionId);
if (!session || session.workspaceId !== workspaceId) return;
draft.quickChat.activeSessionId = sessionId;
}),
renameQuickChatSession: (sessionId: string, name: string) => {
@@ -355,6 +351,11 @@ function buildQuickChatActions(set: ImmerSet) {
});
if (renamed) setStoredQuickChatName(sessionId, name);
},
setQuickChatInitialPrompt: (sessionId: string, prompt?: string) =>
set((draft) => {
const session = draft.quickChat.sessions.find((item) => item.sessionId === sessionId);
if (session) session.initialPrompt = prompt;
}),
};
}
@@ -372,7 +373,6 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
...buildPreviewActions(set),
...buildMobileActions(set),
...buildBottomTerminalActions(set),
...buildConfigChatActions(set),
...buildSidebarViewActions(set, get),
...buildSidebarTaskPrefsActions(set, get),
...buildCollapsedSubtaskActions(set, get),
+9 -9
View File
@@ -235,7 +235,6 @@ export type AppState = {
documentPanel: (typeof defaultUIState)["documentPanel"];
systemHealth: (typeof defaultUIState)["systemHealth"];
quickChat: (typeof defaultUIState)["quickChat"];
configChat: (typeof defaultUIState)["configChat"];
sessionFailureNotification: (typeof defaultUIState)["sessionFailureNotification"];
taskDeletedNotification: (typeof defaultUIState)["taskDeletedNotification"];
bottomTerminal: (typeof defaultUIState)["bottomTerminal"];
@@ -379,17 +378,18 @@ export type AppState = {
setSystemHealth: (response: SystemHealthResponse) => void;
setSystemHealthLoading: (loading: boolean) => void;
invalidateSystemHealth: () => void;
openQuickChat: (sessionId: string, workspaceId: string, agentProfileId?: string) => void;
openQuickChat: (
sessionId: string,
workspaceId: string,
agentProfileId?: string,
kind?: UISliceTypes.QuickChatSessionKind,
) => void;
addQuickChatSession: UIA["addQuickChatSession"];
closeQuickChat: () => void;
closeQuickChatSession: (sessionId: string) => void;
setActiveQuickChatSession: (sessionId: string) => void;
setActiveQuickChatSession: (sessionId: string, workspaceId: string) => void;
renameQuickChatSession: (sessionId: string, name: string) => void;
openConfigChat: (sessionId: string, workspaceId: string) => void;
startNewConfigChat: (workspaceId: string) => void;
closeConfigChat: () => void;
closeConfigChatSession: (sessionId: string) => void;
setActiveConfigChatSession: (sessionId: string) => void;
renameConfigChatSession: (sessionId: string, name: string) => void;
setQuickChatInitialPrompt: UIA["setQuickChatInitialPrompt"];
setSessionFailureNotification: (n: UISliceTypes.SessionFailureNotification | null) => void;
setTaskDeletedNotification: (n: UISliceTypes.TaskDeletedNotification | null) => void;
toggleBottomTerminal: () => void;
@@ -130,9 +130,7 @@ export function maybeEmitEmptyTurnNotice(
): void {
const state = store.getState();
const sid = payload.session_id;
const isEphemeralSurface =
state.quickChat.sessions.some((s) => s.sessionId === sid) ||
state.configChat.sessions.some((s) => s.sessionId === sid);
const isEphemeralSurface = state.quickChat.sessions.some((s) => s.sessionId === sid);
const notice = computeEmptyTurnNotice({
sessionId: sid,
@@ -0,0 +1,80 @@
# ADR-2026-07-14-typed-utility-chat-sessions: Typed Utility Chats Share the Quick Chat Session Model
**Status:** accepted
**Date:** 2026-07-14
**Area:** backend, frontend
## Context
Ordinary quick chats and configuration chats both use ephemeral tasks and the shared chat shell,
but they maintain separate frontend tab stores and presentation containers. Configuration tabs
exist only in memory, so reload makes their still-persisted tasks unreachable, and their fixed
popover constrains reports and inline clarification questions. Config-mode tasks are intentionally
excluded from ordinary quick-chat expiration, making client-only tab closure an unbounded orphan
risk.
## Decision
All user-facing utility conversations use one typed Quick Chat session model with
`kind: "chat" | "config"` and one tab store. `QuickChatContent` remains the single
message/composer/clarification renderer. The responsive Quick Chat modal is the primary large
surface; Settings also provides a compact floating configuration presentation over the same typed
sessions. Expanding the floating presentation activates the same setup or persisted session in the
modal rather than creating or copying a conversation. The floating presentation has no tab model:
it displays the workspace's single current configuration setup or session. Configuration entry
points reuse that session while it exists, and ordinary Quick Chat setup is the only place that
offers configuration creation. Blank setup tabs are
typed client-only placeholders with workspace-and-kind-scoped IDs; persisted tabs map to existing
task sessions. Setup IDs never enter backend hydration and prevent blank ordinary/config tabs from
aliasing each other while retaining client-side cleanup for abandoned setup.
The backend remains authoritative for capability classification. Boot restoration derives
`kind="config"` only from `task.metadata.config_mode == true`; it never infers privileges from a
title or frontend input. The dedicated `/config-chat` endpoint remains responsible for config-mode
MCP preparation, while `/quick-chat` retains ordinary and repository-backed behavior. A missing
`kind` in an older frontend/boot shape normalizes to `chat`.
Boot restoration lists workflow-less, non-automation ephemeral tasks for the active workspace,
hydrates their primary task sessions, assigns their kind, and sorts them by last activity. The
modal and active tab remain closed/unset at boot. Workspace filtering is enforced at restoration,
provider selection, launch, and activation boundaries.
Real tab closure uses the existing Quick Chat confirmation and task deletion lifecycle for both
kinds. Closing the modal or switching tabs is presentation-only. Configuration tasks remain
excluded from the seven-day ordinary-chat sweeper, so explicit tab/workspace deletion is their
cleanup owner.
Inline clarifications stay inside `QuickChatContent` with a bounded scroll region, touch-capable
resize handling, and a collapse/expand affordance. This preserves message context without a nested
dialog on desktop or mobile.
## Consequences
Configuration conversations become durable and readable without a schema migration or a second
chat implementation. Ordinary and configuration sessions can coexist while preserving distinct
setup and backend capability contracts. The floating Settings panel owns only presentation state;
shared session state, pending initial prompts, messages, and task identity must remain transferable.
Tab and hydration code must carry an explicit kind, and workspace transitions must filter rather
than assume all hydrated sessions share the current workspace. Supporting multiple configuration
sessions later requires a deliberate product change because the current creation surfaces and
floating presentation enforce one per workspace.
Config sessions have no automatic idle cleanup. Any creation, delete, or workspace-removal path
must continue to use task lifecycle services so config-mode tasks cannot become hidden orphans.
The separate config-chat tab state is removed. The Settings provider owns the floating panel's open
and selected-view state while delegating durable sessions and task cleanup to the shared model.
## Alternatives Considered
- **Persist a separate configuration-popover tab model.** Rejected because it creates competing
stores and duplicates restoration, prompt delivery, and task lifecycle behavior. The accepted
floating panel is only a presentation over the unified typed-session store.
- **Infer configuration sessions from task titles.** Rejected because titles are mutable display
data and must not control elevated backend capabilities.
- **Add a dedicated configuration-tab table.** Rejected because existing task metadata, primary
sessions, and boot hydration contain the required durable state.
- **Include configuration tasks in the seven-day quick-chat sweeper.** Rejected because config
sessions intentionally have explicit lifecycle semantics and may be durable administrative
conversations.
- **Render clarification in a nested modal.** Rejected because it obscures the conversation context
needed to answer the question.
+1
View File
@@ -49,6 +49,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted | backend, frontend | 2026-07-15 |
| 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 |
| 0043 | [Plugins read/write kandev data via capability-gated Host gRPC RPCs](0043-plugin-host-data-api.md) | accepted | backend, protocol | 2026-07-17 |
| 2026-07-14-typed-utility-chat-sessions | [Typed Utility Chats Share the Quick Chat Session Model](2026-07-14-typed-utility-chat-sessions.md) | accepted | backend, frontend | 2026-07-14 |
| 2026-07-15-office-agent-execution-profile-routing | [Separate Office identity from routed execution profiles](2026-07-15-office-agent-execution-profile-routing.md) | proposed | backend, frontend | 2026-07-15 |
| 0044 | [ACP agent compatibility dialects](0044-acp-agent-compatibility-dialects.md) | accepted | backend, protocol | 2026-07-16 |
| 0045 | [Install-wide storage maintenance uses typed ownership providers and quarantine](0045-install-wide-storage-maintenance.md) | accepted | backend, frontend, infra | 2026-07-14 |
@@ -0,0 +1,117 @@
---
spec: docs/specs/tasks/quick-chat-expiration.md
created: 2026-07-14
status: implemented
---
# Implementation Plan: Typed Quick Chat Sessions
## Overview
Unify ordinary and configuration utility chats in one typed session store. The backend first
restores both kinds with a metadata-derived discriminator; the frontend then shares those sessions
between the Quick Chat modal and a compact Settings configuration panel, and the shared
clarification region gains a collapse control. Focused unit/integration tests precede production
changes, followed by desktop and mobile Playwright coverage and full verification.
## Backend
### Boot restoration contract
- Update `apps/backend/internal/backendapp/boot_state.go` so the boot query includes config-mode
workflow-less ephemeral tasks while still excluding automation-run and workflow-bound tasks.
- Add `kind` to each mapped session. Derive `config` only from `task.Metadata[MetaKeyConfigMode]`;
all other eligible tasks map to `chat`.
- Keep active-workspace filtering, primary-session hydration, newest-activity-first ordering, and
closed/unset modal state.
- Extend `apps/backend/internal/backendapp/helpers_test.go` with ordinary/config kind, ordering,
exclusion, primary-session hydration, and workspace-boundary assertions.
## Frontend
### Typed state and hydration
- Define `QuickChatSessionKind` and `QuickChatSession` in
`apps/web/lib/state/slices/ui/types.ts`; remove the independent config-chat state/actions.
- Extend Quick Chat actions to open typed real/setup sessions and guard workspace activation.
- Normalize absent boot kinds to `chat` in `apps/web/lib/state/default-state.ts` and
`apps/web/lib/state/hydration/hydrator.ts` for backward compatibility.
- Cover store actions and hydration in focused Vitest tests.
### Unified launch, setup, and lifecycle
- Refactor `apps/web/components/config-chat/` so the Settings panel and Quick Chat modal use the
same typed config sessions, task lifecycle, and conversation renderer.
- Move config setup presentation into the Quick Chat modal, preserving the configuration profile,
introduction, suggestions, placeholder, default-profile update, config endpoint, task-session seed,
passthrough detection, and send-after-subscription initial-prompt behavior.
- Extend `use-quick-chat-modal.ts`, `quick-chat-modal.tsx`, and tab components for typed sessions,
accessible config indicators, kind-specific content/toolbar, one explicit delete lifecycle, blank
setup cleanup, and workspace changes. Keep the existing `+` action defaulting to ordinary setup,
with an explicit ordinary/configuration mode selector before creation.
- Keep the Settings FAB floating workflow and add a handoff that activates the same setup/session in
the large modal. Keep Command Palette launching the typed modal directly.
### Clarification presentation
- Keep clarification inline in `QuickChatContent`, bounded and scrollable above the composer.
- Add an accessible collapse/expand affordance that preserves the pending question and restores the
previous region without hiding message history or primary actions on mobile.
- Add focused tests for any extracted state/helper logic and rely on Playwright for rendered layout.
## Tests
- **Boot kind and ordering:** `apps/backend/internal/backendapp/helpers_test.go`; SQLite-backed boot
payload test with ordinary/config tasks and different activity times.
- **Exclusions and workspace scope:** same file; automation-run, workflow-bound, missing-primary,
archived, and foreign-workspace tasks do not restore.
- **Backward-compatible hydration:** `apps/web/lib/state/hydration/hydrator.test.ts` and/or
`apps/web/lib/state/default-state.test.ts`; missing `kind` becomes `chat`.
- **Typed store actions:** existing/new UI slice tests; config/chat setup and real sessions coexist,
cross-workspace activation is rejected, and blank close is client-only.
- **Launch/delete/initial prompt:** `apps/web/components/quick-chat/use-quick-chat-modal.test.ts` and
config launch hook tests; config endpoint/session seeding, exactly-once prompt delivery, passthrough
lookup, and confirmed task deletion.
## E2E Tests
- Update `apps/web/e2e/tests/settings/config-chat-popover.spec.ts` with a desktop shared-session flow:
floating Settings launch, modal handoff, config indicator, send/response, refresh/reopen,
continue, and confirmed deletion. Command Palette continues to launch the modal directly.
- Add clarification coverage proving the message list remains visible and the bottom region can be
collapsed, expanded, resized/scrolled, and answered.
- Add `apps/web/e2e/tests/settings/mobile-configuration-chat.spec.ts` for viewport-bounded floating
launch, full-screen handoff, config indicator, clarification controls, and unclipped actions.
- Re-run ordinary repository-backed Quick Chat desktop/mobile specs from PR #1679.
## Implementation Waves
Wave 1 (parallel after approval):
- [x] [Task 01: Backend boot contract](task-01-backend-boot-contract.md) (`done`)
- [x] [Task 03: Clarification responsive UX](task-03-clarification-responsive-ux.md) (`done`)
Wave 2:
- [x] [Task 02: Unified frontend sessions](task-02-unified-frontend-sessions.md) (`done`)
Wave 3:
- [x] [Task 04: E2E and verification](task-04-e2e-and-verification.md) (`done`)
## Verification
Targeted commands and results are recorded in each task. Final repository verification ran from the
repository root in the required order: `make fmt`, `make typecheck`, `make test`, `make lint`.
Affected E2E runs used the managed runner from `apps/web`, including desktop config/ordinary Quick
Chat and the `mobile-chrome` configuration/repository specs.
## Risks
- Blank setup tabs use deterministic workspace-and-kind-scoped IDs; typed setup actions must replace
or close the matching placeholder without leaking abandoned client-only sessions.
- Config initial prompts must remain component/session scoped so hydration or tab switching cannot
replay them.
- Failed task deletion must remain recoverable on reload; client removal cannot become the only
record of a persistent config task.
- Boot hydration and providers must always scope sessions to the active workspace.
@@ -0,0 +1,42 @@
---
id: "01-backend-boot-contract"
title: "Backend boot contract"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/quick-chat-expiration.md"
---
# Task 01: Backend Boot Contract
## Acceptance
- Boot restores eligible ordinary and config sessions with metadata-derived `chat`/`config` kinds,
hydrated primary sessions, and newest-activity-first order.
- Automation-run, workflow-bound, archived, missing-primary, and foreign-workspace tasks are absent.
- The modal hydrates closed with no active session and no schema change.
## Verification
`cd apps/backend && go test ./internal/backendapp -run 'TestBootPayloadRestoresQuickChatSessions'`
## Files Likely Touched
- `apps/backend/internal/backendapp/boot_state.go`
- `apps/backend/internal/backendapp/helpers_test.go`
## Inputs
- Spec: Data model, API surface, persistence scenarios.
- ADR-2026-07-14-typed-utility-chat-sessions metadata-derived typing and workspace isolation.
- Existing `quickChatSessions`, `listQuickChatTasks`, and boot harness patterns.
## Dependencies
None.
## Output Contract
Report behavior changed, files touched, red/green test commands, blockers, residual risks, and mark
this task `done` in this file and `plan.md`.
@@ -0,0 +1,61 @@
---
id: "02-unified-frontend-sessions"
title: "Unified frontend sessions"
status: done
wave: 2
depends_on: ["01-backend-boot-contract"]
plan: "plan.md"
spec: "../../specs/tasks/quick-chat-expiration.md"
---
# Task 02: Unified Frontend Sessions
## Acceptance
- Settings FAB opens a floating config setup/session that can transfer into the responsive Quick
Chat modal; Command Palette opens the modal directly. Config setup preserves its
profile/copy/suggestions/placeholder and has no repository controls.
- Ordinary Quick Chat setup exposes an explicit ordinary/configuration mode choice before task
creation.
- One typed Quick Chat store owns ordinary/config real and setup tabs; older missing kinds normalize
to `chat`, workspace changes cannot activate foreign sessions, and passthrough remains supported.
- Config start seeds the task session and delivers its initial prompt once; confirmed real-tab close
deletes the backing task while modal close, tab switch, and blank close do not.
## Verification
`cd apps && pnpm --filter @kandev/web test -- --run lib/state/hydration/hydrator.test.ts components/quick-chat/use-quick-chat-modal.test.ts components/quick-chat/quick-chat-modal.test.ts`
`cd apps/web && pnpm run typecheck`
## Files Likely Touched
- `apps/web/lib/state/slices/ui/types.ts`
- `apps/web/lib/state/slices/ui/ui-slice.ts`
- `apps/web/lib/state/default-state.ts`
- `apps/web/lib/state/hydration/hydrator.ts`
- `apps/web/lib/state/hydration/hydrator.test.ts`
- `apps/web/components/quick-chat/use-quick-chat-modal.ts`
- `apps/web/components/quick-chat/use-quick-chat-modal.test.ts`
- `apps/web/components/quick-chat/quick-chat-modal.tsx`
- `apps/web/components/quick-chat/quick-chat-tab-item.tsx`
- `apps/web/components/quick-chat/quick-chat-setup.tsx`
- `apps/web/components/config-chat/config-chat-provider.tsx`
- `apps/web/components/config-chat/config-chat-panel.tsx`
- `apps/web/components/config-chat/use-config-chat.ts`
- `apps/web/components/global-commands.tsx`
## Inputs
- Spec: What, state machine, failure modes, workspace and backward-compatibility scenarios.
- Task 01 boot session shape.
- Existing ordinary Quick Chat setup/delete and config start/profile-default patterns.
## Dependencies
Task 01.
## Output Contract
Report behavior changed, files touched, red/green test commands, blockers, residual risks, and mark
this task `done` in this file and `plan.md`.
@@ -0,0 +1,55 @@
---
id: "03-clarification-responsive-ux"
title: "Clarification responsive UX"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/quick-chat-expiration.md"
---
# Task 03: Clarification Responsive UX
## Acceptance
- A pending clarification remains an inline, bounded, scrollable bottom region while message history
retains meaningful visible space.
- An accessible collapse/expand control works without resolving or dismissing the question and is
usable at narrow mobile widths.
- Existing resize and answer behavior remains intact.
## Verification
`cd apps && pnpm --filter @kandev/web test -- --run components/task/chat/clarification-custom-input.test.tsx`
`cd apps/web && pnpm run typecheck`
## Files Likely Touched
- `apps/web/components/quick-chat/quick-chat-content.tsx`
- `apps/web/components/task/chat/clarification-input-overlay.tsx`
- `apps/web/hooks/use-resizable-clarification-overlay.ts`
- Focused adjacent test files for extracted logic, if needed.
## Inputs
- Spec: Clarification and mobile scenarios.
- Existing `ResizeHandle`, `useResizableClarificationOverlay`, and clarification overlay patterns.
## Dependencies
None. Coordinate any shared `quick-chat-content.tsx` edit before Task 02 integration.
## Output Contract
Report behavior changed, files touched, red/green test commands, blockers, residual risks, and mark
this task `done` in this file and `plan.md`.
## Result
- Quick Chat clarifications now have a 44px accessible collapse/expand control. Collapsing preserves
the pending question, selected answers, custom drafts, and the prior user-resized height.
- The expanded header and scrollable body share a `50vh` cap, leaving message history visible on
desktop and mobile. Hidden carousel keyboard shortcuts are disabled while collapsed.
- Focused clarification tests (23), frontend typecheck, zero-warning focused lint, and diff checks
pass. Desktop/mobile rendered verification remains in Task 04.
@@ -0,0 +1,74 @@
---
id: "04-e2e-and-verification"
title: "E2E and verification"
status: done
wave: 3
depends_on: ["02-unified-frontend-sessions", "03-clarification-responsive-ux"]
plan: "plan.md"
spec: "../../specs/tasks/quick-chat-expiration.md"
---
# Task 04: E2E and Verification
## Acceptance
- Desktop E2E covers config launch, large modal, send/receive, refresh restoration, continuation,
clarification context/controls, and config deletion.
- Mobile E2E covers full-screen launch, config indicator, clarification controls, and unclipped
primary actions; ordinary repository-backed Quick Chat remains green on desktop and mobile.
- Required formatting, typecheck, test, and lint commands pass in order and exact results are recorded.
## Verification
- `cd apps/web && pnpm e2e:run e2e/tests/settings/config-chat-popover.spec.ts`
- Passed: 3 desktop tests (Settings launch/restore/continue/delete, Command Palette launch, and
inline clarification context/collapse/answer).
- `cd apps/web && pnpm e2e:run e2e/tests/settings/mobile-configuration-chat.spec.ts -- --project=mobile-chrome`
- Passed: 1 mobile test (full-screen typed config tab and collapsible clarification answer).
- `cd apps/web && pnpm e2e:run e2e/tests/chat/clarification-resize.spec.ts e2e/tests/chat/quick-chat.spec.ts e2e/tests/chat/mobile-quick-chat-repository.spec.ts`
- Passed: 9 desktop tests. The mobile repository spec is project-gated and was run separately.
- `cd apps/web && pnpm e2e:run e2e/tests/chat/mobile-quick-chat-repository.spec.ts -- --project=mobile-chrome`
- Passed: 1 mobile repository-context regression test.
- `make fmt`
- Passed across backend, web, CLI, and packages.
- `make typecheck`
- Passed across all applications; rerun after lint corrections and passed.
- `make test`
- Passed across the full repository.
- `make lint`
- Initial run reported nine changed-file warnings. After extracting the config setup footer,
splitting the hydration test group, and deduplicating test fixtures, the full rerun passed with
zero backend issues, zero web warnings, and all 120 harness files passing.
Additional focused verification:
- 12 affected Vitest files: 112 tests passed.
- Six lint-correction Vitest files: 65 tests passed.
- `git diff --check`: passed.
## Files Likely Touched
- `apps/web/e2e/tests/settings/config-chat-popover.spec.ts`
- `apps/web/e2e/tests/settings/mobile-configuration-chat.spec.ts`
- `apps/web/e2e/tests/chat/quick-chat.spec.ts`
- `apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts`
- `apps/web/e2e/helpers/api-client.ts` if a missing lifecycle helper is required.
## Inputs
- All acceptance scenarios in the spec.
- Tasks 01-03 implementation and targeted tests.
- Existing E2E fixtures and managed runner conventions.
## Dependencies
Tasks 02 and 03.
## Output Contract
Report exact E2E and repository verification commands/results, files touched, blockers and artifacts,
then mark this task `done` in this file and `plan.md`.
+10 -7
View File
@@ -15,14 +15,15 @@ Select **Quick Chat** beside **New Task** in the expanded sidebar, or select its
### Start a chat
1. Choose an agent profile. Quick Chat requires one and defaults to the workspace's default agent profile when configured.
2. Optionally add one or more workspace repositories.
3. For each repository, choose a branch. The same repository cannot be added twice.
4. Select **Start chat**.
1. Turn on **Configuration chat** when the conversation should inspect or change Kandev configuration. This option is hidden when the workspace already has a configuration conversation.
2. Choose an agent profile. Quick Chat requires one and defaults to the workspace's default agent profile when configured.
3. For an ordinary Quick Chat, optionally add one or more workspace repositories.
4. For each repository, choose a branch. The same repository cannot be added twice.
5. Select **Start chat**.
Each selected repository gets an isolated worktree from the chosen branch. Uncommitted changes in your original checkout are not copied. Without a repository, Kandev creates an ephemeral working directory under `<KANDEV_HOME_DIR>/quick-chat/` (by default `~/.kandev/quick-chat/`).
Quick Chat supports multiple tabs, tab renaming, and **+** to create another chat. Structured profiles show ACP chat; CLI-passthrough profiles show their PTY interface. The desktop window is resizable, while mobile uses a full-screen view.
Quick Chat supports multiple tabs, tab renaming, and **+** to open another ordinary-chat setup. Structured profiles show ACP chat; CLI-passthrough profiles show their PTY interface. The desktop window is resizable, while mobile uses a full-screen view.
Closing a real chat tab permanently deletes its conversation, hidden backing task data, and associated worktree. There is no undo. Kandev also deletes abandoned chats after seven days; cleanup runs when the backend starts and then once per day. Only chats whose session is `RUNNING` or `IDLE` are protected from age-based cleanup. Old `CREATED`, `STARTING`, or `WAITING_FOR_INPUT` chats can expire, so do not use Quick Chat for durable work.
@@ -52,9 +53,11 @@ Utility calls run as ephemeral processes on the Kandev backend host. Kandev reco
The same settings page configures the **Configuration Chat Agent** for each workspace. Choose a profile, choose **No default**, or rely on the workspace default profile. Kandev remembers the first explicit selection as that workspace's configuration-chat default.
Open Configuration Chat from the floating chat button on Settings pages or run **Configuration Chat** from the `Cmd/Ctrl+K` command menu. It supports multiple tabs and uses a repository-less ephemeral task. Its configuration-mode MCP can inspect and change workflows, agent profiles, and MCP configuration. The selected profile's model, credentials, permissions, and external MCP settings apply. Review requested configuration mutations before approving them.
Open Configuration Chat from the floating chat button on Settings pages, turn on **Configuration chat** while creating a Quick Chat, or run **Configuration Chat** from the `Cmd/Ctrl+K` command menu. A workspace currently has one configuration conversation. The Settings panel shows that conversation without tabs; **Open in Quick Chat** moves the same setup or session into the larger tabbed dialog without copying it.
Closing a Configuration Chat tab removes only the browser tab state; it does not delete the backing `config_mode` task. Those tasks are excluded from the seven-day Quick Chat sweeper and can accumulate. Profile deletion attempts to clean matching ephemeral tasks, but otherwise there is no equivalent close-to-delete lifecycle in this UI.
Configuration Chat uses a repository-less ephemeral task. Its configuration-mode MCP can inspect and change workflows, agent profiles, and MCP configuration. The selected profile's model, credentials, permissions, and external MCP settings apply. Review requested configuration mutations before approving them.
Closing the floating Settings panel preserves the conversation. To delete it, open it in Quick Chat, close its tab, and confirm deletion. Configuration tasks are excluded from the seven-day Quick Chat sweeper and remain available until explicitly deleted or their workspace is deleted.
## Saved prompts
+1 -1
View File
@@ -52,7 +52,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande
| Share a session snapshot as a secret GitHub Gist | Dependency-bound | Requires GitHub auth and a structured session outside `CREATED`/`STARTING`. The viewing URL is rendered through `gist.githack.com`; unlisted URLs and heuristic redaction are not access control or exhaustive secret detection. Payloads are capped and shares can be revoked. |
| Quick Chat | Dependency-bound | Requires an agent profile. Repository-backed chats use isolated Kandev worktrees; closing deletes the ephemeral task permanently. Chats older than seven days are swept at startup and every 24 hours unless their session is `RUNNING` or `IDLE`. |
| Utility agents and custom one-shot helpers | Dependency-bound | Require an ACP inference-capable agent and usable model. Passthrough-only agents do not satisfy this path. |
| Configuration Chat | Limited | Uses an ephemeral repository-less task plus configuration MCP. Closing its browser tab does not delete the backing task, and config-mode tasks are excluded from Quick Chat expiry. |
| Configuration Chat | Limited | Each workspace currently presents one repository-less configuration conversation. Settings shows it in a tabless floating panel and can move the same session into Quick Chat. Confirmed tab closure deletes its backing task; config-mode tasks are excluded from Quick Chat expiry. |
| Voice input | Dependency-bound | Browser Web Speech, a browser-downloaded Whisper model, and server Whisper paths exist. Selection does not retry another engine after a recognition error. Server Whisper requires `KANDEV_VOICE_OPENAI_API_KEY` (empty by default); without it the transcription route returns 503. |
| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:<random-port>`. It relies on task-runtime and network isolation rather than its own login. |
| Built-in editors and host editor launch | Dependency-bound | Built-in editing is shipped. External editors depend on host discovery/custom commands. A multi-worktree session presents a repository/worktree picker before host launch; clients that omit the worktree ID retain a first-worktree fallback. A remote executor path may not work without remote-editor configuration. |
+1 -1
View File
@@ -59,7 +59,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
| [link-existing-task-github-issue](tasks/link-existing-task-github-issue.md) | building |
| [wip-limit-pull-system](tasks/wip-limit-pull-system.md) | building |
| [multi-branch](tasks/multi-branch/spec.md) | shipped |
| [quick-chat-expiration](tasks/quick-chat-expiration.md) | draft |
| [quick-chat-sessions](tasks/quick-chat-expiration.md) | shipped |
| [quick-chat-repository-context](tasks/quick-chat-repository-context.md) | shipped |
| [parent-child-message-interrupt](tasks/parent-child-message-interrupt.md) | shipped |
+235 -150
View File
@@ -1,190 +1,275 @@
---
status: shipped
created: 2026-07-02
owner: José Almeida
owner: kandev
---
# Quick Chat Persistence & Expiration
# Quick Chat Sessions, Persistence, and Expiration
## Why
A user reported her quick chats "disappearing much faster." Quick chats are
ephemeral tasks whose open-tab list lives only in the in-memory frontend store,
so a page reload drops every tab even though the underlying task, session, and
workspace directory still exist on the backend. Those now-unreachable rows and
directories never get cleaned up, so they leak indefinitely. Users want their
recent quick chats to survive a reload, and the system needs a bound so
abandoned quick chats don't accumulate forever.
Utility conversations need to remain available across page reloads without becoming detached
backend tasks. Configuration conversations need a compact Settings surface for in-context changes
and an explicit handoff to the larger Quick Chat dialog for reports and clarification questions,
while retaining the elevated tools that distinguish them from ordinary quick chats.
## What
- Open quick-chat tabs SHALL be restored after a full page reload, reconstructed
from backend state rather than client-only memory.
- Hydration SHALL resolve the workspace represented by the current route. On `/t/:id`
and `/office/tasks/:id` task-detail routes, the task's workspace is authoritative over
a stale active-workspace cookie or user setting.
- A quick chat that has been idle longer than the retention window SHALL be
deleted automatically (task row, its sessions, and its workspace directory),
the same as an explicit close.
- The default retention window is **7 days** of inactivity, applied per quick
chat based on its last activity. The window is a hardcoded constant; it is not
user-configurable in this iteration.
- Expiration SHALL reuse the existing quick-chat delete path so that workspace
directory cleanup and task-lifecycle events fire exactly as they do for a
user-initiated close.
- Only quick chats expire. Config-mode chats, automation-run ephemeral tasks,
and non-ephemeral kanban/office tasks are untouched.
- A quick chat that is actively in use (recent message or running session)
SHALL NOT expire, regardless of how old it is.
- Quick Chat presents ordinary and configuration conversations as typed sessions in one session
store and one tab model. A session has `kind: "chat" | "config"`.
- Configuration sessions are clearly identified with a sparkle/configuration indicator and an
accessible label wherever their tab kind is presented.
- Settings Configuration Chat opens a floating configuration panel. The Command Palette
Configuration Chat command opens the same typed setup/session in the Quick Chat modal.
- The Settings panel can transfer its current setup or session into the Quick Chat modal
without creating a second task, losing messages, or replaying the initial prompt.
- Blank setup tabs use client-local workspace-and-kind-scoped identities, so ordinary and
configuration setup can coexist without crossing workspace boundaries.
- Configuration setup retains its configuration-agent selection, introductory copy, suggestion
prompts, and `Ask anything about your configuration...` prompt. It explains that the agent can
manage workflows, agent profiles, and MCP configuration.
- Configuration setup never offers repository or branch selectors. Ordinary quick-chat setup
retains the optional repository context defined by
[Quick Chat Repository Context](quick-chat-repository-context.md).
- Creating an ordinary chat remains the default behavior when Quick Chat first opens. The tab-bar
`+` action opens that setup directly. The setup always explains when to use Quick Chat instead of
a tracked task and offers configuration mode in the form while the workspace has no existing or
pending configuration session.
- A workspace currently presents one configuration session. Configuration entry points reopen that
session when it exists, and the Settings floating panel shows it without a tab strip or new-session
action. The larger Quick Chat dialog retains tabs so the configuration session can coexist with
ordinary chats and be deleted through the established tab lifecycle.
- Desktop Settings uses the compact floating configuration panel until the user expands it. Mobile
Settings uses a viewport-bounded floating panel and the existing full-screen Quick Chat layout
after expansion, with the same creation, tab, clarification, and deletion capabilities.
- Pending clarification questions remain inline below a visible message history. The clarification
region is scrollable, resizable on pointer-capable desktop and mobile devices, and collapsible so
a user can recover conversation context without dismissing the question.
- Closing the modal or switching tabs preserves every real session. Closing a persisted tab asks
for confirmation and deletes its backing ephemeral task through the existing task deletion path.
Closing a blank setup tab only removes that client-side placeholder.
- When a setup start is superseded before its backend response arrives, the returned ephemeral task
is deleted instead of reopening the abandoned tab.
- Restorable utility sessions are reconstructed from backend task/session state for the active
workspace after a reload. They are available as tabs while the modal itself starts closed.
- Hydration resolves the workspace represented by the current route. On `/t/:id` and
`/office/tasks/:id`, the task workspace takes precedence over a stale active-workspace setting.
- Ordinary quick chats expire after seven days of inactivity through the existing task deletion
path. Configuration sessions do not use that idle expiration policy and remain until explicitly
deleted or their workspace is deleted.
- Ordinary and configuration sessions can coexist without sharing setup state, initial prompts,
repository choices, or workspace state.
Decision: [ADR-2026-07-14-typed-utility-chat-sessions](../../decisions/2026-07-14-typed-utility-chat-sessions.md). Repository-backed ordinary
chats additionally follow [ADR 0038](../../decisions/0038-quick-chat-repository-isolation.md).
## Data model
No new tables. This feature operates on existing `tasks` and `task_sessions`
rows. A **quick chat** is precisely the set of tasks matching:
No database migration is required. Both session kinds use existing `tasks` and `task_sessions`
rows. A restorable utility-chat task satisfies all of these predicates:
```
tasks
is_ephemeral = 1
workflow_id = '' -- ephemeral tasks carry no workflow
origin != 'automation_run' -- excludes run-mode ephemeral tasks
metadata.config_mode IS NOT 1 -- excludes config-mode settings chats
archived_at IS NULL
```text
tasks.is_ephemeral = true
tasks.workflow_id = ""
tasks.origin != "automation_run"
tasks.archived_at IS NULL
primary task_session exists
```
This mirrors the existing list filters: `onlyEphemeral = true` plus the
`excludeConfigModePredicate` (`json_extract(metadata,'$.config_mode') IS NOT 1`,
`internal/task/repository/sqlite/task.go:62`) plus an `origin != 'automation_run'`
guard. The `origin` guard distinguishes user-started quick chats (`origin =
'manual'`) from automation run-mode ephemeral tasks.
The persisted discriminator is task metadata, not the task title:
**Last-activity timestamp** for a quick chat is defined as:
```
last_activity = MAX(task.updated_at, MAX(session.updated_at) over its task_sessions)
```text
metadata.config_mode == true -> kind = "config"
otherwise -> kind = "chat"
```
Message and turn writes update their own rows only. A dispatched prompt changes
the quick-chat session's runtime state, which updates `task_sessions.updated_at`;
the associated task runtime-state transition can also update `tasks.updated_at`.
Using the greater of the task row and its newest session row therefore captures
the persisted activity written by the shipped prompt lifecycle. A quick chat
expires when `now - last_activity > 7 days`.
The unified frontend/boot session shape is:
```ts
type QuickChatSession = {
kind: "chat" | "config";
sessionId: string;
workspaceId: string;
name?: string;
agentProfileId?: string;
};
```
For backward compatibility, a session object without `kind` is normalized to `kind: "chat"`.
Blank setup tabs use the same frontend shape with a generated workspace-and-kind-scoped
`sessionId`, but are never persisted or included in boot state.
Ordinary-chat last activity is the greater of `tasks.updated_at` and the newest associated
`task_sessions.updated_at`. An ordinary chat is eligible for deletion when that timestamp is more
than seven days old and none of its sessions is active. Configuration sessions are excluded from
this query by `metadata.config_mode`.
## API surface
- **Hydration (read):** reuse the existing workspace task listing rather than a
new endpoint. The backend boot payload fetches quick chats via
`ListTasksByWorkspace(workspaceId, workflowID="", …, onlyEphemeral=true,
excludeConfig=true)` and filters out `origin == "automation_run"`, then
rebuilds the `quickChat.sessions` store slice (one tab per returned task, in
creation order). Each restored tab needs
`{ sessionId, workspaceId, agentProfileId }`, all resolvable from the task and
its primary session. The persisted display name is already available via
`getStoredQuickChatName` / `setStoredQuickChatName`.
- **Deletion (expiration):** no new external contract. Expiration runs as a
backend background sweep that calls the existing `Service.DeleteTask` path
(which invokes `cleanupQuickChatDirs`, `internal/task/service/service_tasks.go:1402`,
and publishes `task.deleted`). Deleted quick chats propagate to any connected
client through the existing `task.deleted` WS handler.
The creation endpoints remain distinct because they grant different backend capabilities:
- `POST /api/v1/workspaces/:id/quick-chat` creates an ordinary ephemeral chat and retains the
optional repository contract from [Quick Chat Repository Context](quick-chat-repository-context.md).
- `POST /api/v1/workspaces/:id/config-chat` creates an ephemeral task with
`metadata.config_mode=true`, selects the requested/workspace configuration agent profile, and
prepares a session with configuration MCP tools.
- Both return `{ "task_id": string, "session_id": string }`.
The SPA boot payload includes the active workspace's sessions and primary session DTOs:
```json
{
"quickChat": {
"isOpen": false,
"sessions": [
{
"kind": "config",
"sessionId": "session-id",
"workspaceId": "workspace-id",
"name": "Config Chat",
"agentProfileId": "profile-id"
}
],
"activeSessionId": null
},
"taskSessions": {
"items": {
"session-id": { "id": "session-id", "task_id": "task-id" }
}
}
}
```
Sessions are sorted by last activity, newest first. Boot classification always derives `kind` from
`metadata.config_mode`; titles are display values only.
## State machine
A quick chat task is created `active`, and reaches a terminal `deleted` state via
one of these triggers (this feature adds only the last one):
| State | Trigger | Result |
| ----------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Closed, restored sessions available | Quick Chat or command action | Modal opens on an eligible session or typed setup tab in the active workspace. |
| Settings configuration panel closed | Settings FAB | Floating panel opens on an eligible configuration session or configuration setup. |
| Floating setup/session | Open in Quick Chat | Floating panel closes and the same setup/session becomes active in the large dialog without creating a task. |
| Existing configuration session | Configuration entry point | Existing session opens; no second configuration setup is created. |
| Blank `chat` setup | Start succeeds | Placeholder is replaced by a persisted `chat` session. |
| Blank `config` setup | Prompt/profile submit succeeds | The task session is seeded and the typed persisted tab opens. ACP prompts are delivered once after the chat subscription is ready; passthrough prompts are delivered once by the backend launch path before the terminal attaches. |
| Persisted session open | Switch tab or close modal | Session remains persisted and restorable. |
| Persisted tab close requested | User confirms | Backing task is deleted, then the tab is removed or reconciled by task deletion state. |
| Blank setup tab close requested | User closes tab/modal | Placeholder is removed without a backend request. |
| Ordinary chat idle longer than seven days | Expiration sweep | Existing task deletion path removes task, sessions, workspace materialization, and tab. |
| Configuration chat idle | Expiration sweep | Session is retained. |
| Trigger | Actor | Existing? |
| ------------------------------------------- | -------------------- | ---------------------------------------------------- |
| User closes a tab (confirm) | user | yes (`use-quick-chat-modal.ts` `handleConfirmClose`) |
| Rapid re-pick supersedes an in-flight start | frontend | yes (`useAgentSelection` orphan cleanup) |
| Agent profile deleted | user | yes (`DeleteEphemeralTasksByAgentProfile`) |
| **Idle longer than retention window** | **background sweep** | **new** |
## Permissions
All triggers use the existing quick-chat deletion cleanup and event path.
Expiration first rechecks the expiry predicate at delete time, then performs
the same directory cleanup and event publishing.
Configuration capabilities continue to be granted by the backend's config-mode task/session
preparation. A frontend `kind: "config"` label does not itself grant tools. Workspace scoping and
the existing task/session authorization rules apply to creation, restoration, continuation, and
deletion.
## Failure modes
- **Last-activity query fails / returns error:** the sweep fails closed — it
deletes nothing that pass and logs a warning. A transient DB error must never
cause an over-broad delete.
- **A single quick chat delete fails** (dir busy, session stop error): the sweep
logs the failure, leaves that task in place, and continues with the rest. One
bad row does not abort the batch. The next sweep retries it.
- **Hydration list query fails on boot:** quick chat tabs render empty (current
behavior); the app is otherwise unaffected. No error toast is required — this
degrades to today's behavior.
- **A hydrated tab's backend session is already stopped/gone** (e.g. backend was
restarted between reloads): the tab still restores and shows its history;
sending a new message follows the normal resume/relaunch path. A tab whose
task no longer exists is simply not returned by the list and thus not shown.
- **Clock skew / non-monotonic time:** expiration compares stored timestamps to
`now`; it does not assume monotonic wall clock beyond the 7-day granularity.
- A failed ordinary or configuration start leaves its setup visible with an error. Any task that
was created before launch failed is deleted through the existing rollback path.
- A non-passthrough configuration prompt is held only until its created session is available to
`QuickChatContent`; it is sent once after subscription and is not replayed by tab switches or
rerenders. A passthrough prompt is included in the config-chat start request because that session
renders a terminal instead of `QuickChatContent`.
- A boot-state query failure omits utility sessions for that response and logs the error. It does
not alter or delete persisted tasks.
- A task without a primary session is not restored as a tab.
- A tab-delete failure surfaces an error and leaves the backend task eligible for restoration; it
must not be converted into a permanently hidden client-only config session.
- A workspace change closes or re-scopes the visible modal before sessions from another workspace
can be activated. Launch and tab actions reject or ignore cross-workspace session state.
- An expiration candidate query failure deletes nothing. A single ordinary-chat delete failure is
logged and does not stop later candidates from being evaluated.
## Persistence guarantees
- **Survives reload:** open quick-chat tabs (restored from backend), quick-chat
task rows, sessions, and workspace directories — until they expire or are
closed.
- **Survives backend restart:** quick-chat task rows and directories (subject to
the same 7-day idle expiration). Tab restoration on the client depends only on
the backend list, so it works across a backend restart as long as the task row
still exists.
- **Does NOT survive:** a quick chat idle > 7 days (deleted on the next sweep).
The in-memory `quickChat.sessions` slice remains client-only and is rebuilt
from the backend on each load rather than persisted to `localStorage`.
- **Retention window:** 7 days of inactivity, hardcoded. Sweep cadence is an
implementation detail (piggy-backing on an existing periodic job is
acceptable) but SHALL run at least daily so expiry is observed within roughly
a day of the window elapsing.
- Persisted ordinary and configuration tasks, their primary sessions, messages, agent profile
identity, and display names available from task/local name state survive browser reloads and
backend restarts.
- The modal starts closed after boot. Opening it reveals only the restored sessions for the active
workspace.
- Blank setup tabs, unsent setup input, and a collapsed clarification presentation state do not
survive reloads.
- Ordinary quick chats remain subject to the seven-day inactivity deletion policy.
- Configuration chats are not automatically expired. Explicit tab deletion and workspace deletion
use established task cleanup so they do not leave unrecoverable ephemeral tasks.
## Scenarios
- **GIVEN** an open quick chat with one or more tabs, **WHEN** the user reloads
the page, **THEN** the same tabs reappear in creation order,
each showing its prior chat history and persisted name.
- **GIVEN** Quick Chats created from a task whose workspace differs from the
persisted active-workspace setting, **WHEN** the user reloads the task route,
**THEN** the task workspace's tabs restore without tabs from another workspace.
- **GIVEN** a quick-chat task whose `last_activity` is 8 days ago, **WHEN** the
expiration sweep runs, **THEN** the task, its sessions, and its workspace
directory are deleted, a `task.deleted` event is published, and the tab
disappears from any connected client.
- **GIVEN** a quick-chat task with a message sent 10 minutes ago (older than 7
days since creation but recently active), **WHEN** the sweep runs, **THEN** the
task is retained.
- **GIVEN** a config-mode chat (`metadata.config_mode = 1`) idle for 30 days,
**WHEN** the sweep runs, **THEN** it is NOT deleted.
- **GIVEN** an automation-run ephemeral task (`origin = 'automation_run'`) idle
for 30 days, **WHEN** the sweep runs, **THEN** it is NOT deleted.
- **GIVEN** a non-ephemeral kanban task idle for a year, **WHEN** the sweep runs,
**THEN** it is NOT deleted.
- **GIVEN** the last-activity query errors, **WHEN** the sweep runs, **THEN** no
quick chats are deleted and a warning is logged.
- **GIVEN** two idle quick chats where deleting the first fails, **WHEN** the
sweep runs, **THEN** the second is still evaluated and deleted, and the failure
on the first is logged.
- **GIVEN** the workspace has 12 quick chats within the retention window, **WHEN**
the user reloads, **THEN** all 12 tabs restore (no count cap in this
iteration).
- **GIVEN** an ordinary quick chat and a configuration chat in one workspace, **WHEN** the user
opens Quick Chat, **THEN** both appear as tabs and the configuration tab has a visible and
accessible configuration indicator.
- **GIVEN** a configuration agent is selected in Settings, **WHEN** the user starts Configuration
Chat from the Settings FAB, **THEN** a floating configuration setup/session opens and the created
task retains config-mode MCP tools.
- **GIVEN** the floating configuration setup is open, **WHEN** its empty state renders, **THEN** the
panel header is the only Configuration Chat title, the composer action remains visible, and no
separate cancel footer is rendered.
- **GIVEN** a floating configuration conversation, **WHEN** the user chooses Open in Quick Chat,
**THEN** the floating panel closes and the large dialog shows the same session, history, task, and
pending initial prompt.
- **GIVEN** the Quick Chat modal has no configuration session, **WHEN** the user starts a new chat,
**THEN** its setup explains Quick Chat versus a tracked task and offers configuration mode inside
the panel.
- **GIVEN** a workspace already has a configuration session, **WHEN** the user starts a new Quick
Chat, **THEN** configuration mode is not offered and configuration entry points reopen the
existing session.
- **GIVEN** Settings Configuration Chat is open, **WHEN** setup or the existing conversation is
shown, **THEN** the floating panel has no session tabs or new-configuration-session action.
- **GIVEN** any application route, **WHEN** the user chooses Configuration Chat from the Command
Palette, **THEN** the same unified configuration setup/session opens in Quick Chat.
- **GIVEN** a configuration setup tab, **WHEN** it renders, **THEN** it shows configuration copy,
suggestions, the configuration profile choice, and no repository controls.
- **GIVEN** an ordinary setup tab, **WHEN** it renders, **THEN** optional repository/branch controls
and the existing isolated repository behavior remain available.
- **GIVEN** a configuration prompt starts an ACP session, **WHEN** the frontend subscribes to the
created task session, **THEN** the prompt is delivered exactly once and its response appears in
that tab.
- **GIVEN** a passthrough configuration profile, **WHEN** its session starts, **THEN** the backend
launch receives the initial prompt exactly once and the unified tab renders the passthrough
terminal.
- **GIVEN** a completed configuration conversation, **WHEN** the browser reloads and Quick Chat is
reopened, **THEN** the configuration tab and prior messages are restored and the conversation can
continue.
- **GIVEN** Quick Chat is closed with a configuration tab active, **WHEN** the generic Quick Chat
action reopens the dialog, **THEN** that configuration tab remains active regardless of chat kind.
- **GIVEN** restored sessions with different activity times, **WHEN** boot state is built, **THEN**
eligible ordinary and configuration sessions are ordered newest first with the correct kind.
- **GIVEN** config-mode, automation-run, workflow-bound ephemeral, and ordinary quick-chat tasks,
**WHEN** boot state is built, **THEN** only the config-mode and ordinary utility chats are restored.
- **GIVEN** sessions belonging to two workspaces, **WHEN** either workspace is active, **THEN** only
that workspace's sessions can be shown or activated.
- **GIVEN** a task route whose workspace differs from the persisted active-workspace setting,
**WHEN** the page reloads, **THEN** the task workspace's utility sessions are restored.
- **GIVEN** an agent clarification is pending, **WHEN** the Quick Chat tab is visible, **THEN** the
question remains inline in a scrollable region, meaningful preceding message context remains
visible, and the user can resize or collapse the question before answering it.
- **GIVEN** a narrow mobile viewport and a pending clarification, **WHEN** the user expands, scrolls,
collapses, or answers it, **THEN** the primary question actions remain visible and usable without
horizontal clipping.
- **GIVEN** a real ordinary or configuration session, **WHEN** the user switches tabs or closes the
modal, **THEN** no backing task is deleted.
- **GIVEN** a real configuration tab, **WHEN** the user confirms its close action, **THEN** its
backing ephemeral task is deleted through the Quick Chat deletion path and does not return after
reload.
- **GIVEN** a blank typed setup tab, **WHEN** it is closed or the modal is dismissed, **THEN** only
that placeholder is removed and no task deletion request is issued.
- **GIVEN** a quick-chat session object from an older boot/client shape without `kind`, **WHEN** it is
hydrated, **THEN** it behaves as an ordinary `chat` session.
- **GIVEN** a configuration chat idle for more than seven days, **WHEN** the expiration sweep runs,
**THEN** it is retained.
- **GIVEN** an ordinary quick chat idle for more than seven days with no active session, **WHEN** the
expiration sweep runs, **THEN** its task, sessions, and workspace materialization are deleted.
## Out of scope
- A user-configurable retention window (per-workspace or global setting). Ships
as a hardcoded 7-day constant; revisit only if requested.
- A hard count cap on number of quick chats (idle TTL only). If clutter becomes
a problem, a cap is a follow-up.
- Persisting quick-chat tabs to `localStorage`. Backend is the source of truth;
no client-side durable store.
- Archiving/soft-deleting expired quick chats — they are hard-deleted, matching
the existing close behavior.
- Changing config-mode chat, automation-run, or kanban/office task lifecycles.
## Verified implementation
- Raw message and turn persistence does not update the parent task or session.
- Prompt dispatch and completion transition the quick-chat session state and
update `task_sessions.updated_at`.
- A quick-chat task runtime-state transition can also update `tasks.updated_at`.
- The expiration query reads both parent timestamps and excludes `RUNNING` and
`IDLE` sessions, so active or recently completed prompt lifecycles are retained.
- Redesigning the agent protocol, configuration MCP tools, or config-chat creation endpoint.
- A new persistence table for UI tabs or duplicating task/session history in frontend storage.
- A separate configuration session store, message renderer, or backend conversation.
- A general task/chat navigation redesign.
- Multiple configuration sessions per workspace.
- Repository context for configuration sessions.
- A user-configurable ordinary-chat retention window or automatic expiration for config sessions.
@@ -20,8 +20,8 @@ context without changing their checked-out branches or creating a kanban task.
repository has one explicit base branch and can appear at most once in a chat.
- Selecting a repository automatically fills its preferred branch (`main`, `master`, or the
first available branch), including when another repository lookup is still pending.
- A workspace with no existing quick chats explains that quick chat is for discussing an idea,
question, or codebase outside the task board.
- Every new-chat setup explains that quick chat is for questions, exploration, and small requests
outside the tracked task workflow.
- Agent and repository fields include concise helper copy. Repository copy explains that the
optional selection focuses the conversation on specific code and branches.
- Repo-backed quick chats run in Kandev-owned isolated worktrees. They never switch or modify
@@ -88,7 +88,7 @@ them through the existing quick-chat task deletion path.
## Scenarios
- **GIVEN** a workspace with no quick chats, **WHEN** the user opens Quick Chat, **THEN** the
- **GIVEN** a user opens a new Quick Chat setup, **WHEN** the setup renders, **THEN** the
setup explains its purpose and shows agent and repository helper copy.
- **GIVEN** an agent and no repositories, **WHEN** the user starts a chat, **THEN** the chat uses
the existing repo-less scratch workspace.
@@ -100,8 +100,8 @@ them through the existing quick-chat task deletion path.
starts, **THEN** the source checkout and its uncommitted files remain unchanged.
- **GIVEN** one repo-backed chat is active, **WHEN** another chat targets the same repository and
base branch, **THEN** both start in distinct Kandev-owned worktrees.
- **GIVEN** an existing quick chat, **WHEN** the user opens a new chat tab, **THEN** the long
first-use introduction is omitted while field helper copy remains.
- **GIVEN** an existing quick chat, **WHEN** the user opens a new chat tab, **THEN** the Quick Chat
versus tracked-task guidance and field helper copy remain visible.
- **GIVEN** a mobile viewport, **WHEN** the user selects an agent and repository branch, **THEN**
all controls remain touch-accessible and the page has no horizontal overflow.
- **GIVEN** a desktop quick-chat dialog, **WHEN** the user drags either horizontal edge, **THEN**