Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -295,6 +296,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Theme theme={neutralTheme}>
<QueryClientProvider client={queryClient}>
<SessionProjectionsProvider>
<SessionNotifications
onOpen={({ id, projectId }) =>
void router.navigate({
to: "/projects/$projectId/sessions",
params: { projectId },
search: { session: id },
} as never)
}
/>
<RouterProvider router={router} />
{DevUiIntentPicker ? (
<React.Suspense fallback={null}>
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/features/session-notifications/index.ts
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";
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 apps/desktop/src/features/session-notifications/session-notices.ts
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",
});
Comment on lines +45 to +50

Copy link
Copy Markdown
Contributor

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, diffSessionNotices reports Finished for creating to waiting. 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 via runtime-state-resynced without sending a prompt. The projection reducer maps that idle state to waiting. 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 Finished even 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 creating to waiting. Keep notifications for genuine run completions and creation failures.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1390c26. Only running counts as a run in flight now. A fork is inserted as creating and then resyncs to whatever status the source session ended in, so leaving creating can 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.

} else if (session.status === "failed") {
notices.push({
sessionId: session.id,
title: session.title,
body: "Failed",
});
}
}

return { notices, baseline };
}
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",
});
});
});
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;
}
Loading
Loading