diff --git a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx
index 24b09c10d2..2247e7c5e1 100644
--- a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx
@@ -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 (
-
- );
-}
-
function sidebarBadgeClass(variant: NonNullable) {
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({
<>
- {renderUnseenDot(dot)}
+
{!collapsed && (
<>
diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx
index fbe3df3c90..f1ef993b73 100644
--- a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx
@@ -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>,
+ },
+ taskSessions: { items: {} as Record },
+ prepareProgress: { bySessionId: {} as Record },
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();
diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx
index c050d96906..7ee650a35c 100644
--- a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx
@@ -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({
>
- {dot && (
-
- )}
+
@@ -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}
/>
({
const state = {
workspaces: { activeId: "ws-1" as string | null },
office: { inboxCountByWorkspaceId: {} as Record },
- quickChat: { unseenIdleByWorkspace: {} as Record> },
+ quickChat: {
+ isOpen: false,
+ sessions: [] as Array<{
+ sessionId: string;
+ workspaceId: string;
+ kind: "chat";
+ taskId?: string;
+ }>,
+ unseenIdleByWorkspace: {} as Record>,
+ },
+ taskSessions: { items: {} as Record },
+ prepareProgress: { bySessionId: {} as Record },
};
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", () => {
diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx
index 63cd56ed4f..c0043128e1 100644
--- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx
@@ -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}
/>
)}
diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx
index 6faaa2bf24..412815da96 100644
--- a/apps/web/components/kanban/kanban-header-mobile.tsx
+++ b/apps/web/components/kanban/kanban-header-mobile.tsx
@@ -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 (
diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx
index b6a472c756..8884e09ddc 100644
--- a/apps/web/components/kanban/kanban-header.tsx
+++ b/apps/web/components/kanban/kanban-header.tsx
@@ -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): 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 }) {
>
- {quickChatHasUnseenIdle && (
-
- )}
+
>
diff --git a/apps/web/components/quick-chat/quick-chat-activity-indicator.test.tsx b/apps/web/components/quick-chat/quick-chat-activity-indicator.test.tsx
new file mode 100644
index 0000000000..e3614c03cf
--- /dev/null
+++ b/apps/web/components/quick-chat/quick-chat-activity-indicator.test.tsx
@@ -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();
+ 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();
+
+ expect(queryByTestId("quick-chat-activity-indicator")).toBeNull();
+ });
+});
diff --git a/apps/web/components/quick-chat/quick-chat-activity-indicator.tsx b/apps/web/components/quick-chat/quick-chat-activity-indicator.tsx
new file mode 100644
index 0000000000..5e88cbdf47
--- /dev/null
+++ b/apps/web/components/quick-chat/quick-chat-activity-indicator.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/components/quick-chat/quick-chat-modal.test.tsx b/apps/web/components/quick-chat/quick-chat-modal.test.tsx
index bbf53c9316..d1831c1e38 100644
--- a/apps/web/components/quick-chat/quick-chat-modal.test.tsx
+++ b/apps/web/components/quick-chat/quick-chat-modal.test.tsx
@@ -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 = {};
+const quickChatPrepareProgress: Record = {};
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();
+
+ 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", () => {
diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx
index 9d3bd8ac5f..851d5243a8 100644
--- a/apps/web/components/quick-chat/quick-chat-modal.tsx
+++ b/apps/web/components/quick-chat/quick-chat-modal.tsx
@@ -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 (
+
+ );
+}
+
function QuickChatTabs({
sessions,
terminalTabs,
@@ -78,12 +104,11 @@ function QuickChatTabs({
{sessions.map((s, index) => {
const tabName = quickChatTabName(t, s, index);
return (
- onTabChange(s.sessionId)}
onClose={() => onTabClose(s.sessionId)}
onRename={(name) => onRename(s.sessionId, name)}
diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx
index c4ca5b5719..fede05d269 100644
--- a/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx
+++ b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx
@@ -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();
+
+ expect(getByRole("status", { name: "Loading" })).toBeTruthy();
+ });
+
+ it("does not show a spinner for a settled conversation", () => {
+ const { queryByRole } = render();
+
+ expect(queryByRole("status", { name: "Loading" })).toBeNull();
+ });
+});
diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.tsx
index 5a4dfa2a34..26de8ae8c0 100644
--- a/apps/web/components/quick-chat/quick-chat-tab-item.tsx
+++ b/apps/web/components/quick-chat/quick-chat-tab-item.tsx
@@ -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 && }
{kind === "config" && (
diff --git a/apps/web/components/quick-chat/use-quick-chat-activity.ts b/apps/web/components/quick-chat/use-quick-chat-activity.ts
new file mode 100644
index 0000000000..86c105450a
--- /dev/null
+++ b/apps/web/components/quick-chat/use-quick-chat-activity.ts
@@ -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 };
+}
diff --git a/apps/web/components/task/mobile/quick-chat-sheet-button.tsx b/apps/web/components/task/mobile/quick-chat-sheet-button.tsx
index ec9b083962..6e345ab9e3 100644
--- a/apps/web/components/task/mobile/quick-chat-sheet-button.tsx
+++ b/apps/web/components/task/mobile/quick-chat-sheet-button.tsx
@@ -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 (
diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts
index 510c4e548f..649ce870ab 100644
--- a/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts
@@ -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",
+ );
});
});
diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts
index 3db89d5062..79e2fe7eaf 100644
--- a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts
@@ -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);
diff --git a/apps/web/e2e/tests/chat/quick-chat-helpers.ts b/apps/web/e2e/tests/chat/quick-chat-helpers.ts
index 65e4719099..b40aee1728 100644
--- a/apps/web/e2e/tests/chat/quick-chat-helpers.ts
+++ b/apps/web/e2e/tests/chat/quick-chat-helpers.ts
@@ -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 {
+ 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 {
if (navigateHome) {
await page.goto("/");
diff --git a/apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts b/apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts
index 93fab8daad..2cf51bf50f 100644
--- a/apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts
+++ b/apps/web/e2e/tests/chat/quick-chat-idle-dot.spec.ts
@@ -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);
});
});
diff --git a/apps/web/hooks/domains/session/use-session-state.ts b/apps/web/hooks/domains/session/use-session-state.ts
index 5fc88ab577..770fbf879c 100644
--- a/apps/web/hooks/domains/session/use-session-state.ts
+++ b/apps/web/hooks/domains/session/use-session-state.ts
@@ -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;
diff --git a/apps/web/lib/session-working.test.ts b/apps/web/lib/session-working.test.ts
new file mode 100644
index 0000000000..2766e48639
--- /dev/null
+++ b/apps/web/lib/session-working.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/lib/session-working.ts b/apps/web/lib/session-working.ts
new file mode 100644
index 0000000000..ab3345d04e
--- /dev/null
+++ b/apps/web/lib/session-working.ts
@@ -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 | null | undefined,
+): boolean {
+ if (!session) return false;
+ return (
+ session.state === "STARTING" ||
+ session.state === "RUNNING" ||
+ session.foreground_activity === "background"
+ );
+}
diff --git a/apps/web/lib/state/slices/ui/quick-chat-activity-selectors.test.ts b/apps/web/lib/state/slices/ui/quick-chat-activity-selectors.test.ts
new file mode 100644
index 0000000000..e6625783bc
--- /dev/null
+++ b/apps/web/lib/state/slices/ui/quick-chat-activity-selectors.test.ts
@@ -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,
+ 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();
+ });
+});
diff --git a/apps/web/lib/state/slices/ui/quick-chat-activity-selectors.ts b/apps/web/lib/state/slices/ui/quick-chat-activity-selectors.ts
new file mode 100644
index 0000000000..fad71d4d0e
--- /dev/null
+++ b/apps/web/lib/state/slices/ui/quick-chat-activity-selectors.ts
@@ -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;
+}
diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json
index a18f53c980..643a0c8626 100644
--- a/apps/web/src/locales/en/sidebar.json
+++ b/apps/web/src/locales/en/sidebar.json
@@ -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",
diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json
index 325c1fbc8c..8ceac651ac 100644
--- a/apps/web/src/locales/pseudo/sidebar.json
+++ b/apps/web/src/locales/pseudo/sidebar.json
@@ -74,6 +74,7 @@
"wipQueuePosition": "Ƥōśĩţĩōń {{position}} ōƒ {{total}} ĩń {{step}} qũēũē",
"quickChat": "Qũĩćķ Ćĥàţ",
"quickChatUnseen": "Qũĩćķ Ćĥàţ, ńēŵ ŕēśƥōńśē",
+ "quickChatRunning": "Qũĩćķ Ćĥàţ, àĝēńţ ŵōŕķĩńĝ",
"quickTerminal": "Qũĩćķ ţēŕḿĩńàĺ",
"quickChatTerminals": "Ţēŕḿĩńàĺś",
"quickChatNewAgent": "Ńēŵ Àĝēńţ",
diff --git a/apps/web/src/locales/pt-pt/sidebar.json b/apps/web/src/locales/pt-pt/sidebar.json
index f97807d4a2..2fecacdfd5 100644
--- a/apps/web/src/locales/pt-pt/sidebar.json
+++ b/apps/web/src/locales/pt-pt/sidebar.json
@@ -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",
diff --git a/apps/web/src/locales/zh-cn/sidebar.json b/apps/web/src/locales/zh-cn/sidebar.json
index 2e1e318c25..b247b2d819 100644
--- a/apps/web/src/locales/zh-cn/sidebar.json
+++ b/apps/web/src/locales/zh-cn/sidebar.json
@@ -73,6 +73,7 @@
"wipQueuePosition": "{{step}} 队列中的第 {{position}} 项,共 {{total}} 项",
"quickChat": "快速聊天",
"quickChatUnseen": "快速聊天,有新回复",
+ "quickChatRunning": "快速聊天,智能体正在工作",
"quickTerminal": "快速终端",
"quickChatTerminals": "终端",
"quickChatNewAgent": "新建智能体",
diff --git a/apps/web/src/locales/zh-hk/sidebar.json b/apps/web/src/locales/zh-hk/sidebar.json
index b5c8a06959..977ecb3136 100644
--- a/apps/web/src/locales/zh-hk/sidebar.json
+++ b/apps/web/src/locales/zh-hk/sidebar.json
@@ -73,6 +73,7 @@
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
"quickChat": "快速聊天",
"quickChatUnseen": "快速聊天,有新回覆",
+ "quickChatRunning": "快速聊天,代理程式正在工作",
"quickTerminal": "快速終端",
"quickChatTerminals": "終端",
"quickChatNewAgent": "新建代理程式",
diff --git a/apps/web/src/locales/zh-tw/sidebar.json b/apps/web/src/locales/zh-tw/sidebar.json
index 193507f48b..dd0e8f2800 100644
--- a/apps/web/src/locales/zh-tw/sidebar.json
+++ b/apps/web/src/locales/zh-tw/sidebar.json
@@ -73,6 +73,7 @@
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
"quickChat": "快速聊天",
"quickChatUnseen": "快速聊天,有新回覆",
+ "quickChatRunning": "快速聊天,代理程式正在工作",
"quickTerminal": "快速終端",
"quickChatTerminals": "終端",
"quickChatNewAgent": "新建代理程式",
diff --git a/docs/plans/quick-chat-activity-indicators/plan.md b/docs/plans/quick-chat-activity-indicators/plan.md
new file mode 100644
index 0000000000..722e83c490
--- /dev/null
+++ b/docs/plans/quick-chat-activity-indicators/plan.md
@@ -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.
diff --git a/docs/plans/quick-chat-activity-indicators/task-01-activity-selectors.md b/docs/plans/quick-chat-activity-indicators/task-01-activity-selectors.md
new file mode 100644
index 0000000000..b962d2c744
--- /dev/null
+++ b/docs/plans/quick-chat-activity-indicators/task-01-activity-selectors.md
@@ -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.
diff --git a/docs/plans/quick-chat-activity-indicators/task-02-activity-ui.md b/docs/plans/quick-chat-activity-indicators/task-02-activity-ui.md
new file mode 100644
index 0000000000..7883a4b6ce
--- /dev/null
+++ b/docs/plans/quick-chat-activity-indicators/task-02-activity-ui.md
@@ -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.
diff --git a/docs/plans/quick-chat-activity-indicators/task-03-activity-e2e.md b/docs/plans/quick-chat-activity-indicators/task-03-activity-e2e.md
new file mode 100644
index 0000000000..ed68c601e3
--- /dev/null
+++ b/docs/plans/quick-chat-activity-indicators/task-03-activity-e2e.md
@@ -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.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 7b90c633e8..41625a658e 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -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 |
diff --git a/docs/specs/quick-chat-idle-dot/spec.md b/docs/specs/quick-chat-idle-dot/spec.md
index 53a9ea8401..1376c47c7e 100644
--- a/docs/specs/quick-chat-idle-dot/spec.md
+++ b/docs/specs/quick-chat-idle-dot/spec.md
@@ -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).