feat: add quick chat activity indicators (#2952)
This commit is contained in:
@@ -6,6 +6,8 @@ import type { DestinationIcon } from "@/lib/navigation/types";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator";
|
||||
import type { QuickChatActivityState } from "@/lib/state/slices/ui/quick-chat-activity-selectors";
|
||||
import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "./app-sidebar-constants";
|
||||
|
||||
type AppSidebarNavItemProps = {
|
||||
@@ -14,7 +16,7 @@ type AppSidebarNavItemProps = {
|
||||
href?: string;
|
||||
badge?: number;
|
||||
badgeVariant?: "primary" | "muted";
|
||||
dot?: boolean;
|
||||
activity?: QuickChatActivityState;
|
||||
onClick?: () => void;
|
||||
collapsed: boolean;
|
||||
/** Override the auto-derived active-state from pathname. */
|
||||
@@ -77,17 +79,6 @@ function isPathActive(pathname: string, href: string | undefined, exactMatch: bo
|
||||
return hrefPathname !== "/" && pathname.startsWith(`${hrefPathname}/`);
|
||||
}
|
||||
|
||||
function renderUnseenDot(dot: boolean) {
|
||||
if (!dot) return null;
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
data-testid="quick-chat-unseen-dot"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function sidebarBadgeClass(variant: NonNullable<AppSidebarNavItemProps["badgeVariant"]>) {
|
||||
return cn(
|
||||
"rounded-full px-1.5 py-0.5 text-xs",
|
||||
@@ -108,7 +99,7 @@ export function AppSidebarNavItem({
|
||||
disabled = false,
|
||||
testId,
|
||||
className,
|
||||
dot = false,
|
||||
activity = null,
|
||||
}: AppSidebarNavItemProps) {
|
||||
const pathname = usePathname();
|
||||
const active = isActive ?? isPathActive(pathname, href, exactMatch);
|
||||
@@ -126,7 +117,7 @@ export function AppSidebarNavItem({
|
||||
<>
|
||||
<span className="relative flex">
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{renderUnseenDot(dot)}
|
||||
<QuickChatActivityIndicator activity={activity} />
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
|
||||
@@ -35,6 +35,18 @@ const state = {
|
||||
workflowId: "wf-1" as string | null,
|
||||
steps: [{ id: "s1", title: "Todo" }],
|
||||
},
|
||||
quickChat: {
|
||||
isOpen: false,
|
||||
sessions: [] as Array<{
|
||||
sessionId: string;
|
||||
workspaceId: string;
|
||||
kind: "chat";
|
||||
taskId?: string;
|
||||
}>,
|
||||
unseenIdleByWorkspace: {} as Record<string, Record<string, true>>,
|
||||
},
|
||||
taskSessions: { items: {} as Record<string, { state: string; task_id: string }> },
|
||||
prepareProgress: { bySessionId: {} as Record<string, { status: string }> },
|
||||
setActiveTask: mocks.setActiveTask,
|
||||
setActiveSession: mocks.setActiveSession,
|
||||
setImproveDialogOpen: mocks.setImproveDialogOpen,
|
||||
@@ -133,6 +145,11 @@ function resetTestState() {
|
||||
state.appSidebar.improveDialogOpen = false;
|
||||
state.kanban.workflowId = "wf-1";
|
||||
state.kanban.steps = [{ id: "s1", title: "Todo" }];
|
||||
state.quickChat.isOpen = false;
|
||||
state.quickChat.sessions = [];
|
||||
state.quickChat.unseenIdleByWorkspace = {};
|
||||
state.taskSessions.items = {};
|
||||
state.prepareProgress.bySessionId = {};
|
||||
mocks.routerPush.mockClear();
|
||||
mocks.setActiveTask.mockClear();
|
||||
mocks.setActiveSession.mockClear();
|
||||
@@ -272,6 +289,43 @@ describe("AppSidebarNewTaskItem row actions", () => {
|
||||
expect(mocks.openQuickChat).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows a running activity bubble on the Quick Chat shortcut", () => {
|
||||
state.quickChat.sessions = [
|
||||
{ sessionId: "session-1", workspaceId: WORKSPACE_ID, kind: "chat", taskId: "task-1" },
|
||||
];
|
||||
state.taskSessions.items = {
|
||||
"session-1": { state: "RUNNING", task_id: "task-1" },
|
||||
};
|
||||
|
||||
renderItem(false);
|
||||
|
||||
const quickChat = screen.getByRole("button", { name: "Quick Chat, agent working" });
|
||||
expect(
|
||||
quickChat
|
||||
.querySelector('[data-testid="quick-chat-activity-indicator"]')
|
||||
?.getAttribute("data-state"),
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it("shows a finished activity bubble for an unseen response", () => {
|
||||
state.quickChat.sessions = [
|
||||
{ sessionId: "session-1", workspaceId: WORKSPACE_ID, kind: "chat", taskId: "task-1" },
|
||||
];
|
||||
state.taskSessions.items = {
|
||||
"session-1": { state: "COMPLETED", task_id: "task-1" },
|
||||
};
|
||||
state.quickChat.unseenIdleByWorkspace = { [WORKSPACE_ID]: { "session-1": true } };
|
||||
|
||||
renderItem(false);
|
||||
|
||||
const quickChat = screen.getByRole("button", { name: "Quick Chat, new response" });
|
||||
expect(
|
||||
quickChat
|
||||
.querySelector('[data-testid="quick-chat-activity-indicator"]')
|
||||
?.getAttribute("data-state"),
|
||||
).toBe("finished");
|
||||
});
|
||||
|
||||
it("hides the quick chat shortcut when the rail is collapsed", () => {
|
||||
renderItem(true);
|
||||
expect(screen.queryByTestId(QUICK_CHAT_TEST_ID)).toBeNull();
|
||||
|
||||
@@ -16,7 +16,9 @@ import { IMPROVE_KANDEV_WORKSPACE_NAME } from "@/components/improve-kandev-dialo
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import { subscribeNewTaskCreationRequests } from "@/lib/desktop/new-task-request";
|
||||
import { selectQuickChatHasUnseenIdle } from "@/lib/state/slices/ui/quick-chat-unseen-selectors";
|
||||
import type { QuickChatActivityState } from "@/lib/state/slices/ui/quick-chat-activity-selectors";
|
||||
import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator";
|
||||
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
|
||||
import { AppSidebarWorkspaceActions } from "./app-sidebar-workspace-actions";
|
||||
|
||||
// The Office "New issue" dialog only renders on `/office` routes, but this item
|
||||
@@ -43,7 +45,7 @@ type RowActionButtonProps = {
|
||||
icon: TablerIcon;
|
||||
label: string;
|
||||
testId: string;
|
||||
dot?: boolean;
|
||||
activity?: QuickChatActivityState;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
@@ -52,7 +54,7 @@ function RowActionButton({
|
||||
label,
|
||||
testId,
|
||||
onClick,
|
||||
dot = false,
|
||||
activity = null,
|
||||
}: RowActionButtonProps) {
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const hoveredRef = useRef(false);
|
||||
@@ -85,13 +87,7 @@ function RowActionButton({
|
||||
>
|
||||
<span className="relative flex">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{dot && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
data-testid="quick-chat-unseen-dot"
|
||||
/>
|
||||
)}
|
||||
<QuickChatActivityIndicator activity={activity} />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -179,12 +175,7 @@ export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps)
|
||||
const setActiveSession = useAppStore((s) => s.setActiveSession);
|
||||
const inOffice = useInOffice();
|
||||
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
|
||||
const quickChatHasUnseenIdle = useAppStore((state) =>
|
||||
selectQuickChatHasUnseenIdle(state, workspaceId),
|
||||
);
|
||||
const quickChatLabel = t(
|
||||
quickChatHasUnseenIdle ? "sidebar:quickChatUnseen" : "sidebar:quickChat",
|
||||
);
|
||||
const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId);
|
||||
const handleOpenQuickTerminal = useQuickTerminalLauncher(workspaceId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isImproveWorkspace = activeWorkspace?.name === IMPROVE_KANDEV_WORKSPACE_NAME;
|
||||
@@ -242,7 +233,7 @@ export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps)
|
||||
label={quickChatLabel}
|
||||
testId="sidebar-quick-chat-shortcut"
|
||||
onClick={handleOpenQuickChat}
|
||||
dot={quickChatHasUnseenIdle}
|
||||
activity={quickChatActivity}
|
||||
/>
|
||||
<AppSidebarWorkspaceActions
|
||||
workspaceId={workspaceId}
|
||||
|
||||
@@ -9,9 +9,21 @@ const mocks = vi.hoisted(() => ({
|
||||
const state = {
|
||||
workspaces: { activeId: "ws-1" as string | null },
|
||||
office: { inboxCountByWorkspaceId: {} as Record<string, number> },
|
||||
quickChat: { unseenIdleByWorkspace: {} as Record<string, Record<string, true>> },
|
||||
quickChat: {
|
||||
isOpen: false,
|
||||
sessions: [] as Array<{
|
||||
sessionId: string;
|
||||
workspaceId: string;
|
||||
kind: "chat";
|
||||
taskId?: string;
|
||||
}>,
|
||||
unseenIdleByWorkspace: {} as Record<string, Record<string, true>>,
|
||||
},
|
||||
taskSessions: { items: {} as Record<string, { state: string; task_id: string }> },
|
||||
prepareProgress: { bySessionId: {} as Record<string, { status: string }> },
|
||||
};
|
||||
const QUICK_CHAT_LABEL = "Quick Chat";
|
||||
const QUICK_CHAT_RUNNING_LABEL = "Quick Chat, agent working";
|
||||
const QUICK_CHAT_UNSEEN_LABEL = "Quick Chat, new response";
|
||||
let mode: "office" | "kanban" | "unknown" = "kanban";
|
||||
let pathname = "/";
|
||||
@@ -53,7 +65,11 @@ describe("AppSidebarPrimaryNav", () => {
|
||||
beforeEach(() => {
|
||||
state.workspaces.activeId = "ws-1";
|
||||
state.office.inboxCountByWorkspaceId = {};
|
||||
state.quickChat.isOpen = false;
|
||||
state.quickChat.sessions = [];
|
||||
state.quickChat.unseenIdleByWorkspace = {};
|
||||
state.taskSessions.items = {};
|
||||
state.prepareProgress.bySessionId = {};
|
||||
mode = "kanban";
|
||||
pathname = "/";
|
||||
mocks.openQuickChat.mockClear();
|
||||
@@ -70,11 +86,36 @@ describe("AppSidebarPrimaryNav", () => {
|
||||
});
|
||||
|
||||
it("renders an unseen marker on the collapsed Quick Chat rail entry", () => {
|
||||
state.quickChat.sessions = [
|
||||
{ sessionId: "session-1", workspaceId: "ws-1", kind: "chat", taskId: "task-1" },
|
||||
];
|
||||
state.taskSessions.items = {
|
||||
"session-1": { state: "COMPLETED", task_id: "task-1" },
|
||||
};
|
||||
state.quickChat.unseenIdleByWorkspace = { "ws-1": { "session-1": true } };
|
||||
renderNav(true);
|
||||
const quickChat = screen.getByRole("button", { name: QUICK_CHAT_UNSEEN_LABEL });
|
||||
|
||||
expect(quickChat.querySelector('[data-testid="quick-chat-unseen-dot"]')).not.toBeNull();
|
||||
const indicator = quickChat.querySelector('[data-testid="quick-chat-activity-indicator"]');
|
||||
expect(indicator?.getAttribute("data-state")).toBe("finished");
|
||||
});
|
||||
|
||||
it("prioritizes a running activity marker over an unseen response", () => {
|
||||
state.quickChat.sessions = [
|
||||
{ sessionId: "session-1", workspaceId: "ws-1", kind: "chat", taskId: "task-1" },
|
||||
];
|
||||
state.taskSessions.items = {
|
||||
"session-1": { state: "RUNNING", task_id: "task-1" },
|
||||
};
|
||||
state.quickChat.unseenIdleByWorkspace = { "ws-1": { "session-1": true } };
|
||||
renderNav(true);
|
||||
|
||||
const quickChat = screen.getByRole("button", { name: QUICK_CHAT_RUNNING_LABEL });
|
||||
expect(
|
||||
quickChat
|
||||
.querySelector('[data-testid="quick-chat-activity-indicator"]')
|
||||
?.getAttribute("data-state"),
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it("omits the standalone Quick Chat row while expanded", () => {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { selectOfficeInboxCount } from "@/lib/state/slices/office/selectors";
|
||||
import { selectQuickChatHasUnseenIdle } from "@/lib/state/slices/ui/quick-chat-unseen-selectors";
|
||||
import { useOfficeModeState } from "@/hooks/use-in-office";
|
||||
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
|
||||
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
|
||||
import { homeDestinationHref } from "@/lib/navigation/core-destinations";
|
||||
import { AppSidebarNavItem } from "./app-sidebar-nav-item";
|
||||
import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item";
|
||||
@@ -22,12 +22,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
|
||||
const mode = useOfficeModeState();
|
||||
const inOffice = mode === "office";
|
||||
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
|
||||
const quickChatHasUnseenIdle = useAppStore((state) =>
|
||||
selectQuickChatHasUnseenIdle(state, workspaceId),
|
||||
);
|
||||
const quickChatLabel = t(
|
||||
quickChatHasUnseenIdle ? "sidebar:quickChatUnseen" : "sidebar:quickChat",
|
||||
);
|
||||
const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId);
|
||||
const homeHref = mode === "unknown" ? undefined : homeDestinationHref({ workspaceId, inOffice });
|
||||
|
||||
return (
|
||||
@@ -57,7 +52,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
|
||||
label={quickChatLabel}
|
||||
onClick={handleOpenQuickChat}
|
||||
collapsed={collapsed}
|
||||
dot={quickChatHasUnseenIdle}
|
||||
activity={quickChatActivity}
|
||||
/>
|
||||
)}
|
||||
<AppSidebarNewTaskItem collapsed={collapsed} />
|
||||
|
||||
@@ -17,7 +17,8 @@ import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-s
|
||||
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
|
||||
import { useQuickTerminalLauncher } from "@/hooks/use-quick-terminal-launcher";
|
||||
import { workspaceHomeHref } from "@/lib/navigation/workspace-home";
|
||||
import { selectQuickChatHasUnseenIdle } from "@/lib/state/slices/ui/quick-chat-unseen-selectors";
|
||||
import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator";
|
||||
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type KanbanHeaderMobileProps = {
|
||||
@@ -72,9 +73,7 @@ function MobileQuickChatButton({
|
||||
workspaceId: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dot = useAppStore((state) => selectQuickChatHasUnseenIdle(state, workspaceId));
|
||||
const quickChatLabel = t(dot ? "sidebar:quickChatUnseen" : "sidebar:quickChat");
|
||||
const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId);
|
||||
return (
|
||||
<MobileLauncherTarget onClick={onClick} testId="mobile-quick-chat-hit-target">
|
||||
<Button
|
||||
@@ -85,13 +84,7 @@ function MobileQuickChatButton({
|
||||
>
|
||||
<span className="relative flex">
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
{dot && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
data-testid="quick-chat-unseen-dot"
|
||||
/>
|
||||
)}
|
||||
<QuickChatActivityIndicator activity={quickChatActivity} />
|
||||
</span>
|
||||
</Button>
|
||||
</MobileLauncherTarget>
|
||||
|
||||
@@ -28,7 +28,8 @@ import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options";
|
||||
import { linkToTaskOverview, linkToTasks } from "@/lib/links";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { selectQuickChatHasUnseenIdle } from "@/lib/state/slices/ui/quick-chat-unseen-selectors";
|
||||
import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator";
|
||||
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
|
||||
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
|
||||
import { useReleaseNotes } from "@/hooks/use-release-notes";
|
||||
import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator";
|
||||
@@ -161,12 +162,7 @@ function useIsHeaderNarrow(ref: RefObject<HTMLElement | null>): boolean {
|
||||
function TabletQuickActions({ workspaceId }: { workspaceId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
|
||||
const quickChatHasUnseenIdle = useAppStore((state) =>
|
||||
selectQuickChatHasUnseenIdle(state, workspaceId),
|
||||
);
|
||||
const quickChatLabel = t(
|
||||
quickChatHasUnseenIdle ? "sidebar:quickChatUnseen" : "sidebar:quickChat",
|
||||
);
|
||||
const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId);
|
||||
const handleOpenQuickTerminal = useQuickTerminalLauncher(workspaceId);
|
||||
if (!workspaceId) return null;
|
||||
|
||||
@@ -192,13 +188,7 @@ function TabletQuickActions({ workspaceId }: { workspaceId?: string }) {
|
||||
>
|
||||
<span className="relative flex">
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
{quickChatHasUnseenIdle && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
data-testid="quick-chat-unseen-dot"
|
||||
/>
|
||||
)}
|
||||
<QuickChatActivityIndicator activity={quickChatActivity} />
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { QuickChatActivityIndicator } from "./quick-chat-activity-indicator";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("QuickChatActivityIndicator", () => {
|
||||
it.each([
|
||||
["running", "bg-blue-500"],
|
||||
["finished", "bg-emerald-500"],
|
||||
] as const)("maps %s to the shared semantic bubble", (activity, colorClass) => {
|
||||
const { getByTestId } = render(<QuickChatActivityIndicator activity={activity} />);
|
||||
const indicator = getByTestId("quick-chat-activity-indicator");
|
||||
|
||||
expect(indicator.getAttribute("data-state")).toBe(activity);
|
||||
expect(indicator.className).toContain(colorClass);
|
||||
expect(indicator.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders nothing when there is no activity", () => {
|
||||
const { queryByTestId } = render(<QuickChatActivityIndicator activity={null} />);
|
||||
|
||||
expect(queryByTestId("quick-chat-activity-indicator")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { QuickChatActivityState } from "@/lib/state/slices/ui/quick-chat-activity-selectors";
|
||||
|
||||
type QuickChatActivityIndicatorProps = {
|
||||
activity: QuickChatActivityState;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Decorative activity bubble shared by every Quick Chat entry point. */
|
||||
export function QuickChatActivityIndicator({
|
||||
activity,
|
||||
className,
|
||||
}: QuickChatActivityIndicatorProps) {
|
||||
if (!activity) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute -right-1 -top-1 h-2 w-2 rounded-full ring-2 ring-background",
|
||||
activity === "running" ? "bg-blue-500" : "bg-emerald-500",
|
||||
className,
|
||||
)}
|
||||
data-legacy-testid="quick-chat-unseen-dot"
|
||||
data-state={activity}
|
||||
data-testid="quick-chat-activity-indicator"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard";
|
||||
|
||||
const setQuickChatInitialPrompt = vi.fn();
|
||||
const quickChatSessionItems: Record<string, { state: string; task_id: string }> = {};
|
||||
const quickChatPrepareProgress: Record<string, { status: string }> = {};
|
||||
const handleActivateTerminal = vi.fn();
|
||||
const handleNewTerminal = vi.fn();
|
||||
const handleCloseTerminal = vi.fn();
|
||||
@@ -18,8 +20,17 @@ const TOGGLE_ESCAPE_GUARD_TESTID = "toggle-escape-guard";
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (
|
||||
selector: (state: { setQuickChatInitialPrompt: typeof setQuickChatInitialPrompt }) => unknown,
|
||||
) => selector({ setQuickChatInitialPrompt }),
|
||||
selector: (state: {
|
||||
setQuickChatInitialPrompt: typeof setQuickChatInitialPrompt;
|
||||
taskSessions: { items: typeof quickChatSessionItems };
|
||||
prepareProgress: { bySessionId: typeof quickChatPrepareProgress };
|
||||
}) => unknown,
|
||||
) =>
|
||||
selector({
|
||||
setQuickChatInitialPrompt,
|
||||
taskSessions: { items: quickChatSessionItems },
|
||||
prepareProgress: { bySessionId: quickChatPrepareProgress },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/routing/client-dynamic", () => ({
|
||||
@@ -182,6 +193,8 @@ import { QuickChatModal } from "./quick-chat-modal";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
for (const key of Object.keys(quickChatSessionItems)) delete quickChatSessionItems[key];
|
||||
for (const key of Object.keys(quickChatPrepareProgress)) delete quickChatPrepareProgress[key];
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -202,6 +215,30 @@ describe("QuickChatModal mixed tabs", () => {
|
||||
expect(screen.queryByTestId("quick-chat-menu-session-chat-1")).toBeNull();
|
||||
expect(screen.queryByTestId("quick-chat-menu-terminal-terminal-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a spinner only for working conversation tabs", () => {
|
||||
quickChatSessionItems["chat-1"] = { state: "RUNNING", task_id: "task-1" };
|
||||
quickChatSessionItems["quick-chat-setup:ws-1:chat"] = {
|
||||
state: "RUNNING",
|
||||
task_id: "task-setup",
|
||||
};
|
||||
useQuickChatModalMock.mockReturnValue({
|
||||
...defaultQuickChatModalState,
|
||||
activeKind: "conversation",
|
||||
activeSessionNeedsAgent: false,
|
||||
terminalTabs: [],
|
||||
sessions: [
|
||||
...defaultQuickChatModalState.sessions,
|
||||
{ sessionId: "quick-chat-setup:ws-1:chat", workspaceId: WORKSPACE_ID, kind: "chat" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<QuickChatModal workspaceId={WORKSPACE_ID} />);
|
||||
|
||||
const tabs = screen.getAllByTestId("quick-chat-tab");
|
||||
expect(within(tabs[0]).getByRole("status", { name: "Loading" })).toBeTruthy();
|
||||
expect(tabs[1].querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuickChatModal — Escape with a pending clarification", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ConfigChatSetup } from "@/components/config-chat/config-chat-setup";
|
||||
import { useConfigChat } from "@/components/config-chat/use-config-chat";
|
||||
import type { QuickChatSession, QuickTerminalTab } from "@/lib/state/slices/ui/types";
|
||||
import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
|
||||
import { selectQuickChatSessionIsWorking } from "@/lib/state/slices/ui/quick-chat-activity-selectors";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
const QuickTerminalTabView = dynamic(
|
||||
@@ -42,6 +43,31 @@ function quickChatTabName(t: TFunction, session: QuickChatSession, index: number
|
||||
return session.kind === "config" ? t("chat:configurationChatTab") : t("chat:newChatTab");
|
||||
}
|
||||
|
||||
function QuickChatConversationTab({
|
||||
session,
|
||||
...props
|
||||
}: {
|
||||
session: QuickChatSession;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
onRename: (name: string) => void;
|
||||
}) {
|
||||
const isWorking = useAppStore((state) =>
|
||||
selectQuickChatSessionIsWorking(state, session.sessionId),
|
||||
);
|
||||
|
||||
return (
|
||||
<QuickChatTabItem
|
||||
{...props}
|
||||
isRenameable={!isQuickChatSetupSessionId(session.sessionId)}
|
||||
isWorking={!isQuickChatSetupSessionId(session.sessionId) && isWorking}
|
||||
kind={session.kind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickChatTabs({
|
||||
sessions,
|
||||
terminalTabs,
|
||||
@@ -78,12 +104,11 @@ function QuickChatTabs({
|
||||
{sessions.map((s, index) => {
|
||||
const tabName = quickChatTabName(t, s, index);
|
||||
return (
|
||||
<QuickChatTabItem
|
||||
<QuickChatConversationTab
|
||||
key={s.sessionId || `new-${index}`}
|
||||
session={s}
|
||||
name={tabName}
|
||||
isActive={activeKind === "conversation" && s.sessionId === activeSessionId}
|
||||
isRenameable={!isQuickChatSetupSessionId(s.sessionId)}
|
||||
kind={s.kind}
|
||||
onActivate={() => onTabChange(s.sessionId)}
|
||||
onClose={() => onTabClose(s.sessionId)}
|
||||
onRename={(name) => onRename(s.sessionId, name)}
|
||||
|
||||
@@ -119,3 +119,17 @@ describe("QuickChatTabItem rename", () => {
|
||||
expect(queryByLabelText(RENAME_LABEL)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuickChatTabItem activity", () => {
|
||||
it("shows the grid spinner while its conversation is working", () => {
|
||||
const { getByRole } = render(<QuickChatTabItem {...makeProps({ isWorking: true })} />);
|
||||
|
||||
expect(getByRole("status", { name: "Loading" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show a spinner for a settled conversation", () => {
|
||||
const { queryByRole } = render(<QuickChatTabItem {...makeProps({ isWorking: false })} />);
|
||||
|
||||
expect(queryByRole("status", { name: "Loading" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
|
||||
|
||||
type QuickChatTabItemProps = {
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
isRenameable: boolean;
|
||||
isWorking?: boolean;
|
||||
kind?: QuickChatSessionKind;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
@@ -42,6 +44,7 @@ export const QuickChatTabItem = memo(function QuickChatTabItem({
|
||||
name,
|
||||
isActive,
|
||||
isRenameable,
|
||||
isWorking = false,
|
||||
kind = "chat",
|
||||
onActivate,
|
||||
onClose,
|
||||
@@ -128,6 +131,7 @@ export const QuickChatTabItem = memo(function QuickChatTabItem({
|
||||
title={isRenameable ? t("chat:doubleClickToRename") : undefined}
|
||||
className="flex items-center px-2.5 py-1 text-xs cursor-pointer"
|
||||
>
|
||||
{isWorking && <GridSpinner className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
{kind === "config" && (
|
||||
<span role="img" aria-label={t("chat:configurationChat")} className="mr-1.5 shrink-0">
|
||||
<IconSparkles className="h-3 w-3" aria-hidden />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import {
|
||||
selectQuickChatActivity,
|
||||
type QuickChatActivityState,
|
||||
} from "@/lib/state/slices/ui/quick-chat-activity-selectors";
|
||||
|
||||
function quickChatLabelKey(activity: QuickChatActivityState) {
|
||||
if (activity === "running") return "sidebar:quickChatRunning";
|
||||
if (activity === "finished") return "sidebar:quickChatUnseen";
|
||||
return "sidebar:quickChat";
|
||||
}
|
||||
|
||||
export function useQuickChatActivity(workspaceId: string | null | undefined): {
|
||||
activity: QuickChatActivityState;
|
||||
label: string;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const activity = useAppStore((state) => selectQuickChatActivity(state, workspaceId));
|
||||
const label = t(quickChatLabelKey(activity));
|
||||
|
||||
return { activity, label };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { IconMessageCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { selectQuickChatHasUnseenIdle } from "@/lib/state/slices/ui/quick-chat-unseen-selectors";
|
||||
import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator";
|
||||
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
|
||||
|
||||
/** Quick Chat action in the mobile task-switcher sheet, with the unseen-idle dot. */
|
||||
/** Quick Chat action in the mobile task-switcher sheet, with activity state. */
|
||||
export function QuickChatSheetButton({
|
||||
workspaceId,
|
||||
onClick,
|
||||
@@ -13,8 +13,7 @@ export function QuickChatSheetButton({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasUnseenIdle = useAppStore((state) => selectQuickChatHasUnseenIdle(state, workspaceId));
|
||||
const quickChatLabel = t(hasUnseenIdle ? "sidebar:quickChatUnseen" : "sidebar:quickChat");
|
||||
const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId);
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -26,13 +25,7 @@ export function QuickChatSheetButton({
|
||||
>
|
||||
<span className="relative flex">
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
{hasUnseenIdle && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
data-testid="quick-chat-unseen-dot"
|
||||
/>
|
||||
)}
|
||||
<QuickChatActivityIndicator activity={quickChatActivity} />
|
||||
</span>
|
||||
{t("task:chat")}
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { watchWs } from "../../helpers/causal-waits";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { sendQuickChatMessage, startQuickChatFromSetup } from "./quick-chat-helpers";
|
||||
import {
|
||||
sendQuickChatMessage,
|
||||
startQuickChatFromSetup,
|
||||
waitForSessionSettled,
|
||||
waitForSessionSettledBaseline,
|
||||
} from "./quick-chat-helpers";
|
||||
|
||||
test.describe("quick chat idle dot", () => {
|
||||
test("marks the mobile header after a closed quick chat turn completes", async ({ testPage }) => {
|
||||
test.describe("quick chat activity indicators", () => {
|
||||
test("shows the mobile header running and finished states through touch", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const ws = watchWs(testPage);
|
||||
await testPage.goto("/");
|
||||
await testPage.getByTestId("mobile-quick-chat-button").tap();
|
||||
@@ -17,40 +25,41 @@ test.describe("quick chat idle dot", () => {
|
||||
await expect(
|
||||
testPage.getByRole("status", { name: /Agent is (starting|running)/ }),
|
||||
).not.toBeVisible();
|
||||
const { session_id: sessionId } = (await (await created).json()) as { session_id: string };
|
||||
const { session_id: sessionId, task_id: taskId } = (await (await created).json()) as {
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
};
|
||||
const tab = dialog.getByTestId("quick-chat-tab");
|
||||
const button = testPage.getByTestId("mobile-quick-chat-button");
|
||||
const indicator = button.getByTestId("quick-chat-activity-indicator");
|
||||
|
||||
await expect(tab).toHaveCount(1);
|
||||
await waitForSessionSettledBaseline(apiClient, taskId, sessionId);
|
||||
const completed = ws.waitForEvent("session.turn.completed", {
|
||||
where: (payload) => payload.session_id === sessionId,
|
||||
});
|
||||
const settled = waitForSessionSettled(ws, sessionId);
|
||||
await sendQuickChatMessage(dialog, testPage, "/slow 8s");
|
||||
await expect(dialog.getByText("Running slow response", { exact: false })).toBeVisible();
|
||||
await expect(tab.getByRole("status")).toBeVisible();
|
||||
|
||||
await dialog.getByTestId("quick-chat-close").tap();
|
||||
await completed;
|
||||
await expect(button.getByTestId("quick-chat-unseen-dot")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(indicator).toHaveAttribute("data-state", "running");
|
||||
|
||||
await Promise.all([completed, settled]);
|
||||
await expect(indicator).toHaveAttribute("data-state", "finished");
|
||||
|
||||
await button.tap();
|
||||
await expect(button.getByTestId("quick-chat-unseen-dot")).toHaveCount(0);
|
||||
// The reopened dialog re-subscribes over WS; wait until the previous
|
||||
// exchange has rendered so the send cannot race a dead subscription.
|
||||
await expect(dialog.getByText("Slow response complete", { exact: false })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const secondCompleted = ws.waitForEvent("session.turn.completed", {
|
||||
where: (payload) => payload.session_id === sessionId,
|
||||
});
|
||||
await sendQuickChatMessage(dialog, testPage, "/slow 8s");
|
||||
await dialog.getByTestId("quick-chat-close").tap();
|
||||
await secondCompleted;
|
||||
await expect(button.getByTestId("quick-chat-unseen-dot")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(indicator).toHaveCount(0);
|
||||
await expect(dialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("marks the task switcher entry after a closed quick chat turn completes", async ({
|
||||
test("keeps the task-switcher entry in sync with the same activity state", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const ws = watchWs(testPage);
|
||||
const seeded = await apiClient.seedTask(seedData.workspaceId, "Mobile Quick Chat Idle Dot", {
|
||||
const seeded = await apiClient.seedTask(seedData.workspaceId, "Mobile Quick Chat Activity", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
@@ -60,7 +69,7 @@ test.describe("quick chat idle dot", () => {
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const entry = sheet.getByTestId("mobile-sheet-quick-chat");
|
||||
await expect(entry.getByTestId("quick-chat-unseen-dot")).toHaveCount(0);
|
||||
await expect(entry.getByTestId("quick-chat-activity-indicator")).toHaveCount(0);
|
||||
|
||||
const created = testPage.waitForResponse(
|
||||
(response) =>
|
||||
@@ -69,15 +78,37 @@ test.describe("quick chat idle dot", () => {
|
||||
await entry.tap();
|
||||
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
|
||||
await startQuickChatFromSetup(dialog, testPage);
|
||||
const { session_id: sessionId } = (await (await created).json()) as { session_id: string };
|
||||
const { session_id: sessionId, task_id: taskId } = (await (await created).json()) as {
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
};
|
||||
await waitForSessionSettledBaseline(apiClient, taskId, sessionId);
|
||||
const completed = ws.waitForEvent("session.turn.completed", {
|
||||
where: (payload) => payload.session_id === sessionId,
|
||||
});
|
||||
const settled = waitForSessionSettled(ws, sessionId);
|
||||
await sendQuickChatMessage(dialog, testPage, "/slow 8s");
|
||||
await expect(dialog.getByTestId("quick-chat-tab").getByRole("status")).toBeVisible();
|
||||
await dialog.getByTestId("quick-chat-close").tap();
|
||||
|
||||
await completed;
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
await expect(entry.getByTestId("quick-chat-unseen-dot")).toBeVisible({ timeout: 15_000 });
|
||||
const openEntry = testPage
|
||||
.getByRole("dialog", { name: "Tasks" })
|
||||
.getByTestId("mobile-sheet-quick-chat");
|
||||
await expect(openEntry.getByTestId("quick-chat-activity-indicator")).toHaveAttribute(
|
||||
"data-state",
|
||||
"running",
|
||||
);
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
await Promise.all([completed, settled]);
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
const finishedEntry = testPage
|
||||
.getByRole("dialog", { name: "Tasks" })
|
||||
.getByTestId("mobile-sheet-quick-chat");
|
||||
await expect(finishedEntry.getByTestId("quick-chat-activity-indicator")).toHaveAttribute(
|
||||
"data-state",
|
||||
"finished",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,13 @@ test.describe("Quick Chat repository context on mobile", () => {
|
||||
|
||||
const originalTabs = dialog.getByTestId("quick-chat-tab");
|
||||
await expect(originalTabs).toHaveCount(2);
|
||||
const originalNames = await originalTabs.locator("span").allTextContents();
|
||||
const readTabNames = (tabs: typeof originalTabs) =>
|
||||
tabs
|
||||
.locator("button")
|
||||
.evaluateAll((buttons) =>
|
||||
buttons.map((button) => button.textContent?.trim() ?? "").filter(Boolean),
|
||||
);
|
||||
const originalNames = await readTabNames(originalTabs);
|
||||
|
||||
await testPage.reload();
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
@@ -57,7 +63,7 @@ test.describe("Quick Chat repository context on mobile", () => {
|
||||
const restoredTabs = restoredDialog.getByTestId("quick-chat-tab");
|
||||
await expect(restoredTabs).toHaveCount(2);
|
||||
await expect
|
||||
.poll(async () => (await restoredTabs.locator("span").allTextContents()).toSorted())
|
||||
.poll(async () => (await readTabNames(restoredTabs)).toSorted())
|
||||
.toEqual(originalNames.toSorted());
|
||||
await expect(restoredDialog.getByTestId("quick-chat-setup")).not.toBeVisible();
|
||||
await assertNoDocumentHorizontalOverflow(testPage);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { WsWatcher } from "../../helpers/causal-waits";
|
||||
|
||||
/**
|
||||
* Shared Quick Chat drivers. Kept out of the spec files so several specs
|
||||
@@ -7,6 +9,54 @@ import { expect } from "../../fixtures/test-base";
|
||||
* without duplicating its eager-init timing workarounds.
|
||||
*/
|
||||
|
||||
const SETTLED_SESSION_STATES = new Set([
|
||||
"IDLE",
|
||||
"WAITING_FOR_INPUT",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"CANCELLED",
|
||||
]);
|
||||
const ACTIVE_SESSION_STATES = new Set(["STARTING", "RUNNING"]);
|
||||
|
||||
/**
|
||||
* Establish a backend-confirmed settle point before arming waits for the next
|
||||
* turn. The WS watcher does not replay notifications, so the startup settle
|
||||
* must be observed through the session snapshot rather than a guessed delay.
|
||||
*/
|
||||
export async function waitForSessionSettledBaseline(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
return session ? SETTLED_SESSION_STATES.has(session.state) : false;
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: `session ${sessionId} did not reach a settled baseline`,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the settle transition caused by the next turn. Requiring an active
|
||||
* old state prevents a queued startup/previous-turn notification from being
|
||||
* mistaken for the response to the message under test.
|
||||
*/
|
||||
export function waitForSessionSettled(ws: WsWatcher, sessionId: string) {
|
||||
return ws.waitForEvent("session.state_changed", {
|
||||
where: (payload) =>
|
||||
payload.session_id === sessionId &&
|
||||
ACTIVE_SESSION_STATES.has(payload.old_state ?? "") &&
|
||||
SETTLED_SESSION_STATES.has(payload.new_state ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
export async function openQuickChatSetup(page: Page, navigateHome = true): Promise<Locator> {
|
||||
if (navigateHome) {
|
||||
await page.goto("/");
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { watchWs } from "../../helpers/causal-waits";
|
||||
import { openQuickChatWithAgent, sendQuickChatMessage } from "./quick-chat-helpers";
|
||||
import {
|
||||
openQuickChatWithAgent,
|
||||
sendQuickChatMessage,
|
||||
waitForSessionSettled,
|
||||
waitForSessionSettledBaseline,
|
||||
} from "./quick-chat-helpers";
|
||||
|
||||
test.describe("quick chat idle dot", () => {
|
||||
test("marks a closed quick chat after a turn completes and re-arms after opening", async ({
|
||||
test.describe("quick chat activity indicators", () => {
|
||||
test("shows tab and sidebar running state, then clears a finished state when opened", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const ws = watchWs(testPage);
|
||||
const created = testPage.waitForResponse(
|
||||
@@ -12,32 +18,40 @@ test.describe("quick chat idle dot", () => {
|
||||
response.url().includes("/quick-chat") && response.request().method() === "POST",
|
||||
);
|
||||
const dialog = await openQuickChatWithAgent(testPage);
|
||||
const createdBody = (await created).json() as Promise<{ session_id: string }>;
|
||||
const { session_id: sessionId } = await createdBody;
|
||||
const { session_id: sessionId, task_id: taskId } = (await (await created).json()) as {
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
};
|
||||
const tab = dialog.getByTestId("quick-chat-tab");
|
||||
const shortcut = testPage.getByTestId("sidebar-quick-chat-shortcut");
|
||||
const indicator = shortcut.getByTestId("quick-chat-activity-indicator");
|
||||
|
||||
await expect(shortcut.getByTestId("quick-chat-unseen-dot")).toHaveCount(0);
|
||||
const firstCompletion = ws.waitForEvent("session.turn.completed", {
|
||||
await expect(tab).toHaveCount(1);
|
||||
await waitForSessionSettledBaseline(apiClient, taskId, sessionId);
|
||||
const completed = ws.waitForEvent("session.turn.completed", {
|
||||
where: (payload) => payload.session_id === sessionId,
|
||||
});
|
||||
const settled = waitForSessionSettled(ws, sessionId);
|
||||
await sendQuickChatMessage(dialog, testPage, "/slow 8s");
|
||||
await expect(tab.getByRole("status")).toBeVisible();
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
await firstCompletion;
|
||||
await expect(shortcut.getByTestId("quick-chat-unseen-dot")).toBeVisible();
|
||||
await expect(indicator).toHaveAttribute("data-state", "running");
|
||||
|
||||
await Promise.all([completed, settled]);
|
||||
await expect(indicator).toHaveAttribute("data-state", "finished");
|
||||
|
||||
await shortcut.click();
|
||||
await expect(shortcut.getByTestId("quick-chat-unseen-dot")).toHaveCount(0);
|
||||
const secondCompletion = ws.waitForEvent("session.turn.completed", {
|
||||
where: (payload) => payload.session_id === sessionId,
|
||||
});
|
||||
await sendQuickChatMessage(dialog, testPage, "/slow 8s");
|
||||
await expect(indicator).toHaveCount(0);
|
||||
await expect(testPage.getByRole("dialog", { name: "Quick Chat" })).toBeVisible();
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
await secondCompletion;
|
||||
await expect(shortcut.getByTestId("quick-chat-unseen-dot")).toBeVisible();
|
||||
await expect(indicator).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("marks the tablet header after a closed quick chat turn completes", async ({
|
||||
test("uses the same running-to-finished state sequence in the tablet header", async ({
|
||||
tabletTestPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const ws = watchWs(tabletTestPage);
|
||||
const created = tabletTestPage.waitForResponse(
|
||||
@@ -45,17 +59,30 @@ test.describe("quick chat idle dot", () => {
|
||||
response.url().includes("/quick-chat") && response.request().method() === "POST",
|
||||
);
|
||||
const dialog = await openQuickChatWithAgent(tabletTestPage);
|
||||
const { session_id: sessionId } = (await (await created).json()) as { session_id: string };
|
||||
const { session_id: sessionId, task_id: taskId } = (await (await created).json()) as {
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
};
|
||||
const tab = dialog.getByTestId("quick-chat-tab");
|
||||
const button = tabletTestPage.getByTestId("tablet-quick-chat-button");
|
||||
const indicator = button.getByTestId("quick-chat-activity-indicator");
|
||||
|
||||
await expect(button.getByTestId("quick-chat-unseen-dot")).toHaveCount(0);
|
||||
await expect(tab).toHaveCount(1);
|
||||
await waitForSessionSettledBaseline(apiClient, taskId, sessionId);
|
||||
const completed = ws.waitForEvent("session.turn.completed", {
|
||||
where: (payload) => payload.session_id === sessionId,
|
||||
});
|
||||
const settled = waitForSessionSettled(ws, sessionId);
|
||||
await sendQuickChatMessage(dialog, tabletTestPage, "/slow 8s");
|
||||
await tabletTestPage.keyboard.press("Escape");
|
||||
await completed;
|
||||
await expect(tab.getByRole("status")).toBeVisible();
|
||||
|
||||
await expect(button.getByTestId("quick-chat-unseen-dot")).toBeVisible();
|
||||
await tabletTestPage.keyboard.press("Escape");
|
||||
await expect(indicator).toHaveAttribute("data-state", "running");
|
||||
|
||||
await Promise.all([completed, settled]);
|
||||
await expect(indicator).toHaveAttribute("data-state", "finished");
|
||||
|
||||
await button.click();
|
||||
await expect(indicator).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { useSession } from "@/hooks/domains/session/use-session";
|
||||
import { useTask } from "@/hooks/use-task";
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
import { isSessionWorking } from "@/lib/session-working";
|
||||
import { deriveSessionInputMode } from "./session-input-mode";
|
||||
|
||||
export function deriveSessionFlags(session: TaskSession | null | undefined) {
|
||||
@@ -27,7 +28,7 @@ export function deriveSessionFlags(session: TaskSession | null | undefined) {
|
||||
const supportsSteering = isRunning && !hasBackgroundWork && !!session?.supports_steering;
|
||||
// `isWorking` drives the spinner/affordance: any live turn (generating OR
|
||||
// background-idle) plus STARTING — it must stay up through (b).
|
||||
const isWorking = isStarting || isRunning || hasBackgroundWork;
|
||||
const isWorking = isSessionWorking(session);
|
||||
const isFailed = state === "FAILED" || state === "CANCELLED";
|
||||
const isCompleted = state === "COMPLETED";
|
||||
const needsRecovery = state === "WAITING_FOR_INPUT" && !!errorMessage;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
import { isSessionWorking } from "./session-working";
|
||||
|
||||
function session(
|
||||
state: TaskSession["state"],
|
||||
foreground_activity?: TaskSession["foreground_activity"],
|
||||
) {
|
||||
return { state, foreground_activity } as TaskSession;
|
||||
}
|
||||
|
||||
describe("isSessionWorking", () => {
|
||||
it.each([
|
||||
["STARTING", undefined, true],
|
||||
["RUNNING", undefined, true],
|
||||
["RUNNING", "generating", true],
|
||||
["RUNNING", "background", true],
|
||||
["WAITING_FOR_INPUT", "background", true],
|
||||
["IDLE", "background", true],
|
||||
["CREATED", undefined, false],
|
||||
["WAITING_FOR_INPUT", "generating", false],
|
||||
["WAITING_FOR_INPUT", undefined, false],
|
||||
["COMPLETED", undefined, false],
|
||||
["FAILED", undefined, false],
|
||||
["CANCELLED", undefined, false],
|
||||
] as const)("returns %s for %s with %s foreground activity", (state, activity, expected) => {
|
||||
expect(isSessionWorking(session(state, activity))).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns false for an absent session", () => {
|
||||
expect(isSessionWorking(null)).toBe(false);
|
||||
expect(isSessionWorking(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
|
||||
/** Returns whether a session still has live agent or background work. */
|
||||
export function isSessionWorking(
|
||||
session: Pick<TaskSession, "state" | "foreground_activity"> | null | undefined,
|
||||
): boolean {
|
||||
if (!session) return false;
|
||||
return (
|
||||
session.state === "STARTING" ||
|
||||
session.state === "RUNNING" ||
|
||||
session.foreground_activity === "background"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
import { createAppStore } from "@/lib/state/store";
|
||||
import { getQuickChatSetupSessionId } from "./quick-chat-session";
|
||||
import {
|
||||
selectQuickChatActivity,
|
||||
selectQuickChatSessionIsWorking,
|
||||
} from "./quick-chat-activity-selectors";
|
||||
|
||||
const WORKSPACE_A = "workspace-a";
|
||||
const WORKSPACE_B = "workspace-b";
|
||||
const SESSION_A = "session-a";
|
||||
const SESSION_B = "session-b";
|
||||
|
||||
function session(
|
||||
id: string,
|
||||
state: TaskSession["state"],
|
||||
foreground_activity?: TaskSession["foreground_activity"],
|
||||
): TaskSession {
|
||||
return {
|
||||
id,
|
||||
task_id: `task-${id}`,
|
||||
state,
|
||||
foreground_activity,
|
||||
} as TaskSession;
|
||||
}
|
||||
|
||||
function addSessions(
|
||||
store: ReturnType<typeof createAppStore>,
|
||||
sessions: Array<{ sessionId: string; workspaceId: string }>,
|
||||
) {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
quickChat: {
|
||||
...state.quickChat,
|
||||
sessions: sessions.map(({ sessionId, workspaceId }) => ({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
kind: "chat" as const,
|
||||
taskId: `task-${sessionId}`,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe("selectQuickChatSessionIsWorking", () => {
|
||||
it("includes session activity and environment preparation", () => {
|
||||
const store = createAppStore();
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
taskSessions: {
|
||||
items: {
|
||||
[SESSION_A]: session(SESSION_A, "WAITING_FOR_INPUT", "background"),
|
||||
[SESSION_B]: session(SESSION_B, "IDLE"),
|
||||
},
|
||||
},
|
||||
prepareProgress: {
|
||||
bySessionId: {
|
||||
[SESSION_B]: { sessionId: SESSION_B, status: "preparing", steps: [] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(selectQuickChatSessionIsWorking(store.getState(), SESSION_A)).toBe(true);
|
||||
expect(selectQuickChatSessionIsWorking(store.getState(), SESSION_B)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false after a session settles without preparation", () => {
|
||||
const store = createAppStore();
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
taskSessions: { items: { [SESSION_A]: session(SESSION_A, "WAITING_FOR_INPUT") } },
|
||||
}));
|
||||
|
||||
expect(selectQuickChatSessionIsWorking(store.getState(), SESSION_A)).toBe(false);
|
||||
expect(selectQuickChatSessionIsWorking(store.getState(), "missing-session")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectQuickChatActivity", () => {
|
||||
it("returns null when the workspace is absent or the dialog is open", () => {
|
||||
const store = createAppStore();
|
||||
addSessions(store, [{ sessionId: SESSION_A, workspaceId: WORKSPACE_A }]);
|
||||
|
||||
expect(selectQuickChatActivity(store.getState(), null)).toBeNull();
|
||||
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
quickChat: { ...state.quickChat, isOpen: true },
|
||||
}));
|
||||
expect(selectQuickChatActivity(store.getState(), WORKSPACE_A)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns running before finished when any workspace conversation is working", () => {
|
||||
const store = createAppStore();
|
||||
addSessions(store, [
|
||||
{ sessionId: SESSION_A, workspaceId: WORKSPACE_A },
|
||||
{ sessionId: SESSION_B, workspaceId: WORKSPACE_A },
|
||||
]);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
taskSessions: {
|
||||
items: {
|
||||
[SESSION_A]: session(SESSION_A, "WAITING_FOR_INPUT"),
|
||||
[SESSION_B]: session(SESSION_B, "RUNNING"),
|
||||
},
|
||||
},
|
||||
quickChat: {
|
||||
...state.quickChat,
|
||||
unseenIdleByWorkspace: { [WORKSPACE_A]: { [SESSION_A]: true } },
|
||||
},
|
||||
}));
|
||||
|
||||
expect(selectQuickChatActivity(store.getState(), WORKSPACE_A)).toBe("running");
|
||||
});
|
||||
|
||||
it("returns finished only for unseen settled conversations in the requested workspace", () => {
|
||||
const store = createAppStore();
|
||||
addSessions(store, [
|
||||
{ sessionId: SESSION_A, workspaceId: WORKSPACE_A },
|
||||
{ sessionId: SESSION_B, workspaceId: WORKSPACE_B },
|
||||
{ sessionId: getQuickChatSetupSessionId(WORKSPACE_A, "chat"), workspaceId: WORKSPACE_A },
|
||||
]);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
taskSessions: {
|
||||
items: {
|
||||
[SESSION_A]: session(SESSION_A, "COMPLETED"),
|
||||
[SESSION_B]: session(SESSION_B, "COMPLETED"),
|
||||
},
|
||||
},
|
||||
quickChat: {
|
||||
...state.quickChat,
|
||||
unseenIdleByWorkspace: {
|
||||
[WORKSPACE_A]: { [SESSION_A]: true },
|
||||
[WORKSPACE_B]: { [SESSION_B]: true },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(selectQuickChatActivity(store.getState(), WORKSPACE_A)).toBe("finished");
|
||||
expect(selectQuickChatActivity(store.getState(), WORKSPACE_B)).toBe("finished");
|
||||
expect(selectQuickChatActivity(store.getState(), "workspace-without-markers")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show a finished state after all markers are seen", () => {
|
||||
const store = createAppStore();
|
||||
addSessions(store, [{ sessionId: SESSION_A, workspaceId: WORKSPACE_A }]);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
taskSessions: { items: { [SESSION_A]: session(SESSION_A, "IDLE") } },
|
||||
}));
|
||||
|
||||
expect(selectQuickChatActivity(store.getState(), WORKSPACE_A)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import { isSessionWorking } from "@/lib/session-working";
|
||||
import { isQuickChatSetupSessionId } from "./quick-chat-session";
|
||||
import { selectQuickChatHasUnseenIdle } from "./quick-chat-unseen-selectors";
|
||||
|
||||
export type QuickChatActivityState = "running" | "finished" | null;
|
||||
|
||||
/** Returns whether one session has live agent, background, or prepare work. */
|
||||
export function selectQuickChatSessionIsWorking(state: AppState, sessionId: string): boolean {
|
||||
const session = state.taskSessions?.items?.[sessionId];
|
||||
return (
|
||||
isSessionWorking(session) ||
|
||||
state.prepareProgress?.bySessionId?.[sessionId]?.status === "preparing"
|
||||
);
|
||||
}
|
||||
|
||||
/** Derives the activity bubble for one workspace's closed Quick Chat entry. */
|
||||
export function selectQuickChatActivity(
|
||||
state: AppState,
|
||||
workspaceId: string | null | undefined,
|
||||
): QuickChatActivityState {
|
||||
if (!workspaceId || state.quickChat?.isOpen) return null;
|
||||
|
||||
const hasWorkingConversation = (state.quickChat?.sessions ?? []).some(
|
||||
(session) =>
|
||||
session.workspaceId === workspaceId &&
|
||||
!isQuickChatSetupSessionId(session.sessionId) &&
|
||||
selectQuickChatSessionIsWorking(state, session.sessionId),
|
||||
);
|
||||
if (hasWorkingConversation) return "running";
|
||||
return selectQuickChatHasUnseenIdle(state, workspaceId) ? "finished" : null;
|
||||
}
|
||||
@@ -74,6 +74,7 @@
|
||||
"wipQueuePosition": "Position {{position}} of {{total}} in {{step}} queue",
|
||||
"quickChat": "Quick Chat",
|
||||
"quickChatUnseen": "Quick Chat, new response",
|
||||
"quickChatRunning": "Quick Chat, agent working",
|
||||
"quickTerminal": "Quick terminal",
|
||||
"quickChatTerminals": "Terminals",
|
||||
"quickChatNewAgent": "New Agent",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"wipQueuePosition": "Ƥōśĩţĩōń {{position}} ōƒ {{total}} ĩń {{step}} qũēũē",
|
||||
"quickChat": "Qũĩćķ Ćĥàţ",
|
||||
"quickChatUnseen": "Qũĩćķ Ćĥàţ, ńēŵ ŕēśƥōńśē",
|
||||
"quickChatRunning": "Qũĩćķ Ćĥàţ, àĝēńţ ŵōŕķĩńĝ",
|
||||
"quickTerminal": "Qũĩćķ ţēŕḿĩńàĺ",
|
||||
"quickChatTerminals": "Ţēŕḿĩńàĺś",
|
||||
"quickChatNewAgent": "Ńēŵ Àĝēńţ",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"wipQueuePosition": "Posição {{position}} de {{total}} na fila de {{step}}",
|
||||
"quickChat": "Chat rápido",
|
||||
"quickChatUnseen": "Chat rápido, nova resposta",
|
||||
"quickChatRunning": "Chat rápido, agente a trabalhar",
|
||||
"quickTerminal": "Terminal rápido",
|
||||
"quickChatTerminals": "Terminais",
|
||||
"quickChatNewAgent": "Novo agente",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"wipQueuePosition": "{{step}} 队列中的第 {{position}} 项,共 {{total}} 项",
|
||||
"quickChat": "快速聊天",
|
||||
"quickChatUnseen": "快速聊天,有新回复",
|
||||
"quickChatRunning": "快速聊天,智能体正在工作",
|
||||
"quickTerminal": "快速终端",
|
||||
"quickChatTerminals": "终端",
|
||||
"quickChatNewAgent": "新建智能体",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
|
||||
"quickChat": "快速聊天",
|
||||
"quickChatUnseen": "快速聊天,有新回覆",
|
||||
"quickChatRunning": "快速聊天,代理程式正在工作",
|
||||
"quickTerminal": "快速終端",
|
||||
"quickChatTerminals": "終端",
|
||||
"quickChatNewAgent": "新建代理程式",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
|
||||
"quickChat": "快速聊天",
|
||||
"quickChatUnseen": "快速聊天,有新回覆",
|
||||
"quickChatRunning": "快速聊天,代理程式正在工作",
|
||||
"quickTerminal": "快速終端",
|
||||
"quickChatTerminals": "終端",
|
||||
"quickChatNewAgent": "新建代理程式",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
spec: docs/specs/quick-chat-idle-dot/spec.md
|
||||
created: 2026-08-23
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Quick Chat Activity Indicators
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing completion-only Quick Chat dot into a derived running or finished activity state. First add pure selectors, then update tabs and entry icons, and finally cover the real lifecycle in Playwright.
|
||||
|
||||
No backend contract changes are necessary. The frontend already receives session state, foreground activity, preparation progress, and completion events.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Activity selectors
|
||||
|
||||
- Add `apps/web/lib/state/slices/ui/quick-chat-activity-selectors.ts`.
|
||||
- Export `QuickChatActivityState` as `"running" | "finished" | null`.
|
||||
- Extract the shared live-work predicate to `apps/web/lib/session-working.ts`. Use it from `deriveSessionFlags()` and the Quick Chat selector.
|
||||
- Add `selectQuickChatSessionIsWorking(state, sessionId)`. Include `prepareProgress.bySessionId[sessionId]?.status === "preparing"`.
|
||||
- Add `selectQuickChatActivity(state, workspaceId)`. Return `null` while the dialog is open or the workspace is absent.
|
||||
- For a closed dialog, return `"running"` when any workspace conversation is working. Otherwise, return `"finished"` when the workspace has an unseen marker.
|
||||
- Keep `quick-chat-unseen-selectors.ts` for the existing marker-level tests and lifecycle helpers.
|
||||
|
||||
### Tab status
|
||||
|
||||
- Update `apps/web/components/quick-chat/quick-chat-modal.tsx` with a conversation-tab wrapper that reads `selectQuickChatSessionIsWorking` for one session.
|
||||
- Add an `isWorking` prop to `QuickChatTabItem` in `quick-chat-tab-item.tsx`.
|
||||
- Render `GridSpinner` before the tab name while `isWorking` is true.
|
||||
- Do not add a spinner to setup tabs or `QuickTerminalTabItem`.
|
||||
|
||||
### Entry activity bubble
|
||||
|
||||
- Add `apps/web/components/quick-chat/quick-chat-activity-indicator.tsx` for the shared bubble.
|
||||
- Use `bg-blue-500` for `running` and `bg-emerald-500` for `finished`.
|
||||
- Add `data-testid="quick-chat-activity-indicator"`, `data-state`, and `data-legacy-testid="quick-chat-unseen-dot"` during the selector migration.
|
||||
- Add `apps/web/components/quick-chat/use-quick-chat-activity.ts` to select the aggregate state and resolve the accessible button label.
|
||||
- Use the shared hook and indicator in these entry points:
|
||||
- `apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx`
|
||||
- `apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx`
|
||||
- `apps/web/components/kanban/kanban-header.tsx`
|
||||
- `apps/web/components/kanban/kanban-header-mobile.tsx`
|
||||
- `apps/web/components/task/mobile/quick-chat-sheet-button.tsx`
|
||||
- Replace the boolean `dot` props in `AppSidebarNavItem` and `RowActionButton` with the typed activity state.
|
||||
- Add `sidebar:quickChatRunning` in English, Portuguese, Simplified Chinese, Traditional Chinese, and the generated pseudo-locale. Keep `sidebar:quickChatUnseen` for the finished label.
|
||||
|
||||
### Mobile parity
|
||||
|
||||
This change modifies status content inside existing controls. It does not change composition, navigation, scrolling, or touch behavior.
|
||||
|
||||
The nearest mobile exemplars are the current mobile Quick Chat idle dot and the status dot in `session-mobile-bottom-nav.tsx`. Keep the existing mobile header and task-switcher entry points, touch targets, and dismissal behavior.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** The shared working predicate includes `STARTING`, `RUNNING`, and background work.
|
||||
**File:** `apps/web/lib/session-working.test.ts`.
|
||||
**How:** Use a table-driven unit test for every session state and foreground activity value.
|
||||
- **What:** Quick Chat working detection also includes environment preparation.
|
||||
**File:** `apps/web/lib/state/slices/ui/quick-chat-activity-selectors.test.ts`.
|
||||
**How:** Use selector tests with two workspaces and two sessions.
|
||||
- **What:** Running has priority over finished, the dialog suppresses the bubble, and seen results remain clear after close.
|
||||
**File:** `apps/web/lib/state/slices/ui/quick-chat-activity-selectors.test.ts`.
|
||||
**How:** Use pure aggregate-state tests.
|
||||
- **What:** A conversation tab shows the grid spinner only while its session works.
|
||||
**Files:** `apps/web/components/quick-chat/quick-chat-tab-item.test.tsx` and `quick-chat-modal.test.tsx`.
|
||||
**How:** Test the tab prop and the modal mapping for server-backed, setup, and terminal tabs.
|
||||
- **What:** Blue and emerald map to the correct semantic state.
|
||||
**File:** `apps/web/components/quick-chat/quick-chat-activity-indicator.test.tsx`.
|
||||
**How:** Use rendered class and `data-state` assertions.
|
||||
- **What:** Every desktop, tablet, and mobile Quick Chat entry uses the aggregate state and accessible label.
|
||||
**Files:** Existing sidebar, kanban-header, and mobile task-switcher component tests.
|
||||
**How:** Use store-backed rendered tests for no activity, running, and finished.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** A real quick-chat turn starts, the tab shows a grid spinner, the dialog closes, and the entry shows the blue state.
|
||||
**File:** `apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts`.
|
||||
**What to verify:** Tab spinner visibility and the desktop sidebar `data-state="running"` value.
|
||||
- **Scenario:** The last agent settles while the dialog is closed.
|
||||
**File:** `apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts`.
|
||||
**What to verify:** The same bubble changes to `data-state="finished"`, then disappears after the dialog opens.
|
||||
- **Scenario:** The same running-to-finished lifecycle occurs at tablet width.
|
||||
**File:** `apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts`.
|
||||
**What to verify:** The tablet header uses the same state sequence.
|
||||
- **Scenario:** The mobile header and task-switcher entry expose the same activity state.
|
||||
**File:** `apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts`.
|
||||
**What to verify:** Touch entry, close behavior, blue running state, emerald finished state, and clear-on-open behavior.
|
||||
|
||||
The mobile Playwright run is also the focused rendered mobile check. It keeps the existing full-height dialog and touch paths unchanged.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- Focused frontend tests passed: 10 files, 107 tests.
|
||||
- `pnpm --filter @kandev/web lint` passed with zero warnings.
|
||||
- `pnpm --filter @kandev/web run typecheck` passed.
|
||||
- `pnpm --filter @kandev/web run i18n:check` passed; all supported catalogs are complete and pseudo is in sync.
|
||||
- `pnpm --filter @kandev/web run i18n:ratchet` passed with zero new violations.
|
||||
- `pnpm --filter @kandev/web run lint:e2e-sleeps` passed.
|
||||
- Desktop and tablet Playwright coverage passed: 2 tests.
|
||||
- Mobile Playwright coverage passed: 2 tests.
|
||||
- The full `i18n:zh-hant` generator remains blocked by pre-existing residual simplified-Chinese keys in `agents:dynamicProfileSettings`; the targeted `sidebar` conversion completed successfully for this change.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
The tasks run sequentially because the UI and E2E work depend on the activity selectors.
|
||||
|
||||
- [x] [task-01-activity-selectors](task-01-activity-selectors.md)
|
||||
- [x] [task-02-activity-ui](task-02-activity-ui.md)
|
||||
- [x] [task-03-activity-e2e](task-03-activity-e2e.md)
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The plan uses blue for running and emerald for finished to match existing Kandev status-dot semantics.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: "01-activity-selectors"
|
||||
title: "Quick Chat activity selectors"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/quick-chat-idle-dot/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Quick Chat Activity Selectors
|
||||
|
||||
- **Acceptance:**
|
||||
1. One selector reports whether a Quick Chat session has live work, including background work and environment preparation.
|
||||
2. One aggregate selector returns `running`, `finished`, or `null` for one workspace.
|
||||
3. Running has priority over finished, and an open dialog returns `null`.
|
||||
|
||||
- **Verification:**
|
||||
```sh
|
||||
cd apps && pnpm install --frozen-lockfile \
|
||||
&& pnpm --filter @kandev/web test -- --run \
|
||||
lib/session-working.test.ts \
|
||||
hooks/domains/session/use-session-state.test.ts \
|
||||
lib/state/slices/ui/quick-chat-activity-selectors.test.ts
|
||||
```
|
||||
|
||||
- **Files likely touched:**
|
||||
- `apps/web/lib/session-working.ts`
|
||||
- `apps/web/lib/session-working.test.ts`
|
||||
- `apps/web/hooks/domains/session/use-session-state.ts`
|
||||
- `apps/web/lib/state/slices/ui/quick-chat-activity-selectors.ts`
|
||||
- `apps/web/lib/state/slices/ui/quick-chat-activity-selectors.test.ts`
|
||||
- `apps/web/lib/state/slices/ui/quick-chat-unseen-selectors.ts`
|
||||
|
||||
- **Dependencies:** None.
|
||||
- **Parallelism:** sequential.
|
||||
- **Inputs:** Spec `What`, `State machine`, and aggregate scenarios. Preserve the `deriveSessionFlags()` behavior in `use-session-state.ts`.
|
||||
- **Risks:** A second definition of working state can drift from the chat panel. Reuse the shared derivation and add background-work coverage.
|
||||
- **Output contract:** Report changed files, exact command results, blockers, risks, and synchronized task and plan status.
|
||||
|
||||
## Results
|
||||
|
||||
- Added the shared `isSessionWorking` predicate and reused it in `deriveSessionFlags`.
|
||||
- Added pure selectors for per-session work and workspace-level Quick Chat activity.
|
||||
- Covered starting, running, background, preparation, settled, workspace, setup-tab, and open-dialog behavior.
|
||||
- The focused selector/session tests passed as part of the final frontend suite: 10 files, 107 tests.
|
||||
- No blockers remain for this task.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
id: "02-activity-ui"
|
||||
title: "Quick Chat activity UI"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-activity-selectors"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/quick-chat-idle-dot/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Quick Chat Activity UI
|
||||
|
||||
- **Acceptance:**
|
||||
1. Each working conversation tab shows `GridSpinner`. Settled, setup, and terminal tabs do not.
|
||||
2. All five Quick Chat entry points show a blue running bubble or emerald finished bubble from the aggregate selector.
|
||||
3. Entry labels describe the state in every supported locale, and opening the dialog clears the finished state.
|
||||
|
||||
- **Verification:**
|
||||
```sh
|
||||
cd apps && pnpm install --frozen-lockfile \
|
||||
&& pnpm --filter @kandev/web run i18n:pseudo \
|
||||
&& pnpm --filter @kandev/web run i18n:zh-hant \
|
||||
&& pnpm --filter @kandev/web test -- --run \
|
||||
components/quick-chat/quick-chat-tab-item.test.tsx \
|
||||
components/quick-chat/quick-chat-modal.test.tsx \
|
||||
components/quick-chat/quick-chat-activity-indicator.test.tsx \
|
||||
components/app-sidebar/app-sidebar-primary-nav.test.tsx \
|
||||
components/app-sidebar/app-sidebar-new-task-item.test.tsx \
|
||||
components/kanban/kanban-header-mobile.test.tsx \
|
||||
components/task/mobile/session-task-switcher-sheet.test.tsx \
|
||||
&& pnpm --filter @kandev/web run i18n:check \
|
||||
&& pnpm --filter @kandev/web run typecheck
|
||||
```
|
||||
|
||||
- **Files likely touched:**
|
||||
- `apps/web/components/quick-chat/quick-chat-activity-indicator.tsx`
|
||||
- `apps/web/components/quick-chat/quick-chat-activity-indicator.test.tsx`
|
||||
- `apps/web/components/quick-chat/use-quick-chat-activity.ts`
|
||||
- `apps/web/components/quick-chat/quick-chat-tab-item.tsx`
|
||||
- `apps/web/components/quick-chat/quick-chat-tab-item.test.tsx`
|
||||
- `apps/web/components/quick-chat/quick-chat-modal.tsx`
|
||||
- `apps/web/components/quick-chat/quick-chat-modal.test.tsx`
|
||||
- `apps/web/components/app-sidebar/app-sidebar-nav-item.tsx`
|
||||
- `apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx`
|
||||
- `apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx`
|
||||
- `apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx`
|
||||
- `apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx`
|
||||
- `apps/web/components/kanban/kanban-header.tsx`
|
||||
- `apps/web/components/kanban/kanban-header-mobile.tsx`
|
||||
- `apps/web/components/kanban/kanban-header-mobile.test.tsx`
|
||||
- `apps/web/components/task/mobile/quick-chat-sheet-button.tsx`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx`
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,pseudo}/sidebar.json`
|
||||
|
||||
- **Dependencies:** Task 01.
|
||||
- **Parallelism:** sequential.
|
||||
- **Inputs:** Spec `What`, plus plan sections `Tab status`, `Entry activity bubble`, and `Mobile parity`.
|
||||
- **Risks:** Repeated indicator markup can drift across breakpoints. Use one shared indicator component and keep the existing touch surfaces.
|
||||
- **Output contract:** Report changed files, exact command results, blockers, risks, locale-generation results, and synchronized task and plan status.
|
||||
|
||||
## Results
|
||||
|
||||
- Added the shared activity indicator and hook for all desktop, tablet, and mobile Quick Chat entries.
|
||||
- Added the conversation-tab spinner while preserving setup and terminal tab behavior.
|
||||
- Added running/finished state coverage for the shared indicator, tab mapping, sidebar, and mobile entry points.
|
||||
- `pnpm --filter @kandev/web lint` passed with zero warnings.
|
||||
- `pnpm --filter @kandev/web run typecheck` passed.
|
||||
- `pnpm --filter @kandev/web run i18n:check` and `pnpm --filter @kandev/web run i18n:ratchet` passed.
|
||||
- The full Traditional Chinese generator was blocked by the pre-existing `agents:dynamicProfileSettings` residual keys. The targeted `sidebar` conversion generated the required `zh-hk` and `zh-tw` key successfully.
|
||||
- No implementation blockers remain for this task.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: "03-activity-e2e"
|
||||
title: "Quick Chat activity E2E"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-activity-ui"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/quick-chat-idle-dot/spec.md"
|
||||
---
|
||||
|
||||
# Task 03: Quick Chat Activity E2E
|
||||
|
||||
- **Acceptance:**
|
||||
1. Desktop and tablet tests prove tab spinner, blue running bubble, emerald finished bubble, and clear-on-open behavior.
|
||||
2. Mobile header and task-switcher tests prove the same state sequence through touch controls.
|
||||
3. WebSocket waits are armed before each message, and all indicator locators are scoped to their entry point.
|
||||
|
||||
- **Verification:**
|
||||
```sh
|
||||
cd apps && pnpm install --frozen-lockfile \
|
||||
&& cd web \
|
||||
&& pnpm e2e:run tests/chat/quick-chat-idle-dot.spec.ts \
|
||||
&& pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-idle-dot.spec.ts
|
||||
```
|
||||
|
||||
- **Files likely touched:**
|
||||
- `apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/quick-chat-helpers.ts` only if a shared activity locator removes duplication
|
||||
|
||||
- **Dependencies:** Task 02.
|
||||
- **Parallelism:** sequential.
|
||||
- **Inputs:** Spec scenarios, plan `E2E Tests`, and the existing `watchWs`, `openQuickChatWithAgent`, and `sendQuickChatMessage` patterns.
|
||||
- **Risks:** The completion and session-state events use separate streams. Assert the running state before completion, then wait for the semantic completion before the finished assertion.
|
||||
- **Output contract:** Report discovered test counts, exact command results, rendered mobile evidence, blockers, risks, cleanup, and synchronized task and plan status.
|
||||
|
||||
## Results
|
||||
|
||||
- Desktop/tablet command passed: 2 tests covering tab spinner, running state, finished state, and clear-on-open behavior.
|
||||
- Mobile command passed: 2 tests covering the mobile header and task-switcher lifecycle through touch controls.
|
||||
- `pnpm --filter @kandev/web run lint:e2e-sleeps` passed.
|
||||
- WebSocket waits are armed before sends and all activity locators are scoped to their entry point.
|
||||
- No test cleanup or runtime blockers remain for this task.
|
||||
+1
-1
@@ -264,7 +264,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
|
||||
| [cli-mode-parity](cli-mode-parity/spec.md) | draft |
|
||||
| [claude-fork-review-allowlist](claude-fork-review-allowlist/spec.md) | building |
|
||||
| [mobile-quick-chat-topbar](mobile-quick-chat-topbar/spec.md) | building |
|
||||
| [quick-chat-idle-dot](quick-chat-idle-dot/spec.md) | draft |
|
||||
| [quick-chat-activity-indicators](quick-chat-idle-dot/spec.md) | draft |
|
||||
| [native-code-review](native-code-review/spec.md) | building |
|
||||
| [missing-task-route-recovery](missing-task-route-recovery/spec.md) | draft |
|
||||
| [kanban-task-executor-cache-staleness](kanban-task-executor-cache-staleness/spec.md) | draft |
|
||||
|
||||
@@ -1,123 +1,80 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-08-16
|
||||
updated: 2026-08-23
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Quick Chat Idle Dot
|
||||
# Quick Chat Activity Indicators
|
||||
|
||||
## Why
|
||||
|
||||
Quick chat sessions keep running after the user closes the dialog. A session's
|
||||
turn can finish while the chat window is out of view, and nothing tells the
|
||||
user the agent has answered. Replies are missed until the user happens to
|
||||
reopen the dialog.
|
||||
Quick chat agents continue to work after the user closes the dialog. Users need to see which tab is active and whether background work is complete.
|
||||
|
||||
## What
|
||||
|
||||
- Each Quick Chat entry icon in the active workspace — the sidebar rail Quick
|
||||
Chat item, the sidebar Quick Chat shortcut, the tablet kanban header button,
|
||||
the mobile kanban header button, and the mobile task-switcher sheet button —
|
||||
shows a small red dot in the icon's corner while the Quick Chat dialog is
|
||||
closed and at least one quick chat session of that workspace has completed a
|
||||
turn (or settled to idle) since the dialog was last open.
|
||||
- A turn that completes while the dialog is open does NOT raise a dot; the
|
||||
user is looking at the chat window.
|
||||
- Opening the Quick Chat dialog clears every dot. Closing it re-arms tracking:
|
||||
a later turn completion while closed raises the dot again.
|
||||
- The dot is per-workspace: only sessions belonging to the active workspace
|
||||
contribute to that workspace's entry icons.
|
||||
- A session whose tab is closed or whose backing task is deleted stops
|
||||
contributing immediately.
|
||||
- The dot is decorative (`aria-hidden`). Button labels, tooltips, and the
|
||||
dialog itself are unchanged.
|
||||
- Each server-backed Quick Chat conversation tab shows the shared grid spinner while its agent has live work.
|
||||
- Live work includes session startup, a running session, reported background activity, and environment preparation.
|
||||
- Setup tabs and Quick Terminal tabs do not show the grid spinner.
|
||||
- The Quick Chat entry shows an activity bubble only while the Quick Chat dialog is closed.
|
||||
- A blue bubble means that at least one Quick Chat agent in the active workspace has live work.
|
||||
- An emerald bubble means that all Quick Chat agents in the active workspace have settled and at least one result is unseen.
|
||||
- The running state has priority when some agents are settled and at least one agent still has live work.
|
||||
- The bubble appears on the collapsed sidebar item, expanded sidebar shortcut, tablet header, mobile header, and mobile task-switcher entry.
|
||||
- Opening the Quick Chat dialog clears all unseen completion markers and hides the entry bubble.
|
||||
- If the user closes the dialog while work continues, the blue bubble appears again.
|
||||
- If the user closes the dialog after all results are seen, no bubble appears until new work starts or a new result arrives.
|
||||
- Completion markers are scoped to a workspace. A tab removal or backing-task deletion removes its marker.
|
||||
- Button labels describe the running or finished state. The colored bubble remains decorative.
|
||||
|
||||
## State machine
|
||||
|
||||
The unseen marker is a client-only, ephemeral flag keyed per (workspace,
|
||||
session id): `unseenIdleByWorkspace[workspaceId][sessionId]`. Workspace
|
||||
ownership lives in the key, so it survives hydration of a different
|
||||
workspace's session list.
|
||||
The entry indicator is derived from live session rows and the existing client-only completion markers.
|
||||
|
||||
| State | Transition | Trigger | Actor |
|
||||
|---|---|---|---|
|
||||
| unmarked | marked | `session.state_changed` settles an active session (STARTING/RUNNING → IDLE/WAITING_FOR_INPUT/COMPLETED/FAILED/CANCELLED) or `session.turn.completed` arrives, while the dialog is closed, for a session known to `quickChat.sessions` (workspace taken from that tab entry) | WS handler |
|
||||
| marked | unmarked | dialog opens (`openQuickChat`) — all workspaces | user |
|
||||
| marked | unmarked | session becomes the active dialog tab | user |
|
||||
| marked | unmarked | session tab closed or backing task deleted | user / sync |
|
||||
| marked | unmarked | revision-guarded server resync removes the session from its workspace's list | resync |
|
||||
| State | Condition while the dialog is closed | Transition |
|
||||
|---|---|---|
|
||||
| hidden | No agent has live work and no unseen completion exists | A new turn starts or an unseen completion arrives |
|
||||
| running | At least one Quick Chat agent has live work | The last working agent settles, or the dialog opens |
|
||||
| finished | No agent has live work and at least one unseen completion exists | A new turn starts, the dialog opens, or the marked tab is removed |
|
||||
|
||||
Opening the dialog clears `unseenIdleByWorkspace` for all workspaces. Live activity remains in the session store and is not copied into a second ledger.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **WS disconnected**: `session.state_changed` / `session.turn.completed` are
|
||||
live broadcasts; a turn that completes while the socket is down is never
|
||||
delivered and no dot is raised. There is no backfill. The dot is a
|
||||
best-effort cue, not a reliable delivery ledger.
|
||||
- **Race: event before tab**: a settle event for a session whose tab is not
|
||||
yet in `quickChat.sessions` (e.g. the `task.created` tab arrives after the
|
||||
session already settled) does not raise a dot. The user never saw that chat
|
||||
window, so no dot is the conservative outcome.
|
||||
- **Re-delivery**: a re-delivered `session.turn.completed` (reconnect replay,
|
||||
duplicate broadcast) for a turn already recorded as completed does not raise
|
||||
a dot — not after the tab arrives, and not after the dialog was opened and
|
||||
cleared the marker. Only a genuinely new turn completion raises the dot.
|
||||
For `session.state_changed` the same guarantee holds within the settle
|
||||
ledger's retention: the generation (`updated_at`) is kept per session for a
|
||||
60-minute window (max 500 sessions); a duplicate delivered after its
|
||||
generation was evicted (aged out or capped out) MAY raise a dot — accepted
|
||||
bounded-memory tradeoff, since practical replay windows are seconds; the dot
|
||||
self-heals on the next dialog open.
|
||||
- **Abandoned turns**: `AbandonOpenTurns` (session resume/startup cleanup,
|
||||
rejected-message cleanup) publishes `session.turn.completed` for orphan
|
||||
turns whose `completed_at` equals `started_at` — the backend already
|
||||
recognizes this shape (`isAbandonedTurnCompletion` in
|
||||
`apps/backend/internal/backendapp/gateway.go`), so a discriminator EXISTS in
|
||||
the payload. The marker treats these uniformly with live completions and
|
||||
deliberately does NOT filter on it: the session did settle to idle, and the
|
||||
`session.state_changed` settle transition raises the same signal, so
|
||||
excluding abandoned closures would not prevent the dot anyway. This is an
|
||||
intentional product decision, pinned by a test; filtering is out of scope.
|
||||
- **Receipt-time semantics**: the marker reflects events handled while the
|
||||
dialog is closed, not the instant the turn completed. `session.state_changed`
|
||||
and `session.turn.completed` travel over separate event-bus subscriptions
|
||||
and the gateway broadcasts both to every connected client (the payloads
|
||||
carry no `workspace_id`, so the workspace-broadcast path falls back to
|
||||
global delivery); per-workspace dots rely on session-tab membership and the
|
||||
selector, not transport scoping. Their relative order is not guaranteed; a
|
||||
completion notification handled while the dialog is open never marks, and
|
||||
one handled after the dialog closed marks, even if the turn itself finished
|
||||
earlier. The false-positive window is one notification round-trip and
|
||||
self-heals on the next dialog open.
|
||||
- If the WebSocket is disconnected, the live row can be stale until the existing resync updates it.
|
||||
- If a completion event is missed, no finished bubble appears for that result. The indicator remains a best-effort cue.
|
||||
- If a session row is not loaded, that session does not count as running. Its existing unseen marker can still produce the finished state.
|
||||
- A completion received before its Quick Chat tab is known does not create a marker.
|
||||
- Failed, canceled, and abandoned turns count as settled. This rule preserves the existing completion indicator behavior.
|
||||
- Duplicate settle events use the existing 60-minute, 500-session ledger. They do not restore a marker while their ledger entry remains.
|
||||
- A duplicate can restore a marker after its ledger entry expires or is removed by the entry limit.
|
||||
- Marker timing uses receipt time. An event handled while the dialog is open is seen, even if the turn settled before the dialog opened.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
The running state comes from live session data. Unseen completion markers remain browser-memory state and reset on page reload.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** no quick chat session in the active workspace, **WHEN** the app
|
||||
renders, **THEN** no Quick Chat entry icon shows a dot.
|
||||
- **GIVEN** a quick chat session with a running turn and the dialog closed,
|
||||
**WHEN** the turn completes (the session settles to an idle state), **THEN**
|
||||
a red dot appears on every Quick Chat entry icon of that workspace.
|
||||
- **GIVEN** a quick chat session with a running turn, **WHEN** the turn
|
||||
completes while the dialog is open, **THEN** no dot appears for that turn.
|
||||
- **GIVEN** a dot showing, **WHEN** the user opens the Quick Chat dialog,
|
||||
**THEN** the dot disappears.
|
||||
- **GIVEN** a dot cleared by opening the dialog, **WHEN** the same session
|
||||
later completes another turn while the dialog is closed, **THEN** the dot
|
||||
reappears.
|
||||
- **GIVEN** an unseen idle session in workspace A and none in workspace B,
|
||||
**WHEN** the user switches to workspace B, **THEN** workspace B's entry
|
||||
icons show no dot.
|
||||
- **GIVEN** a dot contributed by one session, **WHEN** that session's tab is
|
||||
closed or its backing task is deleted, **THEN** the dot disappears.
|
||||
- **GIVEN** a quick chat session tab that has never completed a turn,
|
||||
**WHEN** the page reloads, **THEN** no dot shows (markers are ephemeral and
|
||||
not persisted).
|
||||
- **GIVEN** a Quick Chat agent starts work, **WHEN** its conversation tab is visible, **THEN** that tab shows a grid spinner.
|
||||
- **GIVEN** a Quick Chat agent settles, **WHEN** its conversation tab is visible, **THEN** that tab does not show a grid spinner.
|
||||
- **GIVEN** the dialog is closed and one agent has live work, **WHEN** the Quick Chat entry renders, **THEN** it shows a blue running bubble.
|
||||
- **GIVEN** one agent has settled and another agent still works, **WHEN** the Quick Chat entry renders, **THEN** it keeps the blue running bubble.
|
||||
- **GIVEN** the dialog is closed and the last working agent settles, **WHEN** the completion arrives, **THEN** the bubble changes from blue to emerald.
|
||||
- **GIVEN** an emerald finished bubble, **WHEN** the user opens the dialog, **THEN** the bubble disappears and the results become seen.
|
||||
- **GIVEN** the user opens and closes the dialog after all work settles, **WHEN** no new work occurs, **THEN** no bubble appears.
|
||||
- **GIVEN** the user opens and closes the dialog while an agent still works, **WHEN** the dialog closes, **THEN** the blue running bubble appears again.
|
||||
- **GIVEN** the dialog is open, **WHEN** a turn completes, **THEN** no finished bubble is stored for that completion.
|
||||
- **GIVEN** activity belongs to workspace A, **WHEN** the user views workspace B, **THEN** workspace B does not show that activity.
|
||||
- **GIVEN** a marked session, **WHEN** its tab or backing task is removed, **THEN** that session stops contributing to the bubble.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No persistence: markers reset on page reload (scenario above).
|
||||
- No new copy, tooltips, sounds, or banner notifications.
|
||||
- No dot on the command-palette Quick Chat item or the config-chat floating
|
||||
panel.
|
||||
- No backend changes: the existing global broadcasts of
|
||||
`session.state_changed` and `session.turn.completed` are the only signals.
|
||||
- No change to quick chat session creation, tab sync, or dialog behavior.
|
||||
- No backend, database, or WebSocket contract changes.
|
||||
- No persisted notification history.
|
||||
- No sound, toast, operating-system notification, or command-palette indicator.
|
||||
- No activity bubble for Quick Terminal tabs.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [the current implementation plan](../../plans/quick-chat-activity-indicators/plan.md).
|
||||
|
||||
Reference in New Issue
Block a user