diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f1086e9c29..199ba4bf4e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -49,6 +49,7 @@ syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; +const REQUEST_USER_ATTENTION_CHANNEL = "desktop:request-user-attention"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; @@ -1150,6 +1151,9 @@ function registerIpcHandlers(): void { return showDesktopConfirmDialog(message, owner); }); + ipcMain.removeHandler(REQUEST_USER_ATTENTION_CHANNEL); + ipcMain.handle(REQUEST_USER_ATTENTION_CHANNEL, async () => requestUserAttention()); + ipcMain.removeHandler(SET_THEME_CHANNEL); ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { const theme = getSafeTheme(rawTheme); @@ -1291,6 +1295,20 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function requestUserAttention(): boolean { + if (process.platform !== "darwin" || !app.dock) { + return false; + } + + const anyFocusedWindow = BrowserWindow.getAllWindows().some((window) => window.isFocused()); + if (anyFocusedWindow) { + return false; + } + + app.dock.bounce("informational"); + return true; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..d7550aec31 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,6 +3,7 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; +const REQUEST_USER_ATTENTION_CHANNEL = "desktop:request-user-attention"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; @@ -21,6 +22,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), + requestUserAttention: () => ipcRenderer.invoke(REQUEST_USER_ATTENTION_CHANNEL), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f6..01650dc88b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -49,7 +49,7 @@ import { import { ensureNativeApi, readNativeApi } from "../../nativeApi"; import { useStore } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; -import { cn } from "../../lib/utils"; +import { cn, isMacPlatform } from "../../lib/utils"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; @@ -475,6 +475,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.dockBounceOnCompletion !== DEFAULT_UNIFIED_SETTINGS.dockBounceOnCompletion + ? ["Completion dock bounce"] + : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), ], @@ -483,6 +486,7 @@ export function useSettingsRestore(onRestored?: () => void) { isGitWritingModelDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, + settings.dockBounceOnCompletion, settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, @@ -516,6 +520,7 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); + const isMacDesktop = isElectron && isMacPlatform(navigator.platform); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); @@ -958,6 +963,35 @@ export function GeneralSettingsPanel() { } /> + {isMacDesktop ? ( + + updateSettings({ + dockBounceOnCompletion: DEFAULT_UNIFIED_SETTINGS.dockBounceOnCompletion, + }) + } + /> + ) : null + } + control={ + + updateSettings({ dockBounceOnCompletion: Boolean(checked) }) + } + aria-label="Bounce the macOS Dock icon when a thread completes" + /> + } + /> + ) : null} + { buildLegacyClientSettingsMigrationPatch({ confirmThreadArchive: true, confirmThreadDelete: false, + dockBounceOnCompletion: false, }), ).toEqual({ confirmThreadArchive: true, confirmThreadDelete: false, + dockBounceOnCompletion: false, }); }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3f804bc48b..cbc42a87db 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -70,9 +70,7 @@ function splitPatch(patch: Partial): { * only re-render when the slice they care about changes. */ -export function useSettings( - selector?: (s: UnifiedSettings) => T, -): T { +export function useSettings(selector?: (s: UnifiedSettings) => T): T { const { data: serverConfig } = useQuery(serverConfigQueryOptions()); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, @@ -210,6 +208,10 @@ export function buildLegacyClientSettingsMigrationPatch( patch.confirmThreadDelete = legacySettings.confirmThreadDelete; } + if (Predicate.isBoolean(legacySettings.dockBounceOnCompletion)) { + patch.dockBounceOnCompletion = legacySettings.dockBounceOnCompletion; + } + if (Predicate.isBoolean(legacySettings.diffWordWrap)) { patch.diffWordWrap = legacySettings.diffWordWrap; } diff --git a/apps/web/src/lib/desktopCompletionAttention.test.ts b/apps/web/src/lib/desktopCompletionAttention.test.ts new file mode 100644 index 0000000000..b5121c00b2 --- /dev/null +++ b/apps/web/src/lib/desktopCompletionAttention.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; + +import { + getCompletionAttentionState, + getCompletionAttentionTurnId, + shouldRequestCompletionAttention, + updateCompletionAttentionNotification, +} from "./desktopCompletionAttention"; +import type { Thread } from "../types"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; + +function makeThread(overrides?: Partial>): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-30T10:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-30T10:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +function makeRunningThread(): Thread { + return makeThread({ + session: { + provider: "codex", + status: "running", + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:01.000Z", + }, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "running", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); +} + +describe("shouldRequestCompletionAttention", () => { + it("requests attention when a working thread completes", () => { + const previous = getCompletionAttentionState(makeRunningThread()); + const next = getCompletionAttentionState( + makeThread({ + session: { + provider: "codex", + status: "ready", + orchestrationStatus: "ready", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:04.000Z", + }, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:04.000Z", + assistantMessageId: null, + }, + }), + ); + + expect(shouldRequestCompletionAttention(previous, next)).toBe(true); + }); + + it("does not request attention on initial hydration of completed threads", () => { + const next = getCompletionAttentionState( + makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:04.000Z", + assistantMessageId: null, + }, + }), + ); + + expect(shouldRequestCompletionAttention(undefined, next)).toBe(false); + }); + + it("does not request attention for repeated completed snapshots", () => { + const completedThread = getCompletionAttentionState( + makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:04.000Z", + assistantMessageId: null, + }, + }), + ); + + expect(shouldRequestCompletionAttention(completedThread, completedThread)).toBe(false); + }); + + it("does not request attention when a turn errors", () => { + const previous = getCompletionAttentionState(makeRunningThread()); + const next = getCompletionAttentionState( + makeThread({ + session: { + provider: "codex", + status: "error", + orchestrationStatus: "error", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:04.000Z", + lastError: "boom", + }, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "error", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:04.000Z", + assistantMessageId: null, + }, + }), + ); + + expect(shouldRequestCompletionAttention(previous, next)).toBe(false); + }); + + it("requests attention when a running session becomes ready without a latest completed turn", () => { + const previous = getCompletionAttentionState(makeRunningThread()); + const next = getCompletionAttentionState( + makeThread({ + session: { + provider: "claudeAgent", + status: "ready", + orchestrationStatus: "ready", + activeTurnId: undefined, + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:04.000Z", + }, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "running", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }), + ); + + expect(shouldRequestCompletionAttention(previous, next)).toBe(true); + }); + + it("returns the same attention turn id for a session-ready lag and later turn completion", () => { + const running = getCompletionAttentionState(makeRunningThread()); + const sessionReady = getCompletionAttentionState( + makeThread({ + session: { + provider: "claudeAgent", + status: "ready", + orchestrationStatus: "ready", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:03.000Z", + }, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "running", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }), + ); + const turnCompleted = getCompletionAttentionState( + makeThread({ + session: { + provider: "claudeAgent", + status: "ready", + orchestrationStatus: "ready", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:04.000Z", + }, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:04.000Z", + assistantMessageId: null, + }, + }), + ); + + expect(getCompletionAttentionTurnId(running, sessionReady)).toBe("turn-1"); + expect(getCompletionAttentionTurnId(sessionReady, turnCompleted)).toBe("turn-1"); + }); + + it("records later attention turn ids even after an earlier thread already triggered a bounce", () => { + const notifiedTurnIds = new Map(); + let shouldBounce = false; + + const firstThreadShouldNotify = updateCompletionAttentionNotification( + notifiedTurnIds, + "thread-1", + undefined, + "turn-1", + ); + if (!shouldBounce && firstThreadShouldNotify) { + shouldBounce = true; + } + + const secondThreadShouldNotify = updateCompletionAttentionNotification( + notifiedTurnIds, + "thread-2", + undefined, + "turn-2", + ); + if (!shouldBounce && secondThreadShouldNotify) { + shouldBounce = true; + } + + expect(shouldBounce).toBe(true); + expect(notifiedTurnIds.get("thread-1")).toBe("turn-1"); + expect(notifiedTurnIds.get("thread-2")).toBe("turn-2"); + }); +}); diff --git a/apps/web/src/lib/desktopCompletionAttention.ts b/apps/web/src/lib/desktopCompletionAttention.ts new file mode 100644 index 0000000000..f84a1c8430 --- /dev/null +++ b/apps/web/src/lib/desktopCompletionAttention.ts @@ -0,0 +1,81 @@ +import type { OrchestrationLatestTurnState, OrchestrationSessionStatus } from "@t3tools/contracts"; +import type { Thread } from "../types"; + +export interface CompletionAttentionState { + activeTurnId: string | null; + completedAt: string | null; + isWorking: boolean; + lastError: string | null; + latestTurnId: string | null; + latestTurnState: OrchestrationLatestTurnState | null; + sessionStatus: OrchestrationSessionStatus | null; +} + +export function getCompletionAttentionState( + thread: Pick | undefined, +): CompletionAttentionState { + return { + activeTurnId: thread?.session?.activeTurnId ?? null, + completedAt: thread?.latestTurn?.completedAt ?? null, + isWorking: + thread?.session?.orchestrationStatus === "starting" || + thread?.session?.orchestrationStatus === "running" || + thread?.latestTurn?.state === "running", + lastError: thread?.session?.lastError ?? null, + latestTurnId: thread?.latestTurn?.turnId ?? null, + latestTurnState: thread?.latestTurn?.state ?? null, + sessionStatus: thread?.session?.orchestrationStatus ?? null, + }; +} + +export function getCompletionAttentionTurnId( + previous: CompletionAttentionState | undefined, + next: CompletionAttentionState, +): string | null { + const completedTurnTransition = + next.latestTurnId !== null && + next.latestTurnState === "completed" && + next.completedAt !== null && + previous?.completedAt !== next.completedAt && + previous?.isWorking === true && + !next.isWorking; + + const sessionReadyTransition = + previous?.isWorking === true && + previous.activeTurnId !== null && + next.sessionStatus === "ready" && + next.activeTurnId === null && + next.lastError === null; + + if (completedTurnTransition) { + return next.latestTurnId; + } + if (sessionReadyTransition) { + return previous.activeTurnId; + } + return null; +} + +export function updateCompletionAttentionNotification( + notifications: Map, + threadId: string, + lastNotifiedTurnId: string | undefined, + attentionTurnId: string | null, +): boolean { + if (attentionTurnId === null) { + if (lastNotifiedTurnId) { + notifications.set(threadId, lastNotifiedTurnId); + } + return false; + } + + notifications.set(threadId, attentionTurnId); + return attentionTurnId !== lastNotifiedTurnId; +} + +export function shouldRequestCompletionAttention( + previous: CompletionAttentionState | undefined, + next: CompletionAttentionState, +): boolean { + return getCompletionAttentionTurnId(previous, next) !== null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e99ec50226..f82484819c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -15,6 +15,14 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { isElectron } from "../env"; +import { useSettings } from "../hooks/useSettings"; +import { + getCompletionAttentionState, + getCompletionAttentionTurnId, + updateCompletionAttentionNotification, +} from "../lib/desktopCompletionAttention"; +import { isMacPlatform } from "../lib/utils"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; @@ -54,6 +62,7 @@ function RootRouteView() { + @@ -63,6 +72,52 @@ function RootRouteView() { ); } +function DesktopCompletionAttention() { + const threads = useStore((store) => store.threads); + const dockBounceOnCompletion = useSettings((settings) => settings.dockBounceOnCompletion); + const previousStatesRef = useRef>>( + new Map(), + ); + const notifiedTurnIdsRef = useRef>(new Map()); + + useEffect(() => { + const previousStates = previousStatesRef.current; + const previousNotifiedTurnIds = notifiedTurnIdsRef.current; + const nextStates = new Map>(); + const nextNotifiedTurnIds = new Map(); + let shouldBounce = false; + for (const thread of threads) { + const nextState = getCompletionAttentionState(thread); + nextStates.set(thread.id, nextState); + const previousState = previousStates.get(thread.id); + const attentionTurnId = getCompletionAttentionTurnId(previousState, nextState); + const lastNotifiedTurnId = previousNotifiedTurnIds.get(thread.id); + const shouldNotifyThread = updateCompletionAttentionNotification( + nextNotifiedTurnIds, + thread.id, + lastNotifiedTurnId, + attentionTurnId, + ); + if (!shouldBounce && shouldNotifyThread) { + shouldBounce = true; + } + } + previousStatesRef.current = nextStates; + notifiedTurnIdsRef.current = nextNotifiedTurnIds; + + if (!dockBounceOnCompletion || !isElectron || !isMacPlatform(navigator.platform)) { + return; + } + if (!shouldBounce) { + return; + } + + void window.desktopBridge?.requestUserAttention?.(); + }, [dockBounceOnCompletion, threads]); + + return null; +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5585e7f309..262127bcbd 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -110,6 +110,7 @@ export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; confirm: (message: string) => Promise; + requestUserAttention: () => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fe683f99..b3cb149f20 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -26,6 +26,7 @@ export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "update export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + dockBounceOnCompletion: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER),