diff --git a/CONTEXT.md b/CONTEXT.md index 975b5bd2..2085b755 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -116,6 +116,10 @@ _Avoid_: Delete, completed, cleanup Session 列表中的轻量消息提示,表示该 Session 有用户尚未看过的新消息、run 结果或失败说明。它不是 Session Status,也不区分失败和完成。只有用户打开该 Session,且 Live Chat 渲染到最新消息位置后才清除;hover 列表项或切换 Project 不清除。 _Avoid_: Error badge, completed badge, runtime status +**Session Notification**: +窗口不在前台时,Session 的一次 run 结束(转为完成或空闲,或者失败)弹出的系统通知。按 Session Status 的变化触发,不按快照触发:应用启动和首次读到 Session 列表只记基线。标题是侧栏中的 Session 名,正文为 `Finished` 或 `Failed`;同一 Session 的新通知替换旧通知;点击后聚焦窗口并打开该 Session。 +_Avoid_: toast, alert, push notification + **Session List Ordering**: Project 与 Chats 下 Session 列表按用户最后一次成功发送消息的时间倒序,包括普通发送、Queue 和 Steer;尚无发送记录的新 Session 按创建时间排序。运行状态、助手输出、未读结果、打开会话和草稿编辑不改变顺序,提醒与导航顺序分离。 _Avoid_: Runtime activity ordering, unread-first ordering, last-opened ordering, draft ordering diff --git a/apps/desktop/src/app/main.tsx b/apps/desktop/src/app/main.tsx index cf4d28cf..10c253d0 100644 --- a/apps/desktop/src/app/main.tsx +++ b/apps/desktop/src/app/main.tsx @@ -21,6 +21,7 @@ import { TrajectoryIndexPage, TrajectorySessionPage } from "@/pages/trajectory"; import { UsagePage } from "@/pages/usage"; import type { EnvironmentPreflightStatus } from "@pace/core"; import { SessionProjectionsProvider } from "@/entities/session/use-session-projections"; +import { SessionNotifications } from "@/features/session-notifications"; import { startModelCatalogInvalidationBridge } from "@/entities/model/use-model-catalog"; import { invoke, isElectronRuntime, onNavigateRequest } from "@/shared/runtime"; import { Theme } from "@astryxdesign/core"; @@ -295,6 +296,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render( + + void router.navigate({ + to: "/projects/$projectId/sessions", + params: { projectId }, + search: { session: id }, + } as never) + } + /> {DevUiIntentPicker ? ( diff --git a/apps/desktop/src/features/session-notifications/index.ts b/apps/desktop/src/features/session-notifications/index.ts new file mode 100644 index 00000000..7afddfd0 --- /dev/null +++ b/apps/desktop/src/features/session-notifications/index.ts @@ -0,0 +1,8 @@ +export { + diffSessionNotices, + type SessionNotificationSource, + type SessionNotice, + type SessionStatusBaseline, +} from "./session-notices"; +export { useSessionNotifications } from "./use-session-notifications"; +export { SessionNotifications } from "./session-notifications"; diff --git a/apps/desktop/src/features/session-notifications/session-notices.test.ts b/apps/desktop/src/features/session-notifications/session-notices.test.ts new file mode 100644 index 00000000..aef7feee --- /dev/null +++ b/apps/desktop/src/features/session-notifications/session-notices.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + diffSessionNotices, + type SessionNotificationSource, + type SessionStatusBaseline, +} from "./session-notices"; +import type { SessionStatus } from "@/entities/session/session-projection"; + +function source( + id: string, + status: SessionStatus, + title = `Session ${id}`, +): SessionNotificationSource { + return { id, title, status }; +} + +function baselineOf( + entries: ReadonlyArray, +): SessionStatusBaseline { + return new Map(entries); +} + +describe("diffSessionNotices", () => { + it("seeds the baseline silently on the first snapshot", () => { + const { notices, baseline } = diffSessionNotices(undefined, [ + source("a", "running"), + source("b", "completed"), + source("c", "failed"), + ]); + + expect(notices).toEqual([]); + expect(baseline.get("a")).toBe("running"); + expect(baseline.get("b")).toBe("completed"); + expect(baseline.get("c")).toBe("failed"); + expect(baseline.size).toBe(3); + }); + + it("notifies Finished when a running session completes", () => { + const { notices } = diffSessionNotices( + baselineOf([["a", "running"]]), + [source("a", "completed", "Fix the bug")], + ); + + expect(notices).toEqual([ + { sessionId: "a", title: "Fix the bug", body: "Finished" }, + ]); + }); + + it("notifies Finished when a running session goes idle (waiting)", () => { + const { notices } = diffSessionNotices( + baselineOf([["a", "running"]]), + [source("a", "waiting")], + ); + + expect(notices).toEqual([ + { sessionId: "a", title: "Session a", body: "Finished" }, + ]); + }); + + it("notifies Failed when a running session fails", () => { + const { notices } = diffSessionNotices( + baselineOf([["a", "running"]]), + [source("a", "failed")], + ); + + expect(notices).toEqual([ + { sessionId: "a", title: "Session a", body: "Failed" }, + ]); + }); + + it("never notifies out of creating: a fork/creation may never have run", () => { + for (const current of ["waiting", "completed", "failed"] as const) { + const { notices } = diffSessionNotices(baselineOf([["a", "creating"]]), [ + source("a", current), + ]); + expect(notices).toEqual([]); + } + }); + + it("stays silent while a run continues or stays ended", () => { + for (const [previous, current] of [ + ["creating", "running"], + ["waiting", "running"], + ["completed", "completed"], + ] as const) { + const { notices } = diffSessionNotices(baselineOf([["a", previous]]), [ + source("a", current), + ]); + expect(notices).toEqual([]); + } + }); + + it("never notifies for a session absent from the previous baseline", () => { + const { notices } = diffSessionNotices(baselineOf([["a", "running"]]), [ + source("a", "running"), + source("new", "completed"), + source("other", "failed"), + ]); + + expect(notices).toEqual([]); + }); + + it("emits one notice per session ending in the same update", () => { + const { notices, baseline } = diffSessionNotices( + baselineOf([ + ["a", "running"], + ["b", "running"], + ["c", "running"], + ]), + [source("a", "completed"), source("b", "failed"), source("c", "running")], + ); + + expect(notices).toEqual([ + { sessionId: "a", title: "Session a", body: "Finished" }, + { sessionId: "b", title: "Session b", body: "Failed" }, + ]); + expect(baseline.get("c")).toBe("running"); + }); + + it("drops sessions that disappeared from the input", () => { + const { baseline } = diffSessionNotices( + baselineOf([ + ["a", "running"], + ["gone", "completed"], + ]), + [source("a", "running")], + ); + + expect(baseline.has("gone")).toBe(false); + expect(baseline.size).toBe(1); + }); +}); diff --git a/apps/desktop/src/features/session-notifications/session-notices.ts b/apps/desktop/src/features/session-notifications/session-notices.ts new file mode 100644 index 00000000..a33744c7 --- /dev/null +++ b/apps/desktop/src/features/session-notifications/session-notices.ts @@ -0,0 +1,61 @@ +import type { SessionStatus } from "@/entities/session/session-projection"; + +export type SessionNotificationSource = { + readonly id: string; + readonly title: string; + readonly status: SessionStatus; +}; + +export type SessionNotice = { + readonly sessionId: string; + readonly title: string; + readonly body: "Finished" | "Failed"; +}; + +export type SessionStatusBaseline = ReadonlyMap; + +// Only "running" counts as a run in flight. A "creating" Session may never +// start one: a fork lands as creating and then resyncs to the source's end +// status, which must not read as a finished run. The trade-off — a creation +// that fails before its first run no longer notifies — is accepted. +const RUNNING_STATUSES: ReadonlySet = new Set(["running"]); + +// Notices fire on status *transitions*, not snapshots: a Session is only +// newsworthy when a run we already saw in flight ends. The first list ever +// seen (undefined previous) just seeds the baseline — Sessions mid-run or +// long finished at startup are not news — and a Session absent from the +// baseline can never notify, since we never saw its run. +export function diffSessionNotices( + previous: SessionStatusBaseline | undefined, + sessions: readonly SessionNotificationSource[], +): { notices: SessionNotice[]; baseline: SessionStatusBaseline } { + const baseline = new Map(); + const notices: SessionNotice[] = []; + + for (const session of sessions) { + baseline.set(session.id, session.status); + + if (previous === undefined) { + continue; + } + const previousStatus = previous.get(session.id); + if (previousStatus === undefined || !RUNNING_STATUSES.has(previousStatus)) { + continue; + } + if (session.status === "completed" || session.status === "waiting") { + notices.push({ + sessionId: session.id, + title: session.title, + body: "Finished", + }); + } else if (session.status === "failed") { + notices.push({ + sessionId: session.id, + title: session.title, + body: "Failed", + }); + } + } + + return { notices, baseline }; +} diff --git a/apps/desktop/src/features/session-notifications/session-notifications.test.tsx b/apps/desktop/src/features/session-notifications/session-notifications.test.tsx new file mode 100644 index 00000000..5092880d --- /dev/null +++ b/apps/desktop/src/features/session-notifications/session-notifications.test.tsx @@ -0,0 +1,114 @@ +import { act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createSessionProjection, + type SessionProjection, +} from "@/entities/session/session-projection"; +import type { SessionProjectionsStore } from "@/entities/session/session-projections-store"; +import { + SessionProjectionsProvider, + useSessionProjections, +} from "@/entities/session/use-session-projections"; +import type { PiRuntimeBridge } from "@/entities/runtime/pi-runtime-bridge"; +import { render } from "@/test/render"; +import { SessionNotifications } from "./session-notifications"; + +class FakeNotification { + static permission: "granted" | "denied" | "default" = "granted"; + static instances: FakeNotification[] = []; + onclick: (() => void) | null = null; + close = vi.fn(); + + constructor( + public title: string, + public options?: NotificationOptions, + ) { + FakeNotification.instances.push(this); + } +} + +const bridge = { + subscribeToEvents: () => () => {}, + subscribeToAgentEvents: () => () => {}, + subscribeToModelControls: () => () => {}, +} as unknown as PiRuntimeBridge; + +function runningProjection(): SessionProjection { + return { + ...createSessionProjection({ + id: "session-notif", + projectId: "pig-docs", + initialPrompt: "Notify me", + createdAt: "2026-09-20T08:00:00.000Z", + }), + status: "running", + runtimeId: "pi-sdk:session-notif", + piSessionId: "pi-session-notif", + }; +} + +describe("SessionNotifications", () => { + beforeEach(() => { + // Skips the provider's rehydrate path, which needs a backend list. + window.__PACE_ENABLE_BROWSER_DEVELOPMENT_MOCKS__ = true; + FakeNotification.instances = []; + FakeNotification.permission = "granted"; + vi.stubGlobal("Notification", FakeNotification); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + vi.spyOn(window, "focus").mockImplementation(() => {}); + }); + + afterEach(() => { + delete window.__PACE_ENABLE_BROWSER_DEVELOPMENT_MOCKS__; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("notifies when a store-driven run ends and opens the Session on click", () => { + let store: SessionProjectionsStore | null = null; + function Probe() { + store = useSessionProjections().store; + return null; + } + const onOpen = vi.fn(); + render( + + + + , + ); + + act(() => { + store!.insert(runningProjection()); + }); + act(() => { + store!.apply("session-notif", { + type: "run-completed", + event: { + id: "evt-done", + piSessionId: "pi-session-notif", + kind: "message", + role: "assistant", + body: "done", + timestamp: "2026-09-20T08:01:00.000Z", + }, + }); + }); + + expect(FakeNotification.instances).toHaveLength(1); + const notification = FakeNotification.instances[0]; + expect(notification.title).toBe("Notify me"); + expect(notification.options).toEqual({ + body: "Finished", + tag: "session-notif", + }); + + notification.onclick?.(); + + expect(onOpen).toHaveBeenCalledWith({ + id: "session-notif", + projectId: "pig-docs", + }); + }); +}); diff --git a/apps/desktop/src/features/session-notifications/session-notifications.tsx b/apps/desktop/src/features/session-notifications/session-notifications.tsx new file mode 100644 index 00000000..8af03de4 --- /dev/null +++ b/apps/desktop/src/features/session-notifications/session-notifications.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { + getSessionProjectionListItems, +} from "@/entities/session/session-projection"; +import { useSessionProjections } from "@/entities/session/use-session-projections"; +import { useSessionNotifications } from "./use-session-notifications"; + +// Mounted once at app level (main.tsx), not inside AppFrame: every page owns +// its own AppFrame, so a widget-level mount would re-seed the status baseline +// on each navigation and silently swallow a run that ended in between. +export function SessionNotifications({ + onOpen, +}: { + onOpen: (session: { id: string; projectId: string }) => void; +}): null { + const { sessionProjections, store } = useSessionProjections(); + const sources = useMemo( + () => + getSessionProjectionListItems(sessionProjections).map( + ({ id, title, projection }) => ({ + id, + title, + status: projection.status, + }), + ), + [sessionProjections], + ); + + useSessionNotifications(sources, (sessionId) => { + const projection = store.get(sessionId); + if (projection) { + onOpen({ id: projection.id, projectId: projection.projectId }); + } + }); + + return null; +} diff --git a/apps/desktop/src/features/session-notifications/use-session-notifications.test.tsx b/apps/desktop/src/features/session-notifications/use-session-notifications.test.tsx new file mode 100644 index 00000000..d938c14d --- /dev/null +++ b/apps/desktop/src/features/session-notifications/use-session-notifications.test.tsx @@ -0,0 +1,88 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSessionNotifications } from "./use-session-notifications"; +import type { SessionNotificationSource } from "./session-notices"; +import type { SessionStatus } from "@/entities/session/session-projection"; + +class FakeNotification { + static permission: "granted" | "denied" | "default" = "granted"; + static instances: FakeNotification[] = []; + onclick: (() => void) | null = null; + close = vi.fn(); + + constructor( + public title: string, + public options?: NotificationOptions, + ) { + FakeNotification.instances.push(this); + } +} + +function sessionsWith(status: SessionStatus): SessionNotificationSource[] { + return [{ id: "s1", title: "Fix the bug", status }]; +} + +describe("useSessionNotifications", () => { + beforeEach(() => { + FakeNotification.instances = []; + FakeNotification.permission = "granted"; + vi.stubGlobal("Notification", FakeNotification); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + vi.spyOn(window, "focus").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("does not notify while the window is in front", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(true); + const onOpen = vi.fn(); + const { rerender } = renderHook( + ({ sessions }) => useSessionNotifications(sessions, onOpen), + { initialProps: { sessions: sessionsWith("running") } }, + ); + + rerender({ sessions: sessionsWith("completed") }); + + expect(FakeNotification.instances).toHaveLength(0); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("notifies when the window is not in front and opens the session on click", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const onOpen = vi.fn(); + const { rerender } = renderHook( + ({ sessions }) => useSessionNotifications(sessions, onOpen), + { initialProps: { sessions: sessionsWith("running") } }, + ); + + rerender({ sessions: sessionsWith("completed") }); + + expect(FakeNotification.instances).toHaveLength(1); + const notification = FakeNotification.instances[0]; + expect(notification.title).toBe("Fix the bug"); + expect(notification.options).toEqual({ body: "Finished", tag: "s1" }); + + notification.onclick?.(); + + expect(window.focus).toHaveBeenCalled(); + expect(onOpen).toHaveBeenCalledWith("s1"); + expect(notification.close).toHaveBeenCalled(); + }); + + it("does not notify when permission is not granted", () => { + FakeNotification.permission = "denied"; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const onOpen = vi.fn(); + const { rerender } = renderHook( + ({ sessions }) => useSessionNotifications(sessions, onOpen), + { initialProps: { sessions: sessionsWith("running") } }, + ); + + rerender({ sessions: sessionsWith("completed") }); + + expect(FakeNotification.instances).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/features/session-notifications/use-session-notifications.ts b/apps/desktop/src/features/session-notifications/use-session-notifications.ts new file mode 100644 index 00000000..869ce33d --- /dev/null +++ b/apps/desktop/src/features/session-notifications/use-session-notifications.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef } from "react"; +import { + diffSessionNotices, + type SessionNotificationSource, + type SessionStatusBaseline, +} from "./session-notices"; + +// Watches sidebar session statuses and raises a system notification when a +// run ends while the window is not in front. The baseline is updated before +// every early return so a skipped batch still records statuses — and a +// StrictMode double-effect with identical input re-diffs to zero notices. +export function useSessionNotifications( + sessions: readonly SessionNotificationSource[], + onOpen: (sessionId: string) => void, +): void { + const baselineRef = useRef(undefined); + // A click may land long after the effect ran; keep the latest callback. + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; + + useEffect(() => { + const { notices, baseline } = diffSessionNotices( + baselineRef.current, + sessions, + ); + baselineRef.current = baseline; + + if ( + typeof Notification === "undefined" || + Notification.permission !== "granted" + ) { + return; + } + // Window in front: the status change is already on screen. + if (document.visibilityState === "visible" && document.hasFocus()) { + return; + } + + for (const notice of notices) { + // tag = session id: a newer notice for the same Session replaces the old. + const notification = new Notification(notice.title, { + body: notice.body, + tag: notice.sessionId, + }); + notification.onclick = () => { + window.focus(); + onOpenRef.current(notice.sessionId); + notification.close(); + }; + } + }, [sessions]); +} diff --git a/apps/desktop/src/pages/agent-workspace.test.tsx b/apps/desktop/src/pages/agent-workspace.test.tsx index 3dec55b3..593c2394 100644 --- a/apps/desktop/src/pages/agent-workspace.test.tsx +++ b/apps/desktop/src/pages/agent-workspace.test.tsx @@ -2374,6 +2374,25 @@ describe("AgentWorkspaceSessionsPage", () => { ); }); + it("selects the Session named by the session search param, then strips it", async () => { + // "Usage evidence review" sorts behind the running "Agent Workspace + // shell", so a first-session fallback would pick the wrong row. + const { router } = renderProjectSessions( + "/projects/pig/sessions?session=session-usage-review", + ); + + expect( + await findSidebarSessionRow("Usage evidence review"), + ).toHaveAttribute("aria-current", "page"); + expect( + await findSidebarSessionRow("Agent Workspace shell"), + ).not.toHaveAttribute("aria-current", "page"); + await waitFor(() => expect(router.state.location.search).toEqual({})); + expect(router.state.location.pathname).toBe( + `/projects/${encodeURIComponent(pigProjectPath)}/sessions`, + ); + }); + it("shows a failed Session Creation in the Live Session and reopens the kept draft", async () => { const user = userEvent.setup(); addProjectToRegistry(pigProjectPath); diff --git a/apps/desktop/src/pages/agent-workspace.tsx b/apps/desktop/src/pages/agent-workspace.tsx index 9f869e96..949918ec 100644 --- a/apps/desktop/src/pages/agent-workspace.tsx +++ b/apps/desktop/src/pages/agent-workspace.tsx @@ -404,6 +404,13 @@ export function AgentWorkspaceSessionsPage() { return search.view === "draft"; }, }); + const requestedSessionId = useRouterState({ + select: (state) => { + const value = (state.location.search as { session?: unknown }).session; + + return typeof value === "string" ? value : null; + }, + }); const [browserDevelopmentData] = useState(() => shouldUseBrowserDevelopmentData(), ); @@ -543,7 +550,10 @@ export function AgentWorkspaceSessionsPage() { // After hydrate (or when project sessions appear), select the first valid session. useEffect(() => { - if (selectedSessionId) { + // A pending ?session= request must also hold the fallback back: this effect + // runs in the same commit while selectedSessionId is still stale null, and + // its selection would win over the requested one. + if (selectedSessionId || requestedSessionId) { return; } @@ -551,7 +561,29 @@ export function AgentWorkspaceSessionsPage() { if (nextSessionId) { setSelectedSessionId(nextSessionId); } - }, [projectId, selectedSessionId, sessionProjections]); + }, [projectId, requestedSessionId, selectedSessionId, sessionProjections]); + + // Cross-page opens (sidebar click from a non-Sessions page, a Session + // Notification) carry the target in ?session=; consume it once, like + // enterLiveSession strips view=draft. + useEffect(() => { + if (!requestedSessionId) { + return; + } + + setSelectedSessionId(requestedSessionId); + void navigate({ + to: "/projects/$projectId/sessions", + params: { projectId }, + search: ((previous: Record) => { + const { session: _session, ...search } = previous; + return search; + }) as never, + hash: true, + replace: true, + resetScroll: false, + }); + }, [navigate, projectId, requestedSessionId]); useEffect(() => { if (!selectedSessionProjection) { diff --git a/apps/desktop/src/widgets/app-frame/app-frame.tsx b/apps/desktop/src/widgets/app-frame/app-frame.tsx index 4c00fa20..73e23a2c 100644 --- a/apps/desktop/src/widgets/app-frame/app-frame.tsx +++ b/apps/desktop/src/widgets/app-frame/app-frame.tsx @@ -1558,6 +1558,9 @@ export function AppFrame({ })); void router.navigate({ to: projectRoute(projectId) as never, + // Pages that own their own selection state (the Sessions page) read the + // intent from the URL; local selection alone would be lost on navigate. + search: { session: sessionId } as never, }); }; diff --git a/docs/architecture.md b/docs/architecture.md index 459d60a1..396eab7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,6 +81,7 @@ flowchart TB | --- | --- | | 界面、页面、交互 | [`apps/desktop/src/`](../apps/desktop/src/),FSD 分层 `pages` → `widgets` → `features` → `entities` → `shared`([ADR-0016](adr/0016-fsd-layers-in-apps-desktop.md)、[ADR-0045](adr/0045-slice-agent-workspace-into-fsd.md));`bun run lint` 强制约束依赖方向 | | Live Session View / Session Draft / Session Dock | [`apps/desktop/src/widgets/live-chat/`](../apps/desktop/src/widgets/live-chat/)、[`features/session-draft/`](../apps/desktop/src/features/session-draft/)、[`widgets/session-dock/`](../apps/desktop/src/widgets/session-dock/) | +| 桌面通知(窗口不在前台时 run 结束提醒) | [`apps/desktop/src/features/session-notifications/`](../apps/desktop/src/features/session-notifications/),由 [`app/main.tsx`](../apps/desktop/src/app/main.tsx) 在应用级挂载(跨页面保持基线) | | 事件语义(什么算 message / run / turn) | [`packages/backend/src/gateway/agent-runtime-event-normalizer.ts`](../packages/backend/src/gateway/) 及其基于录制数据的测试 | | Gateway 协议(命令、事件契约、身份) | [`packages/core/src/`](../packages/core/src/):`runtime-gateway.ts`、`agent-runtime-event.ts` | | 怎么驱动 Pi | [`packages/backend/src/drivers/`](../packages/backend/src/drivers/) |