fix(quick-chat): restore persisted tabs after task reload (#1703)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user