diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index ce0d836627..e9cef97501 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -52,7 +52,7 @@ func bootInitialState( if route.Route == webapp.RouteOffice { builder.addOfficeRouteState(ctx, req, state) } - builder.addQuickChatState(ctx, req, state) + builder.addQuickChatState(ctx, req, state, route) return state } @@ -266,8 +266,13 @@ func (b bootStateBuilder) addRepositoriesState(ctx context.Context, state map[st } } -func (b bootStateBuilder) addQuickChatState(ctx context.Context, req *http.Request, state map[string]any) { - workspaceID := b.resolveQuickChatWorkspaceID(ctx, req, state) +func (b bootStateBuilder) addQuickChatState( + ctx context.Context, + req *http.Request, + state map[string]any, + route webapp.RouteClassification, +) { + workspaceID := b.resolveQuickChatWorkspaceID(ctx, req, state, route) if workspaceID == "" { return } @@ -284,7 +289,15 @@ func (b bootStateBuilder) addQuickChatState(ctx context.Context, req *http.Reque mergeBootTaskSessionItems(state, quickChat.taskSessions) } -func (b bootStateBuilder) resolveQuickChatWorkspaceID(ctx context.Context, req *http.Request, state map[string]any) string { +func (b bootStateBuilder) resolveQuickChatWorkspaceID( + ctx context.Context, + req *http.Request, + state map[string]any, + route webapp.RouteClassification, +) string { + if workspaceID := b.quickChatTaskRouteWorkspaceID(ctx, route); workspaceID != "" { + return workspaceID + } if active := activeWorkspaceIDFromState(state); active != "" { return active } @@ -310,6 +323,28 @@ func (b bootStateBuilder) resolveQuickChatWorkspaceID(ctx context.Context, req * ) } +func (b bootStateBuilder) quickChatTaskRouteWorkspaceID( + ctx context.Context, + route webapp.RouteClassification, +) string { + if b.p.taskSvc == nil || (route.Route != webapp.RouteTaskDetail && route.Route != webapp.RouteOffice) { + return "" + } + taskID := route.Params["taskId"] + if taskID == "" { + return "" + } + task, err := b.p.taskSvc.GetTask(ctx, taskID) + if err != nil { + b.logBootError("get quick-chat task route workspace", err) + return "" + } + if task == nil { + return "" + } + return task.WorkspaceID +} + func activeWorkspaceIDFromState(state map[string]any) string { workspaces, ok := state["workspaces"].(map[string]any) if !ok { diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 7e28c1cda2..ef6b5f3335 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -847,8 +847,82 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { } } +func TestBootPayloadRestoresQuickChatsFromTaskRouteWorkspace(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + repo := harness.taskRepo + for _, workspace := range []*models.Workspace{ + {ID: "ws-active", Name: "Persisted Active"}, + {ID: "ws-task", Name: "Task Workspace"}, + } { + if err := repo.CreateWorkspace(ctx, workspace); err != nil { + t.Fatalf("CreateWorkspace(%s): %v", workspace.ID, err) + } + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "route-task", WorkspaceID: "ws-task", Title: "Route Task", + State: v1.TaskStateTODO, Priority: "medium", + }); err != nil { + t.Fatalf("CreateTask(route-task): %v", err) + } + base := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-active-chat", workspaceID: "ws-active", title: "Wrong Workspace", + updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-active", + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-route-first", workspaceID: "ws-task", title: "First Route Chat", + updatedAt: base.Add(time.Minute), sessionUpdatedAt: base.Add(time.Minute), agentProfileID: "agent-first", + }) + seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ + id: "task-route-second", workspaceID: "ws-task", title: "Second Route Chat", + updatedAt: base.Add(2 * time.Minute), sessionUpdatedAt: base.Add(2 * time.Minute), agentProfileID: "agent-second", + }) + + for _, routePath := range []string{"/t/route-task", "/office/tasks/route-task"} { + t.Run(routePath, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, routePath, nil) + req.AddCookie(&http.Cookie{Name: activeWorkspaceCookie, Value: "ws-active"}) + payload := bootPayload(ctx, req, routeParams{ + taskSvc: harness.taskSvc, services: &Services{Workflow: harness.workflowSvc}, userCtrl: harness.userCtrl, + }, webapp.ClassifyRoute(routePath)) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + InitialState struct { + QuickChat struct { + Sessions []struct { + SessionID string `json:"sessionId"` + WorkspaceID string `json:"workspaceId"` + } `json:"sessions"` + } `json:"quickChat"` + } `json:"initialState"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + sessions := decoded.InitialState.QuickChat.Sessions + 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) + } + for _, session := range sessions { + if session.WorkspaceID != "ws-task" { + t.Fatalf("restored workspace = %q, want ws-task", session.WorkspaceID) + } + } + }) + } +} + type bootQuickChatFixture struct { id string + workspaceID string title string workflowID string origin string @@ -860,13 +934,17 @@ type bootQuickChatFixture struct { func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx context.Context, f bootQuickChatFixture) { t.Helper() + workspaceID := f.workspaceID + if workspaceID == "" { + workspaceID = "ws-qc" + } metadata := map[string]interface{}{models.MetaKeyAgentProfileID: f.agentProfileID} for key, value := range f.metadata { metadata[key] = value } if err := repo.CreateTask(ctx, &models.Task{ ID: f.id, - WorkspaceID: "ws-qc", + WorkspaceID: workspaceID, WorkflowID: f.workflowID, Title: f.title, State: v1.TaskStateTODO, diff --git a/apps/backend/internal/webapp/routes.go b/apps/backend/internal/webapp/routes.go index c245f1943e..191724e76d 100644 --- a/apps/backend/internal/webapp/routes.go +++ b/apps/backend/internal/webapp/routes.go @@ -94,7 +94,7 @@ func classifySPARoute(requestPath string) (RouteName, map[string]string) { case requestPath == "/stats": return RouteStats, nil case requestPath == "/office" || strings.HasPrefix(requestPath, "/office/"): - return RouteOffice, nil + return RouteOffice, officeRouteParams(requestPath) case requestPath == "/settings" || strings.HasPrefix(requestPath, "/settings/"): return RouteSettings, nil default: @@ -102,6 +102,14 @@ func classifySPARoute(requestPath string) (RouteName, map[string]string) { } } +func officeRouteParams(requestPath string) map[string]string { + taskID, ok := cutSingleSegment(requestPath, "/office/tasks/") + if !ok { + return nil + } + return map[string]string{"taskId": taskID} +} + func classifyTaskRoute(requestPath string) (RouteName, map[string]string) { taskID, ok := cutSingleSegment(requestPath, "/t/") if !ok { diff --git a/apps/backend/internal/webapp/routes_test.go b/apps/backend/internal/webapp/routes_test.go index 9d1a669844..afa1bbcbb2 100644 --- a/apps/backend/internal/webapp/routes_test.go +++ b/apps/backend/internal/webapp/routes_test.go @@ -21,6 +21,7 @@ func TestClassifyRouteSPARoutes(t *testing.T) { {name: "task detail compat", path: "/tasks/task-123", wantRoute: RouteTaskDetail, wantParams: map[string]string{"taskId": "task-123"}}, {name: "office root", path: "/office", wantRoute: RouteOffice}, {name: "office nested", path: "/office/agents/agent-1", wantRoute: RouteOffice}, + {name: "office task detail", path: "/office/tasks/task-123", wantRoute: RouteOffice, wantParams: map[string]string{"taskId": "task-123"}}, {name: "settings root", path: "/settings", wantRoute: RouteSettings}, {name: "settings nested", path: "/settings/integrations/github", wantRoute: RouteSettings}, {name: "github", path: "/github", wantRoute: RouteGitHub}, diff --git a/apps/web/components/quick-chat/quick-chat-modal.test.ts b/apps/web/components/quick-chat/quick-chat-modal.test.ts index 54b0e8ed18..fcc33b400f 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.test.ts +++ b/apps/web/components/quick-chat/quick-chat-modal.test.ts @@ -50,6 +50,27 @@ describe("openQuickChat agentProfileId persistence", () => { }); }); +describe("Quick Chat workspace isolation", () => { + it("replaces a blank setup tab when opening a different workspace", () => { + const store = makeStore(); + store.getState().openQuickChat("", "ws-a"); + store.getState().openQuickChat("", "ws-b"); + + expect(store.getState().quickChat.sessions).toEqual([{ sessionId: "", workspaceId: "ws-b" }]); + }); + + it("falls back to a tab from the closed session workspace", () => { + const store = makeStore(); + store.getState().openQuickChat("session-a", "ws-a"); + store.getState().openQuickChat("session-b-1", "ws-b"); + store.getState().openQuickChat("session-b-2", "ws-b"); + + store.getState().closeQuickChatSession("session-b-2"); + + expect(store.getState().quickChat.activeSessionId).toBe("session-b-1"); + }); +}); + describe("renameQuickChatSession local persistence", () => { beforeEach(() => { window.localStorage.clear(); diff --git a/apps/web/components/quick-chat/quick-chat-provider.test.ts b/apps/web/components/quick-chat/quick-chat-provider.test.ts new file mode 100644 index 0000000000..12f383a91f --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-provider.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { getWorkspaceId } from "./quick-chat-provider"; + +const sessions = [ + { sessionId: "session-a", workspaceId: "ws-a" }, + { sessionId: "session-b", workspaceId: "ws-b" }, +]; + +describe("getWorkspaceId", () => { + it("uses the active tab workspace instead of the first persisted tab", () => { + expect(getWorkspaceId(sessions, true, "session-b", "ws-a")).toBe("ws-b"); + }); + + it("uses the active workspace for a new-chat placeholder", () => { + expect(getWorkspaceId(sessions, true, "", "ws-b")).toBe("ws-b"); + }); + + it("does not mount a closed dialog from stale sessions", () => { + expect(getWorkspaceId(sessions, false, "session-a", "ws-a")).toBeNull(); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-provider.tsx b/apps/web/components/quick-chat/quick-chat-provider.tsx index 643cae4355..5fd9583e84 100644 --- a/apps/web/components/quick-chat/quick-chat-provider.tsx +++ b/apps/web/components/quick-chat/quick-chat-provider.tsx @@ -14,18 +14,17 @@ function useIsMounted() { return useSyncExternalStore(emptySubscribe, getClientSnapshot, getServerSnapshot); } -function getWorkspaceId( - sessions: { workspaceId: string }[], +export function getWorkspaceId( + sessions: { sessionId: string; workspaceId: string }[], isOpen: boolean, + activeSessionId: string | null, activeWorkspace: string | null, ): string | null { - if (sessions.length > 0) { - return sessions[0].workspaceId; - } - if (isOpen) { - return activeWorkspace; - } - return null; + if (!isOpen) return null; + return ( + sessions.find((session) => session.sessionId === activeSessionId)?.workspaceId ?? + activeWorkspace + ); } /** @@ -36,14 +35,14 @@ function getWorkspaceId( export function QuickChatProvider({ children }: { children: React.ReactNode }) { const quickChatSessions = useAppStore((s) => s.quickChat.sessions); const isOpen = useAppStore((s) => s.quickChat.isOpen); + const activeSessionId = useAppStore((s) => s.quickChat.activeSessionId); const activeWorkspace = useAppStore((s) => s.workspaces.activeId); const mounted = useIsMounted(); // Preload agent profiles so they're available when quick chat is opened useSettingsData(true); - // Get workspace ID from first session, or use active workspace if modal is open - const workspaceId = getWorkspaceId(quickChatSessions, isOpen, activeWorkspace); + const workspaceId = getWorkspaceId(quickChatSessions, isOpen, activeSessionId, activeWorkspace); return ( <> diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.test.ts b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts index 7d1b8ea168..cc0ac96bdf 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.test.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts @@ -32,7 +32,11 @@ type MockStore = Parameters[1]; function makeAppState() { return { - quickChat: { isOpen: true, sessions: [] as Array<{ sessionId: string }>, activeSessionId: "" }, + quickChat: { + isOpen: true, + sessions: [] as Array<{ sessionId: string; workspaceId: string }>, + activeSessionId: "", + }, closeQuickChat: vi.fn(), closeQuickChatSession: vi.fn(), setActiveQuickChatSession: vi.fn(), @@ -73,7 +77,10 @@ beforeEach(() => { describe("useQuickChatModal — setup lifecycle", () => { it("removes a blank placeholder when dismissed from an active session", () => { - mockAppState.quickChat.sessions = [{ sessionId: "" }, { sessionId: "session-1" }]; + mockAppState.quickChat.sessions = [ + { sessionId: "", workspaceId: WORKSPACE_ID }, + { sessionId: "session-1", workspaceId: WORKSPACE_ID }, + ]; mockAppState.quickChat.activeSessionId = "session-1"; const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID)); @@ -92,6 +99,19 @@ describe("useQuickChatModal — setup lifecycle", () => { expect(result.current.setupKey).toBe(1); expect(mockAppState.openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID); }); + + it("exposes only sessions from the hydrated workspace", () => { + mockAppState.quickChat.sessions = [ + { sessionId: "session-a", workspaceId: WORKSPACE_ID }, + { sessionId: "session-b", workspaceId: "ws-2" }, + ]; + + const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID)); + + expect(result.current.sessions).toEqual([ + { sessionId: "session-a", workspaceId: WORKSPACE_ID }, + ]); + }); }); describe("useAgentSelection — happy path", () => { diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index fb22552419..de3ca9c358 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; @@ -11,8 +11,8 @@ async function deleteQuickChatTask(taskId: string) { await deleteTask(taskId); } -function useQuickChatStore() { - return useAppStore( +function useQuickChatStore(workspaceId: string) { + const store = useAppStore( useShallow((s) => ({ isOpen: s.quickChat.isOpen, sessions: s.quickChat.sessions, @@ -26,6 +26,13 @@ function useQuickChatStore() { taskSessions: s.taskSessions.items || {}, })), ); + return useMemo( + () => ({ + ...store, + sessions: store.sessions.filter((session) => session.workspaceId === workspaceId), + }), + [store, workspaceId], + ); } type QuickChatStore = ReturnType; @@ -108,7 +115,7 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) { export function useQuickChatModal(workspaceId: string) { const { toast } = useToast(); - const store = useQuickChatStore(); + const store = useQuickChatStore(workspaceId); const [setupKey, setSetupKey] = useState(0); const [sessionToClose, setSessionToClose] = useState(null); const { diff --git a/apps/web/components/task/chat/queued-ghost-list.test.tsx b/apps/web/components/task/chat/queued-ghost-list.test.tsx index 3fe83fd568..296f7f2f51 100644 --- a/apps/web/components/task/chat/queued-ghost-list.test.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.test.tsx @@ -71,6 +71,17 @@ function baseState(entries: QueuedMessage[]) { const CHILD =
input
; +function pressQueueEscape(): ReturnType { + const outerEscapeHandler = vi.fn(); + document.addEventListener("keydown", outerEscapeHandler); + try { + fireEvent.keyDown(screen.getByTestId("queue-close"), { key: "Escape" }); + } finally { + document.removeEventListener("keydown", outerEscapeHandler); + } + return outerEscapeHandler; +} + beforeEach(() => { useQueueMock.mockReset(); }); @@ -130,14 +141,6 @@ describe("QueueAffordance", () => { expect(screen.queryByTestId(PANEL_ID)).toBeNull(); }); - it("Escape collapses an open panel", () => { - useQueueMock.mockReturnValue(queueState([entry()])); - render({CHILD}); - fireEvent.click(screen.getByTestId(CHIP_ID)); - fireEvent.keyDown(window, { key: "Escape" }); - expect(screen.queryByTestId(PANEL_ID)).toBeNull(); - }); - it("auto-collapses the panel when the queue drains to zero", () => { useQueueMock.mockReturnValue(queueState([entry()])); const { rerender } = render({CHILD}); @@ -192,6 +195,18 @@ describe("QueueAffordance", () => { }); }); +describe("QueueAffordance Escape handling", () => { + it("collapses an open panel without reaching an outer dialog", () => { + useQueueMock.mockReturnValue(queueState([entry()])); + render({CHILD}); + fireEvent.click(screen.getByTestId(CHIP_ID)); + const outerEscapeHandler = pressQueueEscape(); + expect(screen.queryByTestId(PANEL_ID)).toBeNull(); + expect(screen.getByTestId(CHIP_ID)).toBeTruthy(); + expect(outerEscapeHandler).not.toHaveBeenCalled(); + }); +}); + describe("QueueAffordance — renderStatusBar prop", () => { it("calls renderStatusBar with null when there are no queued entries", () => { useQueueMock.mockReturnValue(queueState([])); diff --git a/apps/web/components/task/chat/queued-ghost-list.tsx b/apps/web/components/task/chat/queued-ghost-list.tsx index c47bffa571..0361f853de 100644 --- a/apps/web/components/task/chat/queued-ghost-list.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.tsx @@ -47,10 +47,12 @@ function useEscToClose(open: boolean, onClose: () => void): void { // the clarification overlay). if (isEditableTarget(e.target)) return; e.preventDefault(); + e.stopPropagation(); onClose(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + // Capture Escape before an enclosing Radix dialog handles it as a dismissal. + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); }, [open, onClose]); } diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts index 9dcd6d32ff..cc2b062e17 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts @@ -10,6 +10,7 @@ async function assertNoHorizontalOverflow(page: import("@playwright/test").Page) test.describe("Quick Chat repository context on mobile", () => { test("selects an agent and repository branch without hidden actions", async ({ testPage }) => { + test.setTimeout(90_000); await testPage.goto("/"); await testPage.waitForLoadState("networkidle"); const modifier = process.platform === "darwin" ? "Meta" : "Control"; @@ -39,5 +40,30 @@ test.describe("Quick Chat repository context on mobile", () => { await dialog.getByTestId("quick-chat-start").click(); await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 }); await assertNoHorizontalOverflow(testPage); + + await dialog.getByLabel("Start new chat").click(); + await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 5_000 }); + const secondAgentSelector = dialog.getByTestId("agent-profile-selector"); + if (await secondAgentSelector.getByText("Select agent", { exact: false }).isVisible()) { + await secondAgentSelector.click(); + await testPage.getByRole("option").first().click(); + } + await dialog.getByTestId("quick-chat-start").click(); + await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 }); + + const originalTabs = dialog.getByTestId("quick-chat-tab"); + await expect(originalTabs).toHaveCount(2); + const originalNames = await originalTabs.locator("span").allTextContents(); + + await testPage.reload(); + await testPage.waitForLoadState("networkidle"); + await testPage.keyboard.press(`${modifier}+Shift+q`); + + 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(restoredDialog.getByTestId("quick-chat-setup")).not.toBeVisible(); + await assertNoHorizontalOverflow(testPage); }); }); diff --git a/apps/web/e2e/tests/chat/quick-chat.spec.ts b/apps/web/e2e/tests/chat/quick-chat.spec.ts index 8c6475ab5f..cf0797caa5 100644 --- a/apps/web/e2e/tests/chat/quick-chat.spec.ts +++ b/apps/web/e2e/tests/chat/quick-chat.spec.ts @@ -8,13 +8,19 @@ import { attachAvailableCommandsCapture } from "../../helpers/ws-capture"; * Quick Chat E2E tests: basic flow, enhance prompt, queued messages, multi-tab. */ -async function openQuickChatSetup(page: Page): Promise { - await page.goto("/"); - await page.waitForLoadState("networkidle"); +async function openQuickChatSetup(page: Page, navigateHome = true): Promise { + if (navigateHome) { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + } - // Open Quick Chat via keyboard shortcut (Cmd+Shift+Q / Ctrl+Shift+Q). - const modifier = process.platform === "darwin" ? "Meta" : "Control"; - await page.keyboard.press(`${modifier}+Shift+q`); + if (navigateHome) { + // Open Quick Chat via keyboard shortcut (Cmd+Shift+Q / Ctrl+Shift+Q). + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+Shift+q`); + } else { + await page.getByTestId("sidebar-quick-chat-shortcut").click(); + } // Wait for Quick Chat dialog. const dialog = page.getByRole("dialog", { name: "Quick Chat" }); @@ -54,8 +60,8 @@ async function startQuickChatFromSetup(dialog: Locator, page: Page) { await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 }); } -async function openQuickChatWithAgent(page: Page): Promise { - const dialog = await openQuickChatSetup(page); +async function openQuickChatWithAgent(page: Page, navigateHome = true): Promise { + const dialog = await openQuickChatSetup(page, navigateHome); await startQuickChatFromSetup(dialog, page); return dialog; } @@ -179,8 +185,7 @@ test.describe("Quick Chat", () => { await expect(dialog).not.toBeVisible(); await testPage.reload(); await testPage.waitForLoadState("networkidle"); - const modifier = process.platform === "darwin" ? "Meta" : "Control"; - await testPage.keyboard.press(`${modifier}+Shift+q`); + await testPage.getByTestId("sidebar-quick-chat-shortcut").click(); await expect(dialog).toBeVisible(); await waitForQuickChatWidth(dialog); const restoredBox = await dialog.boundingBox(); @@ -348,16 +353,46 @@ test.describe("Quick Chat", () => { }); }); - test("supports multiple chat tabs and switching between them", async ({ testPage }) => { + test("restores multiple chat tabs in creation order after reload", async ({ + testPage, + apiClient, + seedData, + }) => { test.setTimeout(90_000); - const dialog = await openQuickChatWithAgent(testPage); + const quickChatWorkspace = await apiClient.createWorkspace("Quick Chat Restore Workspace"); + const workflow = await apiClient.createWorkflow( + quickChatWorkspace.id, + "Restore Workflow", + "simple", + ); + const { steps } = await apiClient.listWorkflowSteps(workflow.id); + const startStep = steps.find((step) => step.is_start_step) ?? steps[0]; + const task = await apiClient.createTaskWithAgent( + quickChatWorkspace.id, + "Quick Chat Restore Task", + seedData.agentProfileId, + { + workflow_id: workflow.id, + workflow_step_id: startStep.id, + }, + ); + await testPage.goto(`/t/${task.id}`); + await testPage.waitForLoadState("networkidle"); + await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`)); + await expect + .poll(() => + testPage.evaluate("window.__KANDEV_E2E_STORE__?.getState().workspaces.activeId ?? null"), + ) + .toBe(quickChatWorkspace.id); + + const dialog = await openQuickChatWithAgent(testPage, false); // Send a message in the first tab. - await sendQuickChatMessage(dialog, testPage, "/e2e:simple-message"); - await expect( - dialog.getByText("simple mock response for e2e testing", { exact: false }), - ).toBeVisible({ timeout: 30_000 }); + await sendQuickChatMessage(dialog, testPage, 'e2e:message("first tab response")'); + await expect(dialog.getByText("first tab response", { exact: true })).toBeVisible({ + timeout: 30_000, + }); // Create a new tab. const newChatBtn = dialog.getByLabel("Start new chat"); @@ -376,14 +411,49 @@ test.describe("Quick Chat", () => { timeout: 30_000, }); - // Switch back to the first tab by clicking its tab button. - const tabBar = dialog.locator(".scrollbar-hide").first(); - const firstTab = tabBar.locator("button").first(); - await firstTab.click(); + const originalTabs = dialog.getByTestId("quick-chat-tab"); + await expect(originalTabs).toHaveCount(2); + const originalNames = await originalTabs.locator("span").allTextContents(); - // First tab content should still be visible. - await expect( - dialog.getByText("simple mock response for e2e testing", { exact: false }), - ).toBeVisible({ timeout: 10_000 }); + const beforeReloadResponse = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${quickChatWorkspace.id}/tasks?only_ephemeral=true&exclude_config=true`, + ); + expect(beforeReloadResponse.ok).toBe(true); + const beforeReload = (await beforeReloadResponse.json()) as { + tasks: Array<{ id: string }>; + }; + expect(beforeReload.tasks).toHaveLength(2); + + await testPage.reload(); + await testPage.waitForLoadState("networkidle"); + + const persistedResponse = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${quickChatWorkspace.id}/tasks?only_ephemeral=true&exclude_config=true`, + ); + expect(persistedResponse.ok).toBe(true); + const persisted = (await persistedResponse.json()) as { tasks: Array<{ id: string }> }; + expect(persisted.tasks).toHaveLength(2); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await testPage.keyboard.press(`${modifier}+Shift+q`); + const restoredDialog = testPage.getByRole("dialog", { name: "Quick Chat" }); + await expect(restoredDialog).toBeVisible({ timeout: 10_000 }); + + const restoredTabs = restoredDialog.getByTestId("quick-chat-tab"); + await expect(restoredTabs).toHaveCount(2); + await expect(restoredTabs.locator("span")).toHaveText(originalNames); + 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({ + timeout: 10_000, + }); + + await restoredTabs.nth(1).locator("button").first().click(); + await expect(restoredDialog.getByText("second tab response", { exact: true })).toBeVisible({ + timeout: 10_000, + }); }); }); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 91e1092e34..5287c76551 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -307,6 +307,70 @@ 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 = ""; + return; + } + const existing = draft.quickChat.sessions.find( + (session) => session.sessionId === sessionId, + ); + if (existing) { + if (agentProfileId) existing.agentProfileId = agentProfileId; + } else { + draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId }); + } + draft.quickChat.activeSessionId = sessionId; + }), + closeQuickChat: () => + set((draft) => { + draft.quickChat.isOpen = false; + }), + closeQuickChatSession: (sessionId: string) => + set((draft) => { + const closedSession = draft.quickChat.sessions.find( + (session) => session.sessionId === sessionId, + ); + draft.quickChat.sessions = draft.quickChat.sessions.filter( + (session) => session.sessionId !== sessionId, + ); + if (draft.quickChat.activeSessionId !== sessionId) return; + const nextSession = draft.quickChat.sessions.find( + (session) => session.workspaceId === closedSession?.workspaceId, + ); + draft.quickChat.activeSessionId = nextSession?.sessionId ?? null; + if (!nextSession) draft.quickChat.isOpen = false; + }), + setActiveQuickChatSession: (sessionId: string) => + set((draft) => { + draft.quickChat.activeSessionId = sessionId; + }), + renameQuickChatSession: (sessionId: string, name: string) => { + let renamed = false; + set((draft) => { + const session = draft.quickChat.sessions.find((item) => item.sessionId === sessionId); + if (session) { + session.name = name; + renamed = true; + } + }); + if (renamed) setStoredQuickChatName(sessionId, name); + }, + }; +} + export const createUISlice: StateCreator = ( set, get, @@ -332,6 +396,7 @@ export const createUISlice: StateCreator set((draft) => { draft.rightPanel.activeTabBySessionId[sessionId] = tab; @@ -355,58 +420,4 @@ export const createUISlice: StateCreator - set((draft) => { - draft.quickChat.isOpen = true; - // If sessionId is empty, create a placeholder tab for agent selection - if (!sessionId) { - // Check if there's already an empty tab - const emptyTabExists = draft.quickChat.sessions.some((s) => s.sessionId === ""); - if (!emptyTabExists) { - draft.quickChat.sessions.push({ sessionId: "", workspaceId }); - } - draft.quickChat.activeSessionId = ""; - return; - } - const existing = draft.quickChat.sessions.find((s) => s.sessionId === sessionId); - if (existing) { - if (agentProfileId) existing.agentProfileId = agentProfileId; - } else { - draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId }); - } - draft.quickChat.activeSessionId = sessionId; - }), - closeQuickChat: () => - set((draft) => { - draft.quickChat.isOpen = false; - }), - closeQuickChatSession: (sessionId) => - set((draft) => { - // Remove session from list - draft.quickChat.sessions = draft.quickChat.sessions.filter((s) => s.sessionId !== sessionId); - // If closing active session, switch to another or close modal - if (draft.quickChat.activeSessionId === sessionId) { - if (draft.quickChat.sessions.length > 0) { - draft.quickChat.activeSessionId = draft.quickChat.sessions[0].sessionId; - } else { - draft.quickChat.activeSessionId = null; - draft.quickChat.isOpen = false; - } - } - }), - setActiveQuickChatSession: (sessionId) => - set((draft) => { - draft.quickChat.activeSessionId = sessionId; - }), - renameQuickChatSession: (sessionId, name) => { - let renamed = false; - set((draft) => { - const session = draft.quickChat.sessions.find((s) => s.sessionId === sessionId); - if (session) { - session.name = name; - renamed = true; - } - }); - if (renamed) setStoredQuickChatName(sessionId, name); - }, }); diff --git a/docs/specs/tasks/quick-chat-expiration.md b/docs/specs/tasks/quick-chat-expiration.md index d620eac80d..3373366e4b 100644 --- a/docs/specs/tasks/quick-chat-expiration.md +++ b/docs/specs/tasks/quick-chat-expiration.md @@ -1,5 +1,5 @@ --- -status: draft +status: shipped created: 2026-07-02 owner: José Almeida --- @@ -7,6 +7,7 @@ owner: José Almeida # Quick Chat Persistence & 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 @@ -16,8 +17,12 @@ recent quick chats to survive a reload, and the system needs a bound so abandoned quick chats don't accumulate forever. ## 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. @@ -33,6 +38,7 @@ abandoned quick chats don't accumulate forever. SHALL NOT expire, regardless of how old it is. ## 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: @@ -57,17 +63,21 @@ guard. The `origin` guard distinguishes user-started quick chats (`origin = last_activity = MAX(task.updated_at, MAX(session.updated_at) over its task_sessions) ``` -Using the greater of the task row and its newest session row makes the metric -robust to whichever row a message/turn bumps. A quick chat expires when -`now - last_activity > 7 days`. +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`. ## API surface + - **Hydration (read):** reuse the existing workspace task listing rather than a - new endpoint. The frontend fetches quick chats via + 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 - most-recently-active-first order). Each restored tab needs + 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`. @@ -78,20 +88,23 @@ robust to whichever row a message/turn bumps. A quick chat expires when client through the existing `task.deleted` WS handler. ## 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): -| 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** | +| 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** | -All four converge on `Service.DeleteTask`, so directory cleanup and event -publishing are identical. +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. ## 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. @@ -109,6 +122,7 @@ publishing are identical. `now`; it does not assume monotonic wall clock beyond the 7-day granularity. ## 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. @@ -125,9 +139,13 @@ publishing are identical. a day of the window elapsing. ## Scenarios + - **GIVEN** an open quick chat with one or more tabs, **WHEN** the user reloads - the page, **THEN** the same tabs reappear, ordered most-recently-active first, + 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 @@ -151,6 +169,7 @@ publishing are identical. iteration). ## 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 @@ -161,8 +180,11 @@ publishing are identical. the existing close behavior. - Changing config-mode chat, automation-run, or kanban/office task lifecycles. -## Open questions -- Does a message/turn in a quick chat currently bump `tasks.updated_at`, or only - `task_sessions.updated_at` / turn rows? The `MAX(task, session)` definition is - chosen to be correct either way, but the implementation should confirm which - row actually moves so the sweep query reads the right column(s). +## 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. diff --git a/docs/specs/tasks/quick-chat-repository-context.md b/docs/specs/tasks/quick-chat-repository-context.md index 91c3a74b55..917e1d1cd9 100644 --- a/docs/specs/tasks/quick-chat-repository-context.md +++ b/docs/specs/tasks/quick-chat-repository-context.md @@ -36,6 +36,9 @@ context without changing their checked-out branches or creating a kanban task. footer uses that same continuous surface, and the new chat action appears immediately after the last chat tab. - Quick-chat tabs remain in creation order across reloads; later activity does not reorder them. +- On `/t/:id` and `/office/tasks/:id` task-detail routes, Quick Chat hydration follows the + task's workspace even when the persisted active-workspace cookie or user setting points + elsewhere. Tabs from other workspaces are not rendered or selected as fallbacks. - Hovering either resize target highlights the dialog's outer border, not an inset line. - The setup flow supports desktop and mobile layouts without horizontal page scrolling or hover-only actions. Mobile quick chat remains full-screen and does not expose resize handles.