fix(quick-chat): restore persisted tabs after task reload (#1703)

This commit is contained in:
Carlos Florêncio
2026-07-15 17:40:34 +01:00
committed by GitHub
parent e067e7a1f6
commit 31157142a2
16 changed files with 470 additions and 131 deletions
+39 -4
View File
@@ -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 {
@@ -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,
+9 -1
View File
@@ -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 {
@@ -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},
@@ -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();
@@ -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();
});
});
@@ -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 (
<>
@@ -32,7 +32,11 @@ type MockStore = Parameters<typeof useAgentSelection>[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", () => {
@@ -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<typeof useQuickChatStore>;
@@ -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<string | null>(null);
const {
@@ -71,6 +71,17 @@ function baseState(entries: QueuedMessage[]) {
const CHILD = <div data-testid="child-marker">input</div>;
function pressQueueEscape(): ReturnType<typeof vi.fn> {
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(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
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(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
@@ -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(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
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([]));
@@ -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]);
}
@@ -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);
});
});
+94 -24
View File
@@ -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<Locator> {
await page.goto("/");
await page.waitForLoadState("networkidle");
async function openQuickChatSetup(page: Page, navigateHome = true): Promise<Locator> {
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<Locator> {
const dialog = await openQuickChatSetup(page);
async function openQuickChatWithAgent(page: Page, navigateHome = true): Promise<Locator> {
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,
});
});
});
+65 -54
View File
@@ -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<UISlice, [["zustand/immer", never]], [], UISlice> = (
set,
get,
@@ -332,6 +396,7 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
...buildSystemHealthActions(set),
...buildDismissedAgentErrors(set),
...buildNotificationActions(set),
...buildQuickChatActions(set),
setRightPanelActiveTab: (sessionId, tab) =>
set((draft) => {
draft.rightPanel.activeTabBySessionId[sessionId] = tab;
@@ -355,58 +420,4 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
if (draft.kanbanPreviewedTaskId === taskId) return;
draft.kanbanPreviewedTaskId = taskId;
}),
openQuickChat: (sessionId, workspaceId, agentProfileId) =>
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);
},
});
+42 -20
View File
@@ -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.
@@ -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.