From 8fae44fb1234d33c7b99c0bb93f9ca85dced2c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 18 Jul 2026 22:58:18 +0100 Subject: [PATCH] feat: unify configuration chat sessions (#1695) --- .../backend/internal/backendapp/boot_state.go | 41 +- .../internal/backendapp/helpers_test.go | 59 ++- apps/web/app/page.tsx | 1 + .../config-chat/config-chat-panel.tsx | 421 ++++++------------ .../config-chat/config-chat-provider.tsx | 9 +- .../config-chat/config-chat-setup.test.tsx | 110 +++++ .../config-chat/config-chat-setup.tsx | 370 +++++++++++++++ .../config-chat/use-config-chat.test.ts | 196 ++++++++ .../components/config-chat/use-config-chat.ts | 246 ++++++---- apps/web/components/global-commands.tsx | 50 +-- .../quick-chat/configuration-chat-toggle.tsx | 36 ++ .../quick-chat/quick-chat-content.tsx | 129 ++++-- .../quick-chat/quick-chat-modal.test.ts | 19 +- .../quick-chat/quick-chat-modal.tsx | 89 ++-- .../quick-chat/quick-chat-session-view.tsx | 44 ++ .../quick-chat/quick-chat-setup.test.tsx | 24 +- .../quick-chat/quick-chat-setup.tsx | 20 +- .../quick-chat/quick-chat-tab-item.test.tsx | 11 + .../quick-chat/quick-chat-tab-item.tsx | 10 +- .../quick-chat/use-quick-chat-modal.test.ts | 114 ++++- .../quick-chat/use-quick-chat-modal.ts | 154 ++++--- .../task/chat/clarification-input-overlay.tsx | 31 +- .../components/task/chat/use-tiptap-editor.ts | 1 + .../tests/chat/clarification-resize.spec.ts | 13 +- apps/web/e2e/tests/chat/message-queue.spec.ts | 1 + .../chat/mobile-quick-chat-entry.spec.ts | 13 + .../chat/mobile-quick-chat-repository.spec.ts | 2 +- apps/web/e2e/tests/chat/quick-chat.spec.ts | 55 ++- .../tests/chat/slash-command-composer.spec.ts | 1 + .../settings/config-chat-popover.spec.ts | 224 ++++++---- .../mobile-configuration-chat.spec.ts | 60 +++ .../web/hooks/use-quick-chat-launcher.test.ts | 78 ++++ apps/web/hooks/use-quick-chat-launcher.ts | 25 +- apps/web/lib/state/default-state.ts | 6 +- apps/web/lib/state/hydration/hydrator.test.ts | 75 +++- apps/web/lib/state/hydration/hydrator.ts | 2 +- .../slices/ui/quick-chat-actions.test.ts | 145 ++++++ .../slices/ui/quick-chat-session.test.ts | 17 + .../lib/state/slices/ui/quick-chat-session.ts | 15 + apps/web/lib/state/slices/ui/types.ts | 44 +- apps/web/lib/state/slices/ui/ui-slice.ts | 148 +++--- apps/web/lib/state/store.ts | 18 +- apps/web/lib/ws/handlers/empty-turn-notice.ts | 4 +- .../2026-07-14-typed-utility-chat-sessions.md | 80 ++++ docs/decisions/INDEX.md | 1 + docs/plans/typed-quick-chat-sessions/plan.md | 117 +++++ .../task-01-backend-boot-contract.md | 42 ++ .../task-02-unified-frontend-sessions.md | 61 +++ .../task-03-clarification-responsive-ux.md | 55 +++ .../task-04-e2e-and-verification.md | 74 +++ docs/public/developer-tools.md | 17 +- docs/public/feature-status.md | 2 +- docs/specs/INDEX.md | 2 +- docs/specs/tasks/quick-chat-expiration.md | 385 +++++++++------- .../tasks/quick-chat-repository-context.md | 10 +- 55 files changed, 2990 insertions(+), 987 deletions(-) create mode 100644 apps/web/components/config-chat/config-chat-setup.test.tsx create mode 100644 apps/web/components/config-chat/config-chat-setup.tsx create mode 100644 apps/web/components/config-chat/use-config-chat.test.ts create mode 100644 apps/web/components/quick-chat/configuration-chat-toggle.tsx create mode 100644 apps/web/components/quick-chat/quick-chat-session-view.tsx create mode 100644 apps/web/e2e/tests/settings/mobile-configuration-chat.spec.ts create mode 100644 apps/web/hooks/use-quick-chat-launcher.test.ts create mode 100644 apps/web/lib/state/slices/ui/quick-chat-actions.test.ts create mode 100644 apps/web/lib/state/slices/ui/quick-chat-session.test.ts create mode 100644 apps/web/lib/state/slices/ui/quick-chat-session.ts create mode 100644 docs/decisions/2026-07-14-typed-utility-chat-sessions.md create mode 100644 docs/plans/typed-quick-chat-sessions/plan.md create mode 100644 docs/plans/typed-quick-chat-sessions/task-01-backend-boot-contract.md create mode 100644 docs/plans/typed-quick-chat-sessions/task-02-unified-frontend-sessions.md create mode 100644 docs/plans/typed-quick-chat-sessions/task-03-clarification-responsive-ux.md create mode 100644 docs/plans/typed-quick-chat-sessions/task-04-e2e-and-verification.md diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 405b1397e1..82f86fec87 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -21,6 +21,8 @@ const ( legacyOfficeWorkspaceCookie = "office-active-workspace" bootStateKeySessionID = "sessionId" bootStateKeyWorkspaceID = "workspaceId" + quickChatSessionKindChat = "chat" + quickChatSessionKindConfig = "config" ) func bootInitialState( @@ -387,13 +389,21 @@ func (b bootStateBuilder) quickChatSessions(ctx context.Context, workspaceID str continue } items = append(items, quickChatBootSession{ - state: mapQuickChatSessionState(task, primary), - createdAt: task.CreatedAt, + state: mapQuickChatSessionState(task, primary), + taskID: task.ID, + createdAt: task.CreatedAt, + lastActivityAt: quickChatLastActivityAt(task, primary), }) taskSessions[primary.ID] = taskdto.FromTaskSession(primary) } sort.SliceStable(items, func(i, j int) bool { - return items[i].createdAt.Before(items[j].createdAt) + if !items[i].lastActivityAt.Equal(items[j].lastActivityAt) { + return items[i].lastActivityAt.After(items[j].lastActivityAt) + } + if !items[i].createdAt.Equal(items[j].createdAt) { + return items[i].createdAt.Before(items[j].createdAt) + } + return items[i].taskID < items[j].taskID }) result := make([]map[string]any, 0, len(items)) for _, item := range items { @@ -406,7 +416,7 @@ func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID st const pageSize = 1000 var all []*taskmodels.Task for page := 1; ; page++ { - tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, "", false, false, true, true) + tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, "", false, false, true, false) if err != nil { return nil, err } @@ -418,8 +428,17 @@ func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID st } type quickChatBootSession struct { - state map[string]any - createdAt time.Time + state map[string]any + taskID string + createdAt time.Time + lastActivityAt time.Time +} + +func quickChatLastActivityAt(task *taskmodels.Task, primary *taskmodels.TaskSession) time.Time { + if primary.UpdatedAt.After(task.UpdatedAt) { + return primary.UpdatedAt + } + return task.UpdatedAt } type quickChatBootState struct { @@ -474,6 +493,7 @@ func mapQuickChatSessionState(task *taskmodels.Task, primary *taskmodels.TaskSes state := map[string]any{ bootStateKeySessionID: primary.ID, bootStateKeyWorkspaceID: task.WorkspaceID, + "kind": quickChatSessionKind(task), } if task.Title != "" && task.Title != "Quick Chat" { state["name"] = task.Title @@ -484,6 +504,15 @@ func mapQuickChatSessionState(task *taskmodels.Task, primary *taskmodels.TaskSes return state } +func quickChatSessionKind(task *taskmodels.Task) string { + if task != nil { + if configMode, ok := task.Metadata["config_mode"].(bool); ok && configMode { + return quickChatSessionKindConfig + } + } + return quickChatSessionKindChat +} + func quickChatAgentProfileID(task *taskmodels.Task, primary *taskmodels.TaskSession) string { if task != nil { if value, ok := task.Metadata[taskmodels.MetaKeyAgentProfileID].(string); ok { diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 51c2a4edd4..6185fce2e4 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -996,6 +996,9 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-qc", Name: "Quick Chats"}); err != nil { t.Fatalf("CreateWorkspace: %v", err) } + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-other", Name: "Other"}); err != nil { + t.Fatalf("CreateWorkspace(other): %v", err) + } if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-qc", WorkspaceID: "ws-qc", Name: "Workflow"}); err != nil { t.Fatalf("CreateWorkflow: %v", err) } @@ -1012,6 +1015,18 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { id: "task-config", title: "Config", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-config", metadata: map[string]interface{}{"config_mode": true}, }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-archived", title: "Archived", updatedAt: base, sessionUpdatedAt: base, + agentProfileID: "agent-archived", archived: true, + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-no-primary", title: "No Primary", updatedAt: base, + agentProfileID: "agent-no-primary", withoutPrimary: true, + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-other-workspace", workspaceID: "ws-other", title: "Foreign", updatedAt: base, + sessionUpdatedAt: base, agentProfileID: "agent-foreign", + }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-automation", title: "Automation", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-automation", origin: models.TaskOriginAutomationRun, @@ -1040,8 +1055,10 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { WorkspaceID string `json:"workspaceId"` Name string `json:"name"` AgentProfileID string `json:"agentProfileId"` + Kind string `json:"kind"` } `json:"sessions"` - IsOpen bool `json:"isOpen"` + IsOpen bool `json:"isOpen"` + ActiveSessionID *string `json:"activeSessionId"` } `json:"quickChat"` TaskSessions struct { Items map[string]struct { @@ -1055,21 +1072,33 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { } sessions := decoded.InitialState.QuickChat.Sessions - if len(sessions) != 2 { - t.Fatalf("quickChat sessions = %#v, want 2 restored sessions", sessions) + if len(sessions) != 3 { + t.Fatalf("quickChat sessions = %#v, want 3 restored sessions", sessions) } - if got := sessions[0].SessionID; got != "task-old-session" { - t.Fatalf("first restored session = %q, want first-created task-old-session", got) + if got := sessions[0].SessionID; got != "task-config-session" { + t.Fatalf("first restored session = %q, want newest task-config-session", got) } - if sessions[0].AgentProfileID != "agent-old" || sessions[1].AgentProfileID != "agent-new" { + if sessions[0].Kind != "config" || sessions[1].Kind != "chat" || sessions[2].Kind != "chat" { + t.Fatalf("restored session kinds = %#v, want config, chat, chat", sessions) + } + if sessions[0].WorkspaceID != "ws-qc" || sessions[0].Name != "Config" { + t.Fatalf("config session identity = %#v, want workspace and task title preserved", sessions[0]) + } + if sessions[0].AgentProfileID != "agent-config" || sessions[1].AgentProfileID != "agent-new" || sessions[2].AgentProfileID != "agent-old" { t.Fatalf("agent profile IDs = %#v", sessions) } - if got := decoded.InitialState.TaskSessions.Items["task-new-session"].TaskID; got != "task-new" { - t.Fatalf("quick chat task session task_id = %q, want task-new", got) + if got := decoded.InitialState.TaskSessions.Items["task-config-session"].TaskID; got != "task-config" { + t.Fatalf("config chat task session task_id = %q, want task-config", got) + } + if len(decoded.InitialState.TaskSessions.Items) != 3 { + t.Fatalf("taskSessions items = %#v, want only the 3 restored primary sessions", decoded.InitialState.TaskSessions.Items) } if decoded.InitialState.QuickChat.IsOpen { t.Fatal("quick chat should hydrate closed") } + if decoded.InitialState.QuickChat.ActiveSessionID != nil { + t.Fatalf("quick chat active session = %q, want nil", *decoded.InitialState.QuickChat.ActiveSessionID) + } } func TestBootPayloadRestoresQuickChatsFromTaskRouteWorkspace(t *testing.T) { @@ -1133,8 +1162,8 @@ func TestBootPayloadRestoresQuickChatsFromTaskRouteWorkspace(t *testing.T) { if len(sessions) != 2 { t.Fatalf("quickChat sessions = %#v, want 2 task-workspace sessions", sessions) } - if sessions[0].SessionID != "task-route-first-session" || sessions[1].SessionID != "task-route-second-session" { - t.Fatalf("quickChat sessions = %#v, want task-workspace creation order", sessions) + if sessions[0].SessionID != "task-route-second-session" || sessions[1].SessionID != "task-route-first-session" { + t.Fatalf("quickChat sessions = %#v, want task-workspace activity order", sessions) } for _, session := range sessions { if session.WorkspaceID != "ws-task" { @@ -1155,6 +1184,8 @@ type bootQuickChatFixture struct { metadata map[string]interface{} updatedAt time.Time sessionUpdatedAt time.Time + archived bool + withoutPrimary bool } func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx context.Context, f bootQuickChatFixture) { @@ -1186,6 +1217,14 @@ func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx co ); err != nil { t.Fatalf("backdate task(%s): %v", f.id, err) } + if f.archived { + if _, err := repo.DB().ExecContext(ctx, `UPDATE tasks SET archived_at = ? WHERE id = ?`, f.updatedAt, f.id); err != nil { + t.Fatalf("archive task(%s): %v", f.id, err) + } + } + if f.withoutPrimary { + return + } if err := repo.CreateTaskSession(ctx, &models.TaskSession{ ID: f.id + "-session", TaskID: f.id, diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 56692f7b3c..2f19cf0b0c 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -73,6 +73,7 @@ function mapQuickChatSessions(tasks: QuickChatTask[]): AppState["quickChat"]["se // sorts by max(task/session updated_at) and is the authoritative SPA path. .sort((a, b) => quickChatUpdatedAt(b) - quickChatUpdatedAt(a)) .map((task) => ({ + kind: "chat" as const, sessionId: task.primary_session_id!, workspaceId: task.workspace_id, name: task.title !== "Quick Chat" ? task.title : undefined, diff --git a/apps/web/components/config-chat/config-chat-panel.tsx b/apps/web/components/config-chat/config-chat-panel.tsx index 3388c8c209..5e1a01aa32 100644 --- a/apps/web/components/config-chat/config-chat-panel.tsx +++ b/apps/web/components/config-chat/config-chat-panel.tsx @@ -1,327 +1,162 @@ "use client"; -import { memo, useEffect, useRef, useState } from "react"; -import { - IconLoader2, - IconMessageCircle, - IconPlus, - IconSend2, - IconSparkles, - IconX, -} from "@tabler/icons-react"; -import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { memo, useCallback, useMemo, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { IconArrowsMaximize, IconSparkles, IconX } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { Textarea } from "@kandev/ui/textarea"; -import { QuickChatContent } from "@/components/quick-chat/quick-chat-content"; import { useAppStore } from "@/components/state-provider"; +import { QuickChatSessionView } from "@/components/quick-chat/quick-chat-session-view"; +import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session"; +import { ConfigChatSetup } from "./config-chat-setup"; import { useConfigChat } from "./use-config-chat"; -import type { ConfigChatSession } from "@/lib/state/slices/ui/types"; -const SUGGESTION_PROMPTS = [ - "Add a 'Code Review' step to my workflow", - "Create a new agent profile with auto-approve enabled", - "Show me the current workflow configuration", - "Update the MCP servers for the default agent profile", -]; - -function SuggestionList() { - return ( -
-

Try asking

- {SUGGESTION_PROMPTS.map((prompt) => ( -

- {prompt} -

- ))} -
+function useConfigChatPanelStore() { + return useAppStore( + useShallow((state) => ({ + quickChatSessions: state.quickChat.sessions, + openQuickChat: state.openQuickChat, + setQuickChatInitialPrompt: state.setQuickChatInitialPrompt, + })), ); } -function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) { - const profiles = useAppStore((s) => s.agentProfiles.items ?? []); +function PanelHeader({ onExpand, onClose }: { onExpand: () => void; onClose: () => void }) { return ( -
-

Select an agent profile

-
- {profiles.map((profile) => ( - - ))} +
+
+ + Configuration Chat
-

- This will be saved as your default. Change it in Agent settings. -

-
- ); -} - -function ConfigChatTabs({ - sessions, - activeSessionId, - onTabChange, - onTabClose, - onNewChat, -}: { - sessions: ConfigChatSession[]; - activeSessionId: string | null; - onTabChange: (sessionId: string) => void; - onTabClose: (sessionId: string) => void; - onNewChat: () => void; -}) { - if (sessions.length === 0) return null; - - return ( -
-
- {sessions.map((s, index) => { - const isActive = s.sessionId === activeSessionId; - const tabName = s.name || `Chat ${index + 1}`; - return ( -
- - -
- ); - })} -
- -
- ); -} - -function LoadingState() { - return ( -
- -

Starting config chat...

-
- ); -} - -function ConfigChatEmptyState({ - defaultProfileId, - onSelectPrompt, - isStarting, - error, -}: { - defaultProfileId: string | undefined; - onSelectPrompt: (prompt: string, profileId: string) => void; - isStarting: boolean; - error: string | null; -}) { - const profileCount = useAppStore((s) => s.agentProfiles.items?.length ?? 0); - const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId ?? ""); - const [inputValue, setInputValue] = useState(""); - - const textareaRef = useRef(null); - const needsProfileSelection = !selectedProfileId && !defaultProfileId && profileCount > 0; - const effectiveProfileId = selectedProfileId || defaultProfileId || ""; - const canSubmit = inputValue.trim().length > 0 && !!effectiveProfileId && !isStarting; - - useEffect(() => { - if (!needsProfileSelection && !isStarting && textareaRef.current) { - textareaRef.current.focus(); - } - }, [needsProfileSelection, isStarting]); - - const handleSubmit = () => { - const trimmed = inputValue.trim(); - if (!trimmed || !effectiveProfileId) return; - onSelectPrompt(trimmed, effectiveProfileId); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; - - if (isStarting) return ; - - return ( -
-
-
-
- -
-
-

Configure Kandev with AI

-

- Manage workflows, agent profiles, and MCP configuration. -

-
- - {needsProfileSelection ? ( - - ) : ( - <> - {inputValue.length === 0 ? :
} - -
-