mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(agents): show unread task indicators
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<button type="button" className={styles.statusItem} onClick={() => target && openTask(target.id)}>
|
||||
{input.length > 0 && (
|
||||
@@ -24,6 +26,12 @@ export function AgentsStatus() {
|
||||
{running} <T>running</T>
|
||||
</span>
|
||||
)}
|
||||
{unseen.length > 0 && (
|
||||
<span className={styles.statusPart}>
|
||||
<span className={styles.unreadDot} aria-hidden="true" />
|
||||
{unseen.length} <T>unread</T>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(() => new Set(["g:archived"]));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
@@ -115,7 +117,12 @@ export function AgentsView() {
|
||||
) : (
|
||||
<span className={styles.row} title={n.task.error ?? n.task.title}>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<span className={styles.title}>{n.task.title}</span>
|
||||
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
|
||||
{n.task.title}
|
||||
</span>
|
||||
{unread.has(n.task.id) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -44,6 +44,8 @@ interface AgentsState {
|
||||
/** Why the tasks could not be loaded. */
|
||||
error: string | null;
|
||||
tasks: Map<string, AgentTask>;
|
||||
/** Tasks with unseen replies or status changes in this project. */
|
||||
unread: Set<string>;
|
||||
/** Entries of the tasks (and subagents) opened in this window, by `entriesKey`. */
|
||||
entries: Map<string, AgentEntry[]>;
|
||||
changes: Map<string, AgentChange[]>;
|
||||
@@ -85,6 +87,7 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
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<ComposerDraft>) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Session tabs on screen; their tasks do not notify. */
|
||||
const visible = new Set<string>();
|
||||
/** Session tabs on screen; a task can be shown in more than one split. */
|
||||
const visible = new Map<string, number>();
|
||||
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<void> {
|
||||
|
||||
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"
|
||||
|
||||
@@ -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<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
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<typeof import("@/workbench/contributions")>()),
|
||||
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(
|
||||
<>
|
||||
<AgentsView />
|
||||
<AgentsStatus />
|
||||
</>,
|
||||
);
|
||||
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 <span data-testid="badge">{useBadge()}</span>;
|
||||
}
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
tasks: new Map([task("one", "needsInput"), task("two", "ready")].map((t) => [t.id, t])),
|
||||
unread: new Set(["one", "two"]),
|
||||
}),
|
||||
);
|
||||
render(<Badge />);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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": "等待输入",
|
||||
|
||||
Reference in New Issue
Block a user