Repository navigation
feat(desktop): notify when a Session's run ends while Pace is in the background #461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; |
132 changes: 132 additions & 0 deletions
132
apps/desktop/src/features/session-notifications/session-notices.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<readonly [string, SessionStatus]>, | ||
| ): 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); | ||
| }); | ||
| }); |
61 changes: 61 additions & 0 deletions
61
apps/desktop/src/features/session-notifications/session-notices.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, SessionStatus>; | ||
|
|
||
| // 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<SessionStatus> = 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<string, SessionStatus>(); | ||
| 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 }; | ||
| } | ||
114 changes: 114 additions & 0 deletions
114
apps/desktop/src/features/session-notifications/session-notifications.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <SessionProjectionsProvider bridge={bridge}> | ||
| <Probe /> | ||
| <SessionNotifications onOpen={onOpen} /> | ||
| </SessionProjectionsProvider>, | ||
| ); | ||
|
|
||
| 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", | ||
| }); | ||
| }); | ||
| }); |
37 changes: 37 additions & 0 deletions
37
apps/desktop/src/features/session-notifications/session-notifications.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Idle fork gets false completion notice
When a fork finishes while Pace is backgrounded,
diffSessionNoticesreportsFinishedforcreatingtowaiting. Fork creation starts no run, so no run ended.Learn more
A fork is inserted with status
creating, then handleForkMessage applies its idle runtime snapshot viaruntime-state-resyncedwithout sending a prompt. The projection reducer maps that idle state towaiting. The notification diff treats this transition as a finished run although the new fork only copied conversation context.Example: Alice forks a message, switches to another application during checkout, and the new fork becomes idle. Pace announces
Finishedeven though Alice has not submitted its prefilled message.Recommended fix: Track whether the creating session actually entered an active run, or distinguish fork creation from prompt-driven creation before notifying on
creatingtowaiting. Keep notifications for genuine run completions and creation failures.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 1390c26. Only
runningcounts as a run in flight now. A fork is inserted ascreatingand then resyncs to whatever status the source session ended in, so leavingcreatingcan never mean a run finished. We accept the trade-off: a creation that fails before its first run no longer notifies. Tests cover creating→waiting/completed/failed producing no notice.