From f8d8f8873f74d3b76ca5db93e4d6cd0f6f51e4e1 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Sun, 11 Oct 2026 18:47:54 +0800 Subject: [PATCH 1/2] feat(desktop): notify when a Session's run ends while Pace is in the background --- CONTEXT.md | 4 + .../features/session-notifications/index.ts | 7 + .../session-notices.test.ts | 134 ++++++++++++++++++ .../session-notifications/session-notices.ts | 61 ++++++++ .../use-session-notifications.test.tsx | 88 ++++++++++++ .../use-session-notifications.ts | 52 +++++++ .../src/widgets/app-frame/app-frame.tsx | 17 +++ docs/architecture.md | 1 + 8 files changed, 364 insertions(+) create mode 100644 apps/desktop/src/features/session-notifications/index.ts create mode 100644 apps/desktop/src/features/session-notifications/session-notices.test.ts create mode 100644 apps/desktop/src/features/session-notifications/session-notices.ts create mode 100644 apps/desktop/src/features/session-notifications/use-session-notifications.test.tsx create mode 100644 apps/desktop/src/features/session-notifications/use-session-notifications.ts 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/features/session-notifications/index.ts b/apps/desktop/src/features/session-notifications/index.ts new file mode 100644 index 00000000..e5cd3b43 --- /dev/null +++ b/apps/desktop/src/features/session-notifications/index.ts @@ -0,0 +1,7 @@ +export { + diffSessionNotices, + type SessionNotificationSource, + type SessionNotice, + type SessionStatusBaseline, +} from "./session-notices"; +export { useSessionNotifications } from "./use-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..532f588f --- /dev/null +++ b/apps/desktop/src/features/session-notifications/session-notices.test.ts @@ -0,0 +1,134 @@ +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("notifies Failed when a creating session fails", () => { + const { notices } = diffSessionNotices( + baselineOf([["a", "creating"]]), + [source("a", "failed")], + ); + + expect(notices).toEqual([ + { sessionId: "a", title: "Session a", body: "Failed" }, + ]); + }); + + 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..daca8524 --- /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; + +// A run "was going" for exactly the statuses isSessionProjectionActive reports. +const ACTIVE_STATUSES: ReadonlySet = new Set([ + "creating", + "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 || !ACTIVE_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/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/widgets/app-frame/app-frame.tsx b/apps/desktop/src/widgets/app-frame/app-frame.tsx index 4c00fa20..5150568e 100644 --- a/apps/desktop/src/widgets/app-frame/app-frame.tsx +++ b/apps/desktop/src/widgets/app-frame/app-frame.tsx @@ -74,6 +74,7 @@ import { useSessionProjectionsOptional } from "@/entities/session/use-session-pr import { useUpdateStatus } from "@/entities/update/use-update-status"; import { shouldUseBrowserDevelopmentData } from "@/dev/browser-development-data"; import { defaultSidebarProjectSessionProjections } from "@/dev/fixtures/agent-workspace"; +import { useSessionNotifications } from "@/features/session-notifications"; import { hostWindowChrome, readHostPlatform, @@ -1561,6 +1562,22 @@ export function AppFrame({ }); }; + const notificationSources = useMemo( + () => + sessions.map((session) => ({ + id: session.id, + title: session.title, + status: session.projection.status, + })), + [sessions], + ); + useSessionNotifications(notificationSources, (sessionId) => { + const session = sessions.find((candidate) => candidate.id === sessionId); + if (session) { + handleOpenSession(session.id, session.projection.projectId); + } + }); + const frameContent = ( <> Date: Sun, 11 Oct 2026 19:04:33 +0800 Subject: [PATCH 2/2] fix(desktop): open the notified Session from any page and keep one notification baseline --- apps/desktop/src/app/main.tsx | 10 ++ .../features/session-notifications/index.ts | 1 + .../session-notices.test.ts | 16 ++- .../session-notifications/session-notices.ts | 12 +- .../session-notifications.test.tsx | 114 ++++++++++++++++++ .../session-notifications.tsx | 37 ++++++ .../src/pages/agent-workspace.test.tsx | 19 +++ apps/desktop/src/pages/agent-workspace.tsx | 36 +++++- .../src/widgets/app-frame/app-frame.tsx | 20 +-- docs/architecture.md | 2 +- 10 files changed, 232 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/features/session-notifications/session-notifications.test.tsx create mode 100644 apps/desktop/src/features/session-notifications/session-notifications.tsx 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 index e5cd3b43..7afddfd0 100644 --- a/apps/desktop/src/features/session-notifications/index.ts +++ b/apps/desktop/src/features/session-notifications/index.ts @@ -5,3 +5,4 @@ export { 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 index 532f588f..aef7feee 100644 --- a/apps/desktop/src/features/session-notifications/session-notices.test.ts +++ b/apps/desktop/src/features/session-notifications/session-notices.test.ts @@ -68,15 +68,13 @@ describe("diffSessionNotices", () => { ]); }); - it("notifies Failed when a creating session fails", () => { - const { notices } = diffSessionNotices( - baselineOf([["a", "creating"]]), - [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", () => { diff --git a/apps/desktop/src/features/session-notifications/session-notices.ts b/apps/desktop/src/features/session-notifications/session-notices.ts index daca8524..a33744c7 100644 --- a/apps/desktop/src/features/session-notifications/session-notices.ts +++ b/apps/desktop/src/features/session-notifications/session-notices.ts @@ -14,11 +14,11 @@ export type SessionNotice = { export type SessionStatusBaseline = ReadonlyMap; -// A run "was going" for exactly the statuses isSessionProjectionActive reports. -const ACTIVE_STATUSES: ReadonlySet = new Set([ - "creating", - "running", -]); +// 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 @@ -39,7 +39,7 @@ export function diffSessionNotices( continue; } const previousStatus = previous.get(session.id); - if (previousStatus === undefined || !ACTIVE_STATUSES.has(previousStatus)) { + if (previousStatus === undefined || !RUNNING_STATUSES.has(previousStatus)) { continue; } if (session.status === "completed" || session.status === "waiting") { 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/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 5150568e..73e23a2c 100644 --- a/apps/desktop/src/widgets/app-frame/app-frame.tsx +++ b/apps/desktop/src/widgets/app-frame/app-frame.tsx @@ -74,7 +74,6 @@ import { useSessionProjectionsOptional } from "@/entities/session/use-session-pr import { useUpdateStatus } from "@/entities/update/use-update-status"; import { shouldUseBrowserDevelopmentData } from "@/dev/browser-development-data"; import { defaultSidebarProjectSessionProjections } from "@/dev/fixtures/agent-workspace"; -import { useSessionNotifications } from "@/features/session-notifications"; import { hostWindowChrome, readHostPlatform, @@ -1559,25 +1558,12 @@ 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, }); }; - const notificationSources = useMemo( - () => - sessions.map((session) => ({ - id: session.id, - title: session.title, - status: session.projection.status, - })), - [sessions], - ); - useSessionNotifications(notificationSources, (sessionId) => { - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) { - handleOpenSession(session.id, session.projection.projectId); - } - }); - const frameContent = ( <>