From 285836da7b50bab56efeb5ff2139132f8553425c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 23 May 2026 22:34:12 +0800 Subject: [PATCH 1/2] refactor(app): extract layout side effect controllers --- packages/app/src/pages/layout.tsx | 209 +++------------- .../layout/layout-sdk-event-effects.test.ts | 104 ++++++++ .../pages/layout/layout-sdk-event-effects.ts | 225 ++++++++++++++++++ .../src/pages/layout/layout-update-polling.ts | 82 +++++++ .../pages/update-install-flow-source.test.ts | 7 +- 5 files changed, 449 insertions(+), 178 deletions(-) create mode 100644 packages/app/src/pages/layout/layout-sdk-event-effects.test.ts create mode 100644 packages/app/src/pages/layout/layout-sdk-event-effects.ts create mode 100644 packages/app/src/pages/layout/layout-update-polling.ts diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 324765d74..e0befb260 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -104,6 +104,8 @@ import { unpinPawworkSession, } from "./layout/pawwork-session-nav" import { createShellNavigation } from "./layout/shell-navigation" +import { useUpdatePolling } from "./layout/layout-update-polling" +import { sessionNotificationHref, useSDKNotificationToasts } from "./layout/layout-sdk-event-effects" import { buildPawworkSessionWindow, nextPawworkSessionWindowLimit, @@ -277,182 +279,37 @@ export default function Layout(props: ParentProps) { setLocale(next) } - const useUpdatePolling = () => - onMount(() => { - if (!platform.checkUpdate || !platform.update) return - - let toastId: number | undefined - let interval: ReturnType | undefined - - const pollUpdate = () => - platform.checkUpdate!().then(({ updateAvailable, version }) => { - if (!updateAvailable) return - if (toastId !== undefined) return - toastId = showToast({ - persistent: true, - icon: "download", - title: language.t("toast.update.title"), - description: language.t("toast.update.description", { version: version ?? "" }), - actions: [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss", - }, - ], - }) - }) - - createEffect(() => { - if (!settings.ready()) return - - if (!settings.updates.startup()) { - if (interval === undefined) return - clearInterval(interval) - interval = undefined - return - } - - if (interval !== undefined) return - void pollUpdate() - interval = setInterval(pollUpdate, 10 * 60 * 1000) - }) - - onCleanup(() => { - if (interval === undefined) return - clearInterval(interval) - }) - }) - - const useSDKNotificationToasts = () => - onMount(() => { - const alertedAtBySession = new Map() - const alertedQuestionCalls = new Set() - const cooldownMs = 5000 - - const isCurrentOrDescendant = (directory: string, sessionID: string) => { - const currentSession = params.id - if (!currentSession) return false - if (workspaceKey(directory) !== workspaceKey(currentDir())) return false - if (sessionID === currentSession) return true - // Walk up the parent chain so a great-grandchild question is also - // recognized as visible in-app whenever an ancestor session page is - // open — matches the dock, which walks descendants from currentSession. - const [store] = globalSync.child(directory, { bootstrap: false }) - const byID = new Map(store.session.map((s) => [s.id, s])) - let cursor: string | undefined = byID.get(sessionID)?.parentID - const seen = new Set([sessionID]) - while (cursor) { - if (cursor === currentSession) return true - if (seen.has(cursor)) break - seen.add(cursor) - cursor = byID.get(cursor)?.parentID - } - return false - } - - const unsub = globalSDK.event.listen((e) => { - if (e.details?.type === "worktree.ready") { - setBusy(e.name, false) - WorktreeState.ready(e.name) - return - } - - if (e.details?.type === "worktree.failed") { - setBusy(e.name, false) - WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed")) - return - } - - if (e.details?.type === "permission.replied") { - const props = e.details.properties as { sessionID: string } - const sessionKey = `${e.name}:${props.sessionID}` - alertedAtBySession.delete(sessionKey) - return - } - - if (e.details?.type === "message.part.updated") { - const directory = e.name - const { sessionID, part } = e.details.properties - if (part.type !== "tool" || part.tool !== "question") return - const callKey = `${directory}:${sessionID}:${part.id}` - // Drop the dedup entry once the part settles. Without this the - // running-question dedup set grows unbounded across the app's - // lifetime, since terminal parts are not always followed by a - // message.part.removed event. The tool runner never transitions - // a question part out of `running` and back; if that ever changes - // the dedup will need to gate on a "first-ready" boolean instead. - if (part.state.status !== "running") { - alertedQuestionCalls.delete(callKey) - return - } - // The tool runner sets metadata.externalResultReady AFTER the - // Deferred is registered. Only notify once the route can resolve. - if (part.state.metadata?.externalResultReady !== true) return - - if (alertedQuestionCalls.has(callKey)) return - alertedQuestionCalls.add(callKey) - - const [store] = globalSync.child(directory, { bootstrap: false }) - const session = store.session.find((s) => s.id === sessionID) - if (isCurrentOrDescendant(directory, sessionID)) return - - if (!settings.notifications.agent()) return - const sessionTitle = session?.title ?? language.t("command.session.new") - const projectName = getFilename(directory) - void platform.notify( - language.t("notification.question.title"), - language.t("notification.question.description", { sessionTitle, projectName }), - `/${base64Encode(directory)}/session/${sessionID}`, - ) - return - } - - if (e.details?.type === "message.part.removed") { - const { sessionID, partID } = e.details.properties - alertedQuestionCalls.delete(`${e.name}:${sessionID}:${partID}`) - return - } - - if (e.details?.type !== "permission.asked") return - const title = language.t("notification.permission.title") - const directory = e.name - const props = e.details.properties - if (permission.autoResponds(e.details.properties, directory)) return - - const [store] = globalSync.child(directory, { bootstrap: false }) - const session = store.session.find((s) => s.id === props.sessionID) - const sessionKey = `${directory}:${props.sessionID}` - - const sessionTitle = session?.title ?? language.t("command.session.new") - const projectName = getFilename(directory) - const description = language.t("notification.permission.description", { sessionTitle, projectName }) - const href = `/${base64Encode(directory)}/session/${props.sessionID}` - - const now = Date.now() - const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 - if (now - lastAlerted < cooldownMs) return - alertedAtBySession.set(sessionKey, now) - - if (settings.sounds.permissionsEnabled()) { - void playSoundById(settings.sounds.permissions()) - } - if (settings.notifications.permissions()) { - if (!isCurrentOrDescendant(directory, props.sessionID)) { - void platform.notify(title, description, href) - } - } - }) - onCleanup(unsub) - }) - - useUpdatePolling() - useSDKNotificationToasts() + useUpdatePolling({ + platform, + settings, + copy: language, + effects: { + showToast, + }, + }) + useSDKNotificationToasts({ + route: { + currentDirectory: currentDir, + currentSessionID: () => params.id, + sessionHref: sessionNotificationHref, + }, + sdk: { + listen: globalSDK.event.listen, + sessions: (directory) => globalSync.child(directory, { bootstrap: false })[0].session, + }, + settings, + permission: { + autoResponds: (request, directory) => permission.autoResponds(request, directory), + }, + effects: { + notify: (title, description, href) => platform.notify(title, description, href), + playPermissionSound: playSoundById, + setBusy, + worktreeReady: (directory) => WorktreeState.ready(directory), + worktreeFailed: (directory, message) => WorktreeState.failed(directory, message), + }, + copy: language, + }) function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return diff --git a/packages/app/src/pages/layout/layout-sdk-event-effects.test.ts b/packages/app/src/pages/layout/layout-sdk-event-effects.test.ts new file mode 100644 index 000000000..5ec7f00c1 --- /dev/null +++ b/packages/app/src/pages/layout/layout-sdk-event-effects.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +import { + isCurrentOrDescendantSession, + permissionSessionKey, + questionCallKey, + questionNotificationAction, + shouldThrottlePermissionAlert, +} from "./layout-sdk-event-effects" + +describe("layout sdk event effects", () => { + test("matches the current session in the active workspace", () => { + expect( + isCurrentOrDescendantSession({ + directory: "/repo/worktree/", + sessionID: "ses_current", + currentDirectory: "/repo/worktree", + currentSessionID: "ses_current", + sessions: [], + }), + ).toBe(true) + }) + + test("matches descendant sessions under the active session", () => { + expect( + isCurrentOrDescendantSession({ + directory: "/repo/worktree", + sessionID: "ses_leaf", + currentDirectory: "/repo/worktree", + currentSessionID: "ses_root", + sessions: [ + { id: "ses_root" }, + { id: "ses_child", parentID: "ses_root" }, + { id: "ses_leaf", parentID: "ses_child" }, + ], + }), + ).toBe(true) + }) + + test("does not match sessions from another workspace", () => { + expect( + isCurrentOrDescendantSession({ + directory: "/repo/other", + sessionID: "ses_current", + currentDirectory: "/repo/worktree", + currentSessionID: "ses_current", + sessions: [{ id: "ses_current" }], + }), + ).toBe(false) + }) + + test("stops descendant walks on parent cycles", () => { + expect( + isCurrentOrDescendantSession({ + directory: "/repo/worktree", + sessionID: "ses_leaf", + currentDirectory: "/repo/worktree", + currentSessionID: "ses_root", + sessions: [ + { id: "ses_leaf", parentID: "ses_a" }, + { id: "ses_a", parentID: "ses_b" }, + { id: "ses_b", parentID: "ses_a" }, + ], + }), + ).toBe(false) + }) + + test("builds stable cleanup keys", () => { + expect(permissionSessionKey("/repo", "ses_1")).toBe("/repo:ses_1") + expect(questionCallKey("/repo", "ses_1", "prt_1")).toBe("/repo:ses_1:prt_1") + }) + + test("resets question dedupe when the question part is no longer running", () => { + expect( + questionNotificationAction({ + type: "tool", + tool: "question", + state: { status: "completed", metadata: { externalResultReady: true } }, + }), + ).toBe("reset") + }) + + test("notifies only after an external question route is ready", () => { + expect( + questionNotificationAction({ + type: "tool", + tool: "question", + state: { status: "running", metadata: { externalResultReady: true } }, + }), + ).toBe("notify") + expect( + questionNotificationAction({ + type: "tool", + tool: "question", + state: { status: "running", metadata: { externalResultReady: false } }, + }), + ).toBe("ignore") + }) + + test("throttles permission alerts within cooldown only", () => { + expect(shouldThrottlePermissionAlert(1000, 5999, 5000)).toBe(true) + expect(shouldThrottlePermissionAlert(1000, 6000, 5000)).toBe(false) + expect(shouldThrottlePermissionAlert(undefined, 1000, 5000)).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/layout-sdk-event-effects.ts b/packages/app/src/pages/layout/layout-sdk-event-effects.ts new file mode 100644 index 000000000..0ef8de619 --- /dev/null +++ b/packages/app/src/pages/layout/layout-sdk-event-effects.ts @@ -0,0 +1,225 @@ +import { onCleanup, onMount } from "solid-js" +import { base64Encode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import type { Event, Part, PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { workspaceKey } from "./helpers" + +type LayoutSession = Pick + +type LayoutSdkEvent = { + name: string + details?: Event +} + +type LayoutSdkEventCopyKey = + | "common.requestFailed" + | "command.session.new" + | "notification.permission.title" + | "notification.permission.description" + | "notification.question.title" + | "notification.question.description" + +type LayoutSdkEventCopy = { + t(key: LayoutSdkEventCopyKey, params?: Record): string +} + +type QuestionNotificationPart = Pick & { + tool?: string + state?: { + status?: string + metadata?: { + externalResultReady?: unknown + } + } +} + +export function permissionSessionKey(directory: string, sessionID: string) { + return `${directory}:${sessionID}` +} + +export function questionCallKey(directory: string, sessionID: string, partID: string) { + return `${directory}:${sessionID}:${partID}` +} + +export function sessionNotificationHref(directory: string, sessionID: string) { + return `/${base64Encode(directory)}/session/${sessionID}` +} + +export function shouldThrottlePermissionAlert(lastAlerted: number | undefined, now: number, cooldownMs: number) { + if (lastAlerted === undefined) return false + return now - lastAlerted < cooldownMs +} + +export function questionNotificationAction(part: QuestionNotificationPart): "ignore" | "reset" | "notify" { + if (part.type !== "tool" || part.tool !== "question") return "ignore" + // Terminal updates may not be followed by message.part.removed, so they must + // clear the running-question dedupe entry themselves. + if (part.state?.status !== "running") return "reset" + if (part.state.metadata?.externalResultReady !== true) return "ignore" + return "notify" +} + +export function isCurrentOrDescendantSession(input: { + directory: string + sessionID: string + currentDirectory: string + currentSessionID: string | undefined + sessions: readonly Pick[] +}) { + const currentSession = input.currentSessionID + if (!currentSession) return false + if (workspaceKey(input.directory) !== workspaceKey(input.currentDirectory)) return false + if (input.sessionID === currentSession) return true + + // Walk ancestors so a child-agent question stays quiet while its parent + // session page is already visible. + const byID = new Map(input.sessions.map((session) => [session.id, session])) + let cursor: string | undefined = byID.get(input.sessionID)?.parentID + const seen = new Set([input.sessionID]) + + while (cursor) { + if (cursor === currentSession) return true + if (seen.has(cursor)) break + seen.add(cursor) + cursor = byID.get(cursor)?.parentID + } + + return false +} + +export function useSDKNotificationToasts(input: { + route: { + currentDirectory: () => string + currentSessionID: () => string | undefined + sessionHref: (directory: string, sessionID: string) => string + } + sdk: { + listen: (handler: (event: LayoutSdkEvent) => void) => () => void + sessions: (directory: string) => readonly LayoutSession[] + } + settings: { + notifications: { + agent: () => boolean + permissions: () => boolean + } + sounds: { + permissionsEnabled: () => boolean + permissions: () => string + } + } + permission: { + autoResponds: (request: PermissionRequest, directory: string) => boolean + } + effects: { + notify: (title: string, description?: string, href?: string) => Promise | void + playPermissionSound: (soundID: string) => unknown + setBusy: (directory: string, value: boolean) => void + worktreeReady: (directory: string) => void + worktreeFailed: (directory: string, message: string) => void + } + copy: LayoutSdkEventCopy + cooldownMs?: number + now?: () => number +}) { + onMount(() => { + const alertedAtBySession = new Map() + const alertedQuestionCalls = new Set() + const cooldownMs = input.cooldownMs ?? 5000 + const now = input.now ?? Date.now + + const isVisibleInCurrentRoute = (directory: string, sessionID: string) => + isCurrentOrDescendantSession({ + directory, + sessionID, + currentDirectory: input.route.currentDirectory(), + currentSessionID: input.route.currentSessionID(), + sessions: input.sdk.sessions(directory), + }) + + const unsub = input.sdk.listen((event) => { + const details = event.details + if (!details) return + + if (details.type === "worktree.ready") { + input.effects.setBusy(event.name, false) + input.effects.worktreeReady(event.name) + return + } + + if (details.type === "worktree.failed") { + input.effects.setBusy(event.name, false) + input.effects.worktreeFailed(event.name, details.properties?.message ?? input.copy.t("common.requestFailed")) + return + } + + if (details.type === "permission.replied") { + alertedAtBySession.delete(permissionSessionKey(event.name, details.properties.sessionID)) + return + } + + if (details.type === "message.part.updated") { + const directory = event.name + const { sessionID, part } = details.properties + const action = questionNotificationAction(part) + if (action === "ignore") return + + const callKey = questionCallKey(directory, sessionID, part.id) + if (action === "reset") { + alertedQuestionCalls.delete(callKey) + return + } + + if (alertedQuestionCalls.has(callKey)) return + alertedQuestionCalls.add(callKey) + + const session = input.sdk.sessions(directory).find((item) => item.id === sessionID) + if (isVisibleInCurrentRoute(directory, sessionID)) return + + if (!input.settings.notifications.agent()) return + const sessionTitle = session?.title ?? input.copy.t("command.session.new") + const projectName = getFilename(directory) + void input.effects.notify( + input.copy.t("notification.question.title"), + input.copy.t("notification.question.description", { sessionTitle, projectName }), + input.route.sessionHref(directory, sessionID), + ) + return + } + + if (details.type === "message.part.removed") { + const { sessionID, partID } = details.properties + alertedQuestionCalls.delete(questionCallKey(event.name, sessionID, partID)) + return + } + + if (details.type !== "permission.asked") return + const directory = event.name + const props = details.properties + if (input.permission.autoResponds(props, directory)) return + + const session = input.sdk.sessions(directory).find((item) => item.id === props.sessionID) + const sessionKey = permissionSessionKey(directory, props.sessionID) + + const lastAlerted = alertedAtBySession.get(sessionKey) + const currentTime = now() + if (shouldThrottlePermissionAlert(lastAlerted, currentTime, cooldownMs)) return + alertedAtBySession.set(sessionKey, currentTime) + + if (input.settings.sounds.permissionsEnabled()) { + void input.effects.playPermissionSound(input.settings.sounds.permissions()) + } + if (input.settings.notifications.permissions()) { + if (!isVisibleInCurrentRoute(directory, props.sessionID)) { + const sessionTitle = session?.title ?? input.copy.t("command.session.new") + const projectName = getFilename(directory) + void input.effects.notify( + input.copy.t("notification.permission.title"), + input.copy.t("notification.permission.description", { sessionTitle, projectName }), + input.route.sessionHref(directory, props.sessionID), + ) + } + } + }) + onCleanup(unsub) + }) +} diff --git a/packages/app/src/pages/layout/layout-update-polling.ts b/packages/app/src/pages/layout/layout-update-polling.ts new file mode 100644 index 000000000..ab31ea835 --- /dev/null +++ b/packages/app/src/pages/layout/layout-update-polling.ts @@ -0,0 +1,82 @@ +import { createEffect, onCleanup, onMount } from "solid-js" +import type { Platform } from "@/context/platform" +import type { ToastOptions } from "@opencode-ai/ui/toast" + +type UpdateCopyKey = + | "toast.update.title" + | "toast.update.description" + | "toast.update.action.installRestart" + | "toast.update.action.notYet" + +type UpdateCopy = { + t(key: UpdateCopyKey, params?: Record): string +} + +type ShowToast = (options: ToastOptions | string) => number + +export function useUpdatePolling(input: { + platform: Pick + settings: { + ready: () => boolean + updates: { + startup: () => boolean + } + } + copy: UpdateCopy + effects: { + showToast: ShowToast + } +}) { + onMount(() => { + const checkUpdate = input.platform.checkUpdate + const update = input.platform.update + if (!checkUpdate || !update) return + + let toastId: number | undefined + let interval: ReturnType | undefined + + const pollUpdate = () => + checkUpdate().then(({ updateAvailable, version }) => { + if (!updateAvailable) return + if (toastId !== undefined) return + toastId = input.effects.showToast({ + persistent: true, + icon: "download", + title: input.copy.t("toast.update.title"), + description: input.copy.t("toast.update.description", { version: version ?? "" }), + actions: [ + { + label: input.copy.t("toast.update.action.installRestart"), + onClick: async () => { + await update() + }, + }, + { + label: input.copy.t("toast.update.action.notYet"), + onClick: "dismiss", + }, + ], + }) + }) + + createEffect(() => { + if (!input.settings.ready()) return + + if (!input.settings.updates.startup()) { + if (interval === undefined) return + clearInterval(interval) + interval = undefined + return + } + + if (interval !== undefined) return + void pollUpdate() + interval = setInterval(pollUpdate, 10 * 60 * 1000) + }) + + onCleanup(() => { + if (interval === undefined) return + clearInterval(interval) + }) + }) +} diff --git a/packages/app/src/pages/update-install-flow-source.test.ts b/packages/app/src/pages/update-install-flow-source.test.ts index b690a824d..cd4198f47 100644 --- a/packages/app/src/pages/update-install-flow-source.test.ts +++ b/packages/app/src/pages/update-install-flow-source.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs" import { describe, expect, test } from "bun:test" const layout = readFileSync(new URL("./layout.tsx", import.meta.url), "utf8") +const updatePolling = readFileSync(new URL("./layout/layout-update-polling.ts", import.meta.url), "utf8") const errorPage = readFileSync(new URL("./error.tsx", import.meta.url), "utf8") const settingsUpdates = readFileSync(new URL("../components/settings-updates-section.tsx", import.meta.url), "utf8") const platform = readFileSync(new URL("../context/platform.tsx", import.meta.url), "utf8") @@ -9,16 +10,18 @@ const platform = readFileSync(new URL("../context/platform.tsx", import.meta.url describe("update install renderer contracts", () => { test("renderer install actions do not relaunch after platform update", () => { expect(layout).not.toMatch(/await\s+platform\.restart!\(\)/) + expect(updatePolling).not.toMatch(/await\s+platform\.restart!\(\)/) expect(settingsUpdates).not.toMatch(/await\s+platform\.restart!\(\)/) expect(errorPage).not.toMatch(/await\s+platform\.restart!\(\)/) expect(layout).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) + expect(updatePolling).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) expect(settingsUpdates).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) expect(errorPage).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) }) test("renderer update prompts only require platform update", () => { - expect(layout).toContain("if (!platform.checkUpdate || !platform.update) return") - expect(layout).not.toContain("if (!platform.checkUpdate || !platform.update || !platform.restart) return") + expect(updatePolling).toContain("if (!checkUpdate || !update) return") + expect(updatePolling).not.toContain("restart") expect(settingsUpdates).toContain("platform.update") expect(settingsUpdates).not.toContain("platform.update && platform.restart") }) From c8a6c13a50e6a1753e80a9305aa0a8f33f3a6ffd Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 23 May 2026 23:10:53 +0800 Subject: [PATCH 2/2] refactor(app): defer layout notification session lookups --- .../layout/layout-sdk-event-effects.test.ts | 143 +++++++++ .../pages/layout/layout-sdk-event-effects.ts | 278 ++++++++++-------- 2 files changed, 301 insertions(+), 120 deletions(-) diff --git a/packages/app/src/pages/layout/layout-sdk-event-effects.test.ts b/packages/app/src/pages/layout/layout-sdk-event-effects.test.ts index 5ec7f00c1..a812c9584 100644 --- a/packages/app/src/pages/layout/layout-sdk-event-effects.test.ts +++ b/packages/app/src/pages/layout/layout-sdk-event-effects.test.ts @@ -1,12 +1,114 @@ import { describe, expect, test } from "bun:test" import { + createSDKNotificationEventHandler, isCurrentOrDescendantSession, permissionSessionKey, questionCallKey, questionNotificationAction, + sessionNotificationHref, shouldThrottlePermissionAlert, } from "./layout-sdk-event-effects" +type TestEvent = Parameters>[0] + +function questionUpdatedEvent(directory: string, sessionID: string, partID = "prt_1"): TestEvent { + return { + name: directory, + details: { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: partID, + type: "tool", + tool: "question", + state: { status: "running", metadata: { externalResultReady: true } }, + }, + }, + }, + } as unknown as TestEvent +} + +function permissionAskedEvent(directory: string, sessionID: string): TestEvent { + return { + name: directory, + details: { + type: "permission.asked", + properties: { + id: "perm_1", + sessionID, + permission: "edit", + patterns: [], + metadata: {}, + always: [], + }, + }, + } as unknown as TestEvent +} + +function createSDKNotificationHarness(input?: { + currentSessionID?: string + agentNotifications?: boolean + permissionNotifications?: boolean + now?: () => number +}) { + let sessionsCalls = 0 + const notifications: Array<{ title: string; description?: string; href?: string }> = [] + const sessions = [ + { id: "ses_root", title: "Root session" }, + { id: "ses_child", parentID: "ses_root", title: "Child session" }, + { id: "ses_other", title: "Other session" }, + ] + + const handler = createSDKNotificationEventHandler({ + route: { + currentDirectory: () => "/repo", + currentSessionID: () => input?.currentSessionID, + sessionHref: sessionNotificationHref, + }, + sdk: { + sessions: () => { + sessionsCalls += 1 + return sessions + }, + }, + settings: { + notifications: { + agent: () => input?.agentNotifications ?? true, + permissions: () => input?.permissionNotifications ?? true, + }, + sounds: { + permissionsEnabled: () => false, + permissions: () => "default", + }, + }, + permission: { + autoResponds: () => false, + }, + effects: { + notify: (title, description, href) => { + notifications.push({ title, description, href }) + }, + playPermissionSound: () => undefined, + setBusy: () => undefined, + worktreeReady: () => undefined, + worktreeFailed: () => undefined, + }, + copy: { + t: (key, params) => `${key}:${params?.sessionTitle ?? ""}:${params?.projectName ?? ""}`, + }, + now: input?.now, + }) + + return { + emit(event: TestEvent) { + handler(event) + }, + notifications, + sessionsCalls: () => sessionsCalls, + } +} + describe("layout sdk event effects", () => { test("matches the current session in the active workspace", () => { expect( @@ -101,4 +203,45 @@ describe("layout sdk event effects", () => { expect(shouldThrottlePermissionAlert(1000, 6000, 5000)).toBe(false) expect(shouldThrottlePermissionAlert(undefined, 1000, 5000)).toBe(false) }) + + test("does not look up sessions for current-route question notifications", () => { + const hook = createSDKNotificationHarness({ currentSessionID: "ses_root" }) + hook.emit(questionUpdatedEvent("/repo", "ses_root")) + + expect(hook.sessionsCalls()).toBe(0) + expect(hook.notifications).toHaveLength(0) + }) + + test("does not look up sessions when question notifications are disabled", () => { + const hook = createSDKNotificationHarness({ agentNotifications: false }) + hook.emit(questionUpdatedEvent("/repo", "ses_other")) + + expect(hook.sessionsCalls()).toBe(0) + expect(hook.notifications).toHaveLength(0) + }) + + test("does not look up permission title while cooldown applies", () => { + let time = 1000 + const hook = createSDKNotificationHarness({ now: () => time }) + hook.emit(permissionAskedEvent("/repo", "ses_other")) + time = 2000 + hook.emit(permissionAskedEvent("/repo", "ses_other")) + + expect(hook.sessionsCalls()).toBe(1) + expect(hook.notifications).toHaveLength(1) + }) + + test("reuses one session snapshot when notifying for a question", () => { + const hook = createSDKNotificationHarness({ currentSessionID: "ses_root" }) + hook.emit(questionUpdatedEvent("/repo", "ses_other")) + + expect(hook.sessionsCalls()).toBe(1) + expect(hook.notifications).toEqual([ + { + title: "notification.question.title::", + description: "notification.question.description:Other session:repo", + href: sessionNotificationHref("/repo", "ses_other"), + }, + ]) + }) }) diff --git a/packages/app/src/pages/layout/layout-sdk-event-effects.ts b/packages/app/src/pages/layout/layout-sdk-event-effects.ts index 0ef8de619..ee15b561f 100644 --- a/packages/app/src/pages/layout/layout-sdk-event-effects.ts +++ b/packages/app/src/pages/layout/layout-sdk-event-effects.ts @@ -33,6 +33,46 @@ type QuestionNotificationPart = Pick & { } } +type LayoutSdkEventEffectsInput = { + route: { + currentDirectory: () => string + currentSessionID: () => string | undefined + sessionHref: (directory: string, sessionID: string) => string + } + sdk: { + sessions: (directory: string) => readonly LayoutSession[] + } + settings: { + notifications: { + agent: () => boolean + permissions: () => boolean + } + sounds: { + permissionsEnabled: () => boolean + permissions: () => string + } + } + permission: { + autoResponds: (request: PermissionRequest, directory: string) => boolean + } + effects: { + notify: (title: string, description?: string, href?: string) => Promise | void + playPermissionSound: (soundID: string) => unknown + setBusy: (directory: string, value: boolean) => void + worktreeReady: (directory: string) => void + worktreeFailed: (directory: string, message: string) => void + } + copy: LayoutSdkEventCopy + cooldownMs?: number + now?: () => number +} + +type LayoutSdkEventHookInput = Omit & { + sdk: LayoutSdkEventEffectsInput["sdk"] & { + listen: (handler: (event: LayoutSdkEvent) => void) => () => void + } +} + export function permissionSessionKey(directory: string, sessionID: string) { return `${directory}:${sessionID}` } @@ -87,139 +127,137 @@ export function isCurrentOrDescendantSession(input: { return false } -export function useSDKNotificationToasts(input: { - route: { - currentDirectory: () => string - currentSessionID: () => string | undefined - sessionHref: (directory: string, sessionID: string) => string - } - sdk: { - listen: (handler: (event: LayoutSdkEvent) => void) => () => void - sessions: (directory: string) => readonly LayoutSession[] +function currentRouteVisibility( + input: LayoutSdkEventEffectsInput, + directory: string, + sessionID: string, +): { visible: boolean; sessions?: readonly LayoutSession[] } { + const currentSessionID = input.route.currentSessionID() + if (!currentSessionID) return { visible: false } + + const currentDirectory = input.route.currentDirectory() + if (workspaceKey(directory) !== workspaceKey(currentDirectory)) return { visible: false } + if (sessionID === currentSessionID) return { visible: true } + + const sessions = input.sdk.sessions(directory) + return { + visible: isCurrentOrDescendantSession({ + directory, + sessionID, + currentDirectory, + currentSessionID, + sessions, + }), + sessions, } - settings: { - notifications: { - agent: () => boolean - permissions: () => boolean +} + +function titleFromSessions( + sessions: readonly LayoutSession[] | undefined, + sessionID: string, + fallback: string, +) { + return sessions?.find((item) => item.id === sessionID)?.title ?? fallback +} + +export function createSDKNotificationEventHandler(input: LayoutSdkEventEffectsInput) { + const alertedAtBySession = new Map() + const alertedQuestionCalls = new Set() + const cooldownMs = input.cooldownMs ?? 5000 + const now = input.now ?? Date.now + + return (event: LayoutSdkEvent) => { + const details = event.details + if (!details) return + + if (details.type === "worktree.ready") { + input.effects.setBusy(event.name, false) + input.effects.worktreeReady(event.name) + return } - sounds: { - permissionsEnabled: () => boolean - permissions: () => string + + if (details.type === "worktree.failed") { + input.effects.setBusy(event.name, false) + input.effects.worktreeFailed(event.name, details.properties?.message ?? input.copy.t("common.requestFailed")) + return } - } - permission: { - autoResponds: (request: PermissionRequest, directory: string) => boolean - } - effects: { - notify: (title: string, description?: string, href?: string) => Promise | void - playPermissionSound: (soundID: string) => unknown - setBusy: (directory: string, value: boolean) => void - worktreeReady: (directory: string) => void - worktreeFailed: (directory: string, message: string) => void - } - copy: LayoutSdkEventCopy - cooldownMs?: number - now?: () => number -}) { - onMount(() => { - const alertedAtBySession = new Map() - const alertedQuestionCalls = new Set() - const cooldownMs = input.cooldownMs ?? 5000 - const now = input.now ?? Date.now - - const isVisibleInCurrentRoute = (directory: string, sessionID: string) => - isCurrentOrDescendantSession({ - directory, - sessionID, - currentDirectory: input.route.currentDirectory(), - currentSessionID: input.route.currentSessionID(), - sessions: input.sdk.sessions(directory), - }) - - const unsub = input.sdk.listen((event) => { - const details = event.details - if (!details) return - - if (details.type === "worktree.ready") { - input.effects.setBusy(event.name, false) - input.effects.worktreeReady(event.name) - return - } - if (details.type === "worktree.failed") { - input.effects.setBusy(event.name, false) - input.effects.worktreeFailed(event.name, details.properties?.message ?? input.copy.t("common.requestFailed")) - return - } + if (details.type === "permission.replied") { + alertedAtBySession.delete(permissionSessionKey(event.name, details.properties.sessionID)) + return + } - if (details.type === "permission.replied") { - alertedAtBySession.delete(permissionSessionKey(event.name, details.properties.sessionID)) - return - } + if (details.type === "message.part.updated") { + const directory = event.name + const { sessionID, part } = details.properties + const action = questionNotificationAction(part) + if (action === "ignore") return - if (details.type === "message.part.updated") { - const directory = event.name - const { sessionID, part } = details.properties - const action = questionNotificationAction(part) - if (action === "ignore") return - - const callKey = questionCallKey(directory, sessionID, part.id) - if (action === "reset") { - alertedQuestionCalls.delete(callKey) - return - } - - if (alertedQuestionCalls.has(callKey)) return - alertedQuestionCalls.add(callKey) - - const session = input.sdk.sessions(directory).find((item) => item.id === sessionID) - if (isVisibleInCurrentRoute(directory, sessionID)) return - - if (!input.settings.notifications.agent()) return - const sessionTitle = session?.title ?? input.copy.t("command.session.new") - const projectName = getFilename(directory) - void input.effects.notify( - input.copy.t("notification.question.title"), - input.copy.t("notification.question.description", { sessionTitle, projectName }), - input.route.sessionHref(directory, sessionID), - ) + const callKey = questionCallKey(directory, sessionID, part.id) + if (action === "reset") { + alertedQuestionCalls.delete(callKey) return } - if (details.type === "message.part.removed") { - const { sessionID, partID } = details.properties - alertedQuestionCalls.delete(questionCallKey(event.name, sessionID, partID)) - return - } + if (alertedQuestionCalls.has(callKey)) return + alertedQuestionCalls.add(callKey) - if (details.type !== "permission.asked") return - const directory = event.name - const props = details.properties - if (input.permission.autoResponds(props, directory)) return + if (!input.settings.notifications.agent()) return - const session = input.sdk.sessions(directory).find((item) => item.id === props.sessionID) - const sessionKey = permissionSessionKey(directory, props.sessionID) + const visibility = currentRouteVisibility(input, directory, sessionID) + if (visibility.visible) return - const lastAlerted = alertedAtBySession.get(sessionKey) - const currentTime = now() - if (shouldThrottlePermissionAlert(lastAlerted, currentTime, cooldownMs)) return - alertedAtBySession.set(sessionKey, currentTime) + const sessions = visibility.sessions ?? input.sdk.sessions(directory) + const sessionTitle = titleFromSessions(sessions, sessionID, input.copy.t("command.session.new")) + const projectName = getFilename(directory) + void input.effects.notify( + input.copy.t("notification.question.title"), + input.copy.t("notification.question.description", { sessionTitle, projectName }), + input.route.sessionHref(directory, sessionID), + ) + return + } - if (input.settings.sounds.permissionsEnabled()) { - void input.effects.playPermissionSound(input.settings.sounds.permissions()) - } - if (input.settings.notifications.permissions()) { - if (!isVisibleInCurrentRoute(directory, props.sessionID)) { - const sessionTitle = session?.title ?? input.copy.t("command.session.new") - const projectName = getFilename(directory) - void input.effects.notify( - input.copy.t("notification.permission.title"), - input.copy.t("notification.permission.description", { sessionTitle, projectName }), - input.route.sessionHref(directory, props.sessionID), - ) - } - } - }) + if (details.type === "message.part.removed") { + const { sessionID, partID } = details.properties + alertedQuestionCalls.delete(questionCallKey(event.name, sessionID, partID)) + return + } + + if (details.type !== "permission.asked") return + const directory = event.name + const props = details.properties + if (input.permission.autoResponds(props, directory)) return + + const sessionKey = permissionSessionKey(directory, props.sessionID) + + const lastAlerted = alertedAtBySession.get(sessionKey) + const currentTime = now() + if (shouldThrottlePermissionAlert(lastAlerted, currentTime, cooldownMs)) return + alertedAtBySession.set(sessionKey, currentTime) + + if (input.settings.sounds.permissionsEnabled()) { + void input.effects.playPermissionSound(input.settings.sounds.permissions()) + } + if (!input.settings.notifications.permissions()) return + + const visibility = currentRouteVisibility(input, directory, props.sessionID) + if (visibility.visible) return + + const sessions = visibility.sessions ?? input.sdk.sessions(directory) + const sessionTitle = titleFromSessions(sessions, props.sessionID, input.copy.t("command.session.new")) + const projectName = getFilename(directory) + void input.effects.notify( + input.copy.t("notification.permission.title"), + input.copy.t("notification.permission.description", { sessionTitle, projectName }), + input.route.sessionHref(directory, props.sessionID), + ) + } +} + +export function useSDKNotificationToasts(input: LayoutSdkEventHookInput) { + onMount(() => { + const unsub = input.sdk.listen(createSDKNotificationEventHandler(input)) onCleanup(unsub) }) }