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": "等待输入",