feat: add quick chat activity indicators (#2952)

This commit is contained in:
Carlos Florêncio
2026-08-23 17:39:09 +00:00
committed by GitHub
parent 96c48d0517
commit a40fef5c94
36 changed files with 1027 additions and 234 deletions
@@ -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>
+4 -14
View File
@@ -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;
+34
View File
@@ -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);
});
});
+13
View File
@@ -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;
}
+1
View File
@@ -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",
+1
View File
@@ -74,6 +74,7 @@
"wipQueuePosition": "Ƥōśĩţĩōń {{position}} ōƒ {{total}} ĩń {{step}} qũēũē",
"quickChat": "Qũĩćķ Ćĥàţ",
"quickChatUnseen": "Qũĩćķ Ćĥàţ, ńēŵ ŕēśƥōńśē",
"quickChatRunning": "Qũĩćķ Ćĥàţ, àĝēńţ ŵōŕķĩńĝ",
"quickTerminal": "Qũĩćķ ţēŕḿĩńàĺ",
"quickChatTerminals": "Ţēŕḿĩńàĺś",
"quickChatNewAgent": "Ńēŵ Àĝēńţ",
+1
View File
@@ -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",
+1
View File
@@ -73,6 +73,7 @@
"wipQueuePosition": "{{step}} 队列中的第 {{position}} 项,共 {{total}} 项",
"quickChat": "快速聊天",
"quickChatUnseen": "快速聊天,有新回复",
"quickChatRunning": "快速聊天,智能体正在工作",
"quickTerminal": "快速终端",
"quickChatTerminals": "终端",
"quickChatNewAgent": "新建智能体",
+1
View File
@@ -73,6 +73,7 @@
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
"quickChat": "快速聊天",
"quickChatUnseen": "快速聊天,有新回覆",
"quickChatRunning": "快速聊天,代理程式正在工作",
"quickTerminal": "快速終端",
"quickChatTerminals": "終端",
"quickChatNewAgent": "新建代理程式",
+1
View File
@@ -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
View File
@@ -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 |
+55 -98
View File
@@ -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).