From fc2496c3f0036b23204aafc01ff5d25c9efb2e75 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Thu, 29 Oct 2026 20:48:02 +0800
Subject: [PATCH] feat(agents): show unread task indicators
---
src/features/agents/Agents.module.css | 11 ++
src/features/agents/AgentsStatus.tsx | 12 +-
src/features/agents/AgentsView.tsx | 11 +-
src/features/agents/index.ts | 4 +-
src/features/agents/store.ts | 65 +++++++-
src/features/agents/unread.test.tsx | 224 ++++++++++++++++++++++++++
src/platform/locales/text/zh.json | 2 +
7 files changed, 317 insertions(+), 12 deletions(-)
create mode 100644 src/features/agents/unread.test.tsx
diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css
index b52ea530..39722d6f 100644
--- a/src/features/agents/Agents.module.css
+++ b/src/features/agents/Agents.module.css
@@ -39,6 +39,17 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+.title[data-unread] {
+ color: var(--fg-strong);
+ font-weight: 600;
+}
+.unreadDot {
+ flex: none;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--accent);
+}
.detail {
flex: 1 1 0;
min-width: 0;
diff --git a/src/features/agents/AgentsStatus.tsx b/src/features/agents/AgentsStatus.tsx
index 2b955f12..b0a759c9 100644
--- a/src/features/agents/AgentsStatus.tsx
+++ b/src/features/agents/AgentsStatus.tsx
@@ -5,11 +5,13 @@ import styles from "./Agents.module.css";
export function AgentsStatus() {
const tasks = useAgents((s) => s.tasks);
+ const unread = useAgents((s) => s.unread);
const list = [...tasks.values()];
+ const unseen = list.filter((t) => unread.has(t.id)).sort((a, b) => (b.updatedMs ?? 0) - (a.updatedMs ?? 0));
const input = list.filter((t) => t.status === "needsInput");
const running = list.filter((t) => t.status === "running" || t.status === "starting").length;
- if (!input.length && !running) return null;
- const target = input[0] ?? list.find((t) => t.status === "running");
+ if (!input.length && !running && !unseen.length) return null;
+ const target = input[0] ?? unseen[0] ?? list.find((t) => t.status === "running" || t.status === "starting");
return (
);
}
diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx
index 8d9c1338..2f899c16 100644
--- a/src/features/agents/AgentsView.tsx
+++ b/src/features/agents/AgentsView.tsx
@@ -8,7 +8,7 @@ import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu, type M
import { SESSION_KIND, age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model";
import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store";
import styles from "./Agents.module.css";
-import { tx } from "@/platform/i18n";
+import { tx, useTx } from "@/platform/i18n";
export function AgentsHeaderActions() {
return (
@@ -44,9 +44,11 @@ export function TaskStatusIcon({ status }: { status: Status }) {
}
export function AgentsView() {
+ const tx = useTx();
const loaded = useAgents((s) => s.loaded);
const error = useAgents((s) => s.error);
const tasks = useAgents((s) => s.tasks);
+ const unread = useAgents((s) => s.unread);
const providers = useAgents((s) => s.providers);
const [collapsed, setCollapsed] = useState>(() => new Set(["g:archived"]));
const [selected, setSelected] = useState>(() => {
@@ -115,7 +117,12 @@ export function AgentsView() {
) : (
- {n.task.title}
+
+ {n.task.title}
+
+ {unread.has(n.task.id) && (
+
+ )}
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts
index 180e19e6..2deed65d 100644
--- a/src/features/agents/index.ts
+++ b/src/features/agents/index.ts
@@ -54,7 +54,9 @@ export function activate(): () => void {
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
useBadge: () => {
- const n = useAgents((s) => [...s.tasks.values()].filter((t) => t.status === "needsInput").length);
+ const n = useAgents(
+ (s) => [...s.tasks.values()].filter((t) => t.status === "needsInput" || s.unread.has(t.id)).length,
+ );
return n || null;
},
}),
diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts
index 06c2e836..d284121f 100644
--- a/src/features/agents/store.ts
+++ b/src/features/agents/store.ts
@@ -44,6 +44,8 @@ interface AgentsState {
/** Why the tasks could not be loaded. */
error: string | null;
tasks: Map;
+ /** Tasks with unseen replies or status changes in this project. */
+ unread: Set;
/** Entries of the tasks (and subagents) opened in this window, by `entriesKey`. */
entries: Map;
changes: Map;
@@ -85,6 +87,7 @@ export const useAgents = create(() => ({
loaded: false,
error: null,
tasks: new Map(),
+ unread: new Set(),
entries: new Map(),
changes: new Map(),
providers: [],
@@ -110,11 +113,40 @@ export function updateComposer(key: string, patch: Partial) {
}));
}
-/** Session tabs on screen; their tasks do not notify. */
-const visible = new Set();
+/** Session tabs on screen; a task can be shown in more than one split. */
+const visible = new Map();
+const windowFocused = () => document.visibilityState !== "hidden" && document.hasFocus();
+const viewing = (task: string) => visible.has(task) && windowFocused();
+
+function setUnread(task: string, value: boolean) {
+ const s = useAgents.getState();
+ if (s.unread.has(task) === value) return;
+ const unread = new Set(s.unread);
+ if (value) unread.add(task);
+ else unread.delete(task);
+ useAgents.setState({ unread });
+}
+
+function markUnread(task: string) {
+ const current = useAgents.getState().tasks.get(task);
+ if (current && current.status !== "archived" && !viewing(task)) setUnread(task, true);
+}
+
export function markVisible(task: string): () => void {
- visible.add(task);
- return () => visible.delete(task);
+ visible.set(task, (visible.get(task) ?? 0) + 1);
+ const read = () => {
+ if (viewing(task)) setUnread(task, false);
+ };
+ read();
+ window.addEventListener("focus", read);
+ document.addEventListener("visibilitychange", read);
+ return () => {
+ const count = (visible.get(task) ?? 1) - 1;
+ if (count) visible.set(task, count);
+ else visible.delete(task);
+ window.removeEventListener("focus", read);
+ document.removeEventListener("visibilitychange", read);
+ };
}
let generation = 0;
@@ -154,7 +186,14 @@ export async function connect(): Promise {
export function disconnect() {
generation++;
- useAgents.setState({ loaded: false, error: null, tasks: new Map(), entries: new Map(), changes: new Map() });
+ useAgents.setState({
+ loaded: false,
+ error: null,
+ tasks: new Map(),
+ unread: new Set(),
+ entries: new Map(),
+ changes: new Map(),
+ });
}
function onEvent(e: AgentEvent) {
@@ -163,6 +202,8 @@ function onEvent(e: AgentEvent) {
case "task": {
const prev = s.tasks.get(e.task.id);
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
+ if (e.task.status === "archived") setUnread(e.task.id, false);
+ else if (attentionChange(prev?.status, e.task.status)) markUnread(e.task.id);
notifyChange(prev, e.task);
if (prev?.status !== e.task.status && !isBusy(e.task.status)) {
void refreshChanges(e.task.id);
@@ -175,7 +216,9 @@ function onEvent(e: AgentEvent) {
tasks.delete(e.id);
const composers = new Map(s.composers);
composers.delete(e.id);
- useAgents.setState({ tasks, composers });
+ const unread = new Set(s.unread);
+ unread.delete(e.id);
+ useAgents.setState({ tasks, composers, unread });
editorService().closeInput?.(sessionKey(e.id));
break;
}
@@ -186,6 +229,14 @@ function onEvent(e: AgentEvent) {
break;
}
case "entry": {
+ if (
+ !e.subagent &&
+ ((e.entry.type === "agent" && e.entry.text.trim()) ||
+ e.entry.type === "error" ||
+ (e.entry.type === "permission" && e.entry.chosen === null))
+ ) {
+ markUnread(e.task);
+ }
const key = entriesKey(e.task, e.subagent);
const current = s.entries.get(key);
if (!current) return;
@@ -203,7 +254,7 @@ function onEvent(e: AgentEvent) {
function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
const change = attentionChange(prev?.status, task.status);
if (!change) return;
- if (visible.has(task.id) && document.hasFocus()) return;
+ if (viewing(task.id)) return;
const body =
change === "input"
? "Waiting for your answer"
diff --git a/src/features/agents/unread.test.tsx b/src/features/agents/unread.test.tsx
new file mode 100644
index 00000000..3c78162e
--- /dev/null
+++ b/src/features/agents/unread.test.tsx
@@ -0,0 +1,224 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings";
+import { useContributions } from "@/workbench/contributions";
+import { AgentsView } from "./AgentsView";
+import { AgentsStatus } from "./AgentsStatus";
+import { activate } from "./index";
+import { connect, disconnect, markVisible, useAgents } from "./store";
+
+const backend = vi.hoisted(() => ({
+ channel: null as { onmessage: (event: AgentEvent) => void } | null,
+ tasks: [] as AgentTask[],
+ notify: vi.fn(),
+ openInput: vi.fn(),
+ closeInput: vi.fn(),
+}));
+
+vi.mock("@tauri-apps/api/core", async (original) => ({
+ ...(await original()),
+ Channel: class {
+ onmessage = () => {};
+ },
+}));
+vi.mock("@/platform/ipc", async (original) => ({
+ ...(await original()),
+ isTauri: () => true,
+ commands: {
+ agentSubscribe: async (channel: typeof backend.channel) => {
+ backend.channel = channel;
+ return { status: "ok", data: backend.tasks };
+ },
+ agentProviders: async () => ({ status: "ok", data: [] }),
+ agentChanges: async () => ({ status: "ok", data: [] }),
+ },
+}));
+vi.mock("@/workbench/contributions", async (original) => ({
+ ...(await original()),
+ editorService: () => ({ openInput: backend.openInput, closeInput: backend.closeInput }),
+ notifications: () => ({ notify: backend.notify }),
+}));
+
+const task = (id: string, status: AgentTask["status"] = "running"): AgentTask => ({
+ id,
+ title: id,
+ status,
+ provider: "codex",
+ isolation: "local",
+ model: null,
+ cwd: "/project",
+ branch: null,
+ base: null,
+ baseCommit: null,
+ error: null,
+ createdMs: 0,
+ updatedMs: 0,
+ config: [],
+ commands: [],
+ usage: null,
+ queue: [],
+});
+
+const emit = (event: AgentEvent) => act(() => backend.channel!.onmessage(event));
+const reply = (id = "one", text = "New reply") =>
+ emit({ type: "entry", task: id, subagent: null, index: 0, entry: { type: "agent", text } });
+const visible: Array<() => void> = [];
+const show = (id = "one") => {
+ let hide!: () => void;
+ act(() => {
+ hide = markVisible(id);
+ });
+ visible.push(hide);
+ return () => visible.splice(visible.indexOf(hide), 1)[0]!();
+};
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ vi.spyOn(document, "hasFocus").mockReturnValue(true);
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
+ disconnect();
+ backend.tasks = [task("one"), task("two", "ready")];
+ await connect();
+});
+afterEach(() => {
+ visible.splice(0).forEach((hide) => hide());
+ disconnect();
+ vi.restoreAllMocks();
+});
+
+describe("agent unread activity", () => {
+ it("does not mark historical tasks unread, but tracks replies even without loaded entries", () => {
+ expect(useAgents.getState().unread.size).toBe(0);
+ reply();
+ reply("one", "New reply, streamed further");
+ reply("two");
+ expect([...useAgents.getState().unread]).toEqual(["one", "two"]);
+ expect(useAgents.getState().entries.size).toBe(0);
+ expect(backend.notify).not.toHaveBeenCalled();
+ });
+
+ it.each(["ready", "needsInput", "failed", "limited"] as const)("marks background %s transitions unread", (status) => {
+ emit({ type: "task", task: task("one", status) });
+ expect(useAgents.getState().unread.has("one")).toBe(true);
+ expect(backend.notify).toHaveBeenCalledTimes(1);
+ show();
+ emit({ type: "task", task: task("one", status) });
+ expect(useAgents.getState().unread.size).toBe(0);
+ expect(backend.notify).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores user messages, thoughts, empty replies and subagent streaming", () => {
+ const entries: AgentEntry[] = [
+ { type: "user", text: "Do this", attachments: [] },
+ { type: "thought", text: "Thinking" },
+ { type: "agent", text: " " },
+ ];
+ for (const entry of entries) emit({ type: "entry", task: "one", subagent: null, index: 0, entry });
+ emit({ type: "entry", task: "one", subagent: "child", index: 0, entry: { type: "agent", text: "Working" } });
+ emit({ type: "task", task: { ...task("one"), title: "Renamed" } });
+ expect(useAgents.getState().unread.size).toBe(0);
+ });
+
+ it("clears only the viewed task and keeps reading while either split shows it", () => {
+ reply();
+ reply("two");
+ const hide = show();
+ show();
+ hide();
+ reply();
+ emit({ type: "task", task: task("one", "ready") });
+ expect([...useAgents.getState().unread]).toEqual(["two"]);
+ expect(backend.notify).not.toHaveBeenCalled();
+ });
+
+ it("keeps a visible session unread while the window is unfocused, then clears on focus", () => {
+ vi.mocked(document.hasFocus).mockReturnValue(false);
+ reply();
+ show();
+ expect(useAgents.getState().unread.has("one")).toBe(true);
+ vi.mocked(document.hasFocus).mockReturnValue(true);
+ fireEvent(window, new Event("focus"));
+ expect(useAgents.getState().unread.size).toBe(0);
+ reply();
+ expect(useAgents.getState().unread.size).toBe(0);
+ });
+
+ it("does not read hidden windows and removes focus listeners when the session closes", () => {
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
+ const hide = show();
+ reply();
+ fireEvent(window, new Event("focus"));
+ expect(useAgents.getState().unread.has("one")).toBe(true);
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
+ fireEvent(document, new Event("visibilitychange"));
+ expect(useAgents.getState().unread.size).toBe(0);
+ hide();
+ reply();
+ fireEvent(window, new Event("focus"));
+ expect(useAgents.getState().unread.has("one")).toBe(true);
+ });
+
+ it("cleans up archived and deleted tasks, ignores late replies, and resets on project switch", async () => {
+ reply();
+ reply("two");
+ emit({ type: "task", task: task("one", "archived") });
+ reply();
+ expect([...useAgents.getState().unread]).toEqual(["two"]);
+ emit({ type: "removed", id: "two" });
+ reply("two");
+ expect(useAgents.getState().unread.size).toBe(0);
+ emit({ type: "task", task: task("one") });
+ reply();
+ const oldChannel = backend.channel!;
+ disconnect();
+ backend.tasks = [task("other")];
+ await connect();
+ oldChannel.onmessage({ type: "task", task: task("one", "ready") });
+ expect(useAgents.getState().unread.size).toBe(0);
+ expect([...useAgents.getState().tasks.keys()]).toEqual(["other"]);
+ });
+});
+
+describe("agent unread indicators", () => {
+ it("shows a dot on the task and a clickable status count even when all tasks are idle", () => {
+ emit({ type: "task", task: task("one", "ready") });
+ render(
+ <>
+
+
+ >,
+ );
+ expect(screen.getByRole("img", { name: "Unread" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "1 unread" }));
+ expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ data: { task: "one", title: "one" } }), {
+ focus: true,
+ });
+ show();
+ expect(screen.queryByRole("img", { name: "Unread" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /unread/ })).not.toBeInTheDocument();
+ });
+
+ it("counts unread and needs-input tasks once each in the sidebar badge", () => {
+ const dispose = activate();
+ try {
+ const useBadge = useContributions.getState().toolWindows.get("agents")!.useBadge!;
+ function Badge() {
+ return {useBadge()};
+ }
+ act(() =>
+ useAgents.setState({
+ tasks: new Map([task("one", "needsInput"), task("two", "ready")].map((t) => [t.id, t])),
+ unread: new Set(["one", "two"]),
+ }),
+ );
+ render();
+ expect(screen.getByTestId("badge")).toHaveTextContent("2");
+ show("two");
+ expect(screen.getByTestId("badge")).toHaveTextContent("1");
+ show("one");
+ expect(screen.getByTestId("badge")).toHaveTextContent("1");
+ } finally {
+ act(dispose);
+ }
+ });
+});
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index fba57530..d9b6ffed 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -2152,6 +2152,7 @@
"Underscored": "下划线",
"Underwaved": "波浪线",
"Undo": "撤消",
+ "Unread": "未读",
"Undo Commit": "撤消提交",
"Undo Commit…": "撤消提交…",
"Undo Move / Rename": "撤消移动/重命名",
@@ -3244,6 +3245,7 @@
"Also remove its worktree folder": "同时删除它的 worktree 目录",
"needs input": "等待输入",
"running": "正在运行",
+ "unread": "未读",
"This agent task no longer exists": "这个 Agent 任务已不存在",
"Local checkout": "当前工作区",
"Needs input": "等待输入",