feat: unify configuration chat sessions (#1695)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user