diff --git a/apps/desktop/src/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts new file mode 100644 index 00000000000..48828ac6415 --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.test.ts @@ -0,0 +1,76 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +const { notificationInstances, notificationIsSupported } = vi.hoisted(() => ({ + notificationInstances: [] as Array<{ + options: unknown; + listeners: Map void>; + show: ReturnType; + }>, + notificationIsSupported: vi.fn(() => true), +})); + +vi.mock("electron", () => ({ + Notification: class Notification { + static isSupported = notificationIsSupported; + readonly listeners = new Map void>(); + readonly show = vi.fn(); + readonly options: unknown; + + constructor(options: unknown) { + this.options = options; + notificationInstances.push(this); + } + + once(event: string, listener: () => void) { + this.listeners.set(event, listener); + return this; + } + }, +})); + +import * as ElectronNotification from "./ElectronNotification.ts"; + +describe("ElectronNotification", () => { + it.effect("shows a silent native notification on macOS and handles its click", () => { + const onClick = vi.fn(); + notificationInstances.length = 0; + + return Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + assert.isTrue( + yield* notifications.show({ title: "Thread finished", body: "Refactor", onClick }), + ); + const instance = notificationInstances[0]!; + assert.deepEqual(instance.options, { + title: "Thread finished", + body: "Refactor", + silent: true, + }); + assert.equal(instance.show.mock.calls.length, 1); + + instance.listeners.get("click")?.(); + assert.equal(onClick.mock.calls.length, 1); + }).pipe( + Effect.provide(ElectronNotification.layer), + Effect.provide(Layer.succeed(HostProcessPlatform, "darwin")), + ); + }); + + it.effect("does nothing outside macOS", () => { + notificationInstances.length = 0; + return Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + assert.isFalse( + yield* notifications.show({ title: "Thread finished", body: "Refactor", onClick: vi.fn() }), + ); + assert.equal(notificationInstances.length, 0); + }).pipe( + Effect.provide(ElectronNotification.layer), + Effect.provide(Layer.succeed(HostProcessPlatform, "linux")), + ); + }); +}); diff --git a/apps/desktop/src/electron/ElectronNotification.ts b/apps/desktop/src/electron/ElectronNotification.ts new file mode 100644 index 00000000000..26a73e834cc --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.ts @@ -0,0 +1,56 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +export interface ElectronNotificationInput { + readonly title: string; + readonly body: string; + readonly onClick: () => void; +} + +export class ElectronNotification extends Context.Service< + ElectronNotification, + { + readonly show: (input: ElectronNotificationInput) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronNotification") {} + +export const layer = Layer.effect( + ElectronNotification, + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const activeNotifications = new Set(); + + return ElectronNotification.of({ + show: (input) => + Effect.sync(() => { + if (platform !== "darwin" || !Electron.Notification.isSupported()) { + return false; + } + + try { + const notification = new Electron.Notification({ + title: input.title, + body: input.body, + silent: true, + }); + const release = () => activeNotifications.delete(notification); + notification.once("click", () => { + input.onClick(); + release(); + }); + notification.once("close", release); + notification.once("failed", release); + activeNotifications.add(notification); + notification.show(); + return true; + } catch { + return false; + } + }), + }); + }), +); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..f1f88f965d4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,6 +40,7 @@ import { pickFolder, setTheme, showContextMenu, + showThreadCompletionNotification, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(showThreadCompletionNotification); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0d5c79cf55a..b7c5526d854 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,10 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const SHOW_THREAD_COMPLETION_NOTIFICATION_CHANNEL = + "desktop:show-thread-completion-notification"; +export const THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL = + "desktop:thread-completion-notification-click"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d3956..983637b67bb 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -2,13 +2,20 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; +import * as ElectronNotification from "../../electron/ElectronNotification.ts"; +import { THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL } from "../channels.ts"; +import { + getLocalEnvironmentBootstraps, + getWindowFullscreenState, + showThreadCompletionNotification, +} from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -146,3 +153,57 @@ describe("getWindowFullscreenState", () => { ); }); }); + +describe("showThreadCompletionNotification", () => { + it.effect( + "reveals the app and forwards the scoped thread when the notification is clicked", + () => { + const send = vi.fn(); + const reveal = vi.fn(() => Effect.void); + const window = { + webContents: { send }, + } as unknown as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.isTrue( + yield* showThreadCompletionNotification.handler({ + threadRef: { + environmentId: "environment-1", + threadId: "thread-1", + }, + threadTitle: "Refactor notifications", + }), + ); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(reveal.mock.calls.length, 1); + assert.deepEqual(send.mock.calls, [ + [ + THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL, + { environmentId: "environment-1", threadId: "thread-1" }, + ], + ]); + }), + ); + }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ElectronNotification.ElectronNotification)({ + show: (input) => + Effect.sync(() => { + assert.equal(input.title, "Thread finished"); + assert.equal(input.body, "Refactor notifications"); + input.onClick(); + return true; + }), + }), + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + reveal, + }), + ), + ), + ); + }, + ); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..3d13ab1ec8e 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -2,6 +2,7 @@ import { ContextMenuItemSchema, DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, + DesktopThreadCompletionNotificationInput, DesktopThemeSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, @@ -19,6 +20,7 @@ import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; +import * as ElectronNotification from "../../electron/ElectronNotification.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; @@ -268,3 +270,32 @@ export const openExternal = DesktopIpc.makeIpcMethod({ return yield* shell.openExternal(url); }), }); + +export const showThreadCompletionNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SHOW_THREAD_COMPLETION_NOTIFICATION_CHANNEL, + payload: DesktopThreadCompletionNotificationInput, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.showThreadCompletionNotification")(function* (input) { + const notifications = yield* ElectronNotification.ElectronNotification; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const openThread = Effect.gen(function* () { + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(window)) return; + yield* electronWindow.reveal(window.value); + yield* Effect.sync(() => { + window.value.webContents.send( + IpcChannels.THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL, + input.threadRef, + ); + }); + }); + + return yield* notifications.show({ + title: "Thread finished", + body: input.threadTitle, + onClick: () => { + Effect.runFork(openThread); + }, + }); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7a51700e0fd..ec2440579dd 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,6 +18,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronNotification from "./electron/ElectronNotification.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; @@ -107,6 +108,7 @@ const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, ElectronMenu.layer, + ElectronNotification.layer, ElectronProtocol.layer, ElectronSafeStorage.layer, ElectronShell.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f92cfcf2fa..b6912e49e4a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,22 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + showThreadCompletionNotification: (input) => + ipcRenderer.invoke(IpcChannels.SHOW_THREAD_COMPLETION_NOTIFICATION_CHANNEL, input), + onThreadCompletionNotificationClick: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, threadRef: unknown) => { + if (typeof threadRef !== "object" || threadRef === null) return; + listener(threadRef as Parameters[0]); + }; + + ipcRenderer.on(IpcChannels.THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener( + IpcChannels.THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL, + wrappedListener, + ); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 2f5608a895a..ed0c24f3c28 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -26,6 +26,7 @@ import { orderItemsByPreferredIds, orderSidebarThreadsByWorktree, resolveSidebarWorktreeThreadGroups, + resolveSidebarWorktreeNewThreadOptions, resolveAdjacentThreadId, resolveProjectStatusIndicator, resolveProjectTitleClassName, @@ -245,6 +246,35 @@ describe("groupSidebarThreadsByWorktree", () => { }); }); +describe("resolveSidebarWorktreeNewThreadOptions", () => { + it("opens an exact existing worktree when navigation is enabled", () => { + expect( + resolveSidebarWorktreeNewThreadOptions({ + enableSidebarWorktreeNavigation: true, + branch: "feature/exact-checkout", + worktreePath: "/repo/.t3/worktrees/exact-checkout", + isMainCheckout: false, + }), + ).toEqual({ + branch: "feature/exact-checkout", + worktreePath: "/repo/.t3/worktrees/exact-checkout", + envMode: "worktree", + startFromOrigin: false, + }); + }); + + it("preserves upstream non-actionable worktree labels when navigation is disabled", () => { + expect( + resolveSidebarWorktreeNewThreadOptions({ + enableSidebarWorktreeNavigation: false, + branch: "feature/exact-checkout", + worktreePath: "/repo/.t3/worktrees/exact-checkout", + isMainCheckout: false, + }), + ).toBeNull(); + }); +}); + describe("orderSidebarThreadsByWorktree", () => { it("matches the visual grouped-row order for interleaved worktrees", () => { const first = { @@ -939,6 +969,7 @@ describe("resolveThreadRowClassName", () => { expect(className).toContain("bg-primary/15"); expect(className).toContain("hover:bg-primary/19"); expect(className).toContain("dark:bg-primary/22"); + expect(className).toContain("font-medium"); expect(className).not.toContain("hover:bg-accent"); }); @@ -969,6 +1000,7 @@ describe("resolveThreadRowClassName", () => { hasUnseenCompletion: true, }); expect(className).toContain("text-foreground"); + expect(className).toContain("font-medium"); expect(className).not.toContain("text-muted-foreground/55"); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index fdd739fa7d9..49b9f716c0b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -18,6 +18,29 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export interface SidebarWorktreeNewThreadOptions { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly envMode: SidebarNewThreadEnvMode; + readonly startFromOrigin: false; +} + +export function resolveSidebarWorktreeNewThreadOptions(input: { + readonly enableSidebarWorktreeNavigation: boolean; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly isMainCheckout: boolean; +}): SidebarWorktreeNewThreadOptions | null { + if (!input.enableSidebarWorktreeNavigation) return null; + + return { + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.isMainCheckout ? "local" : "worktree", + startFromOrigin: false, + }; +} + export interface SidebarWorktreeThreadGroup { readonly key: string; readonly label: string; @@ -479,7 +502,7 @@ export function resolveThreadRowClassName(input: { if (input.isSelected) { return cn( baseClassName, - "bg-primary/15 text-muted-foreground/55 hover:bg-primary/19 hover:text-muted-foreground/70 dark:bg-primary/22 dark:hover:bg-primary/28", + "bg-primary/15 text-muted-foreground/55 font-medium hover:bg-primary/19 hover:text-muted-foreground/70 dark:bg-primary/22 dark:hover:bg-primary/28", ); } @@ -491,7 +514,7 @@ export function resolveThreadRowClassName(input: { } if (input.hasUnseenCompletion) { - return cn(baseClassName, "text-foreground hover:bg-accent hover:text-foreground"); + return cn(baseClassName, "text-foreground font-medium hover:bg-accent hover:text-foreground"); } return cn( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fac9f6ed757..f10b69d5410 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -162,6 +162,7 @@ import { resolveProjectStatusIndicator, resolveProjectTitleClassName, resolveSidebarStageBadgeLabel, + resolveSidebarWorktreeNewThreadOptions, resolveSidebarWorktreeThreadGroups, resolveThreadRowClassName, resolveThreadStatusPill, @@ -716,6 +717,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr isActive={isActive} data-testid={`thread-row-${thread.id}`} data-finished={threadStatus?.label === "Completed"} + data-selected={isSelected} className={`${resolveThreadRowClassName({ isActive, isSelected, @@ -1161,6 +1163,22 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( }, [refreshVcsStatus, removeWorktree, resolveWorktreeGroupContext], ); + const startThreadInWorktreeGroup = useCallback( + (group: (typeof renderedThreadGroups)[number]) => { + const context = resolveWorktreeGroupContext(group); + if (!context) return; + const options = resolveSidebarWorktreeNewThreadOptions({ + enableSidebarWorktreeNavigation, + branch: context.branch, + worktreePath: context.worktreePath, + isMainCheckout: context.isMainCheckout, + }); + if (!options) return; + + void handleNewThread(scopeProjectRef(context.environmentId, context.projectId), options); + }, + [enableSidebarWorktreeNavigation, handleNewThread, resolveWorktreeGroupContext], + ); const handleWorktreeGroupMenu = useCallback( (group: (typeof renderedThreadGroups)[number], position: { x: number; y: number }) => { const context = resolveWorktreeGroupContext(group); @@ -1183,12 +1201,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( position, ); if (clicked === "new-chat") { - void handleNewThread(scopeProjectRef(context.environmentId, context.projectId), { - branch: context.branch, - worktreePath: context.worktreePath, - envMode: group.label === "Main" ? "local" : "worktree", - startFromOrigin: false, - }); + startThreadInWorktreeGroup(group); return; } if (clicked === "rename-branch" && context.worktreePath && context.branch) { @@ -1206,7 +1219,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( } })(); }, - [archiveWorktreeGroup, handleNewThread, resolveWorktreeGroupContext], + [archiveWorktreeGroup, resolveWorktreeGroupContext, startThreadInWorktreeGroup], ); const submitBranchRename = useCallback(async () => { const target = branchRenameTarget; @@ -1339,7 +1352,18 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( {enableSidebarWorktreeNavigation ? (
{ + startThreadInWorktreeGroup(group); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + startThreadInWorktreeGroup(group); + }} onContextMenu={(event) => { event.preventDefault(); handleWorktreeGroupMenu(group, { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fefdb8a02df..db88314db5a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -114,6 +114,7 @@ type PersonalFeatureFlagName = Extract< keyof UnifiedSettings, | "enableStandaloneChats" | "enableNativeMacSidebar" + | "enableMacosCompletionNotifications" | "enableSidebarWorktreeNavigation" | "enableCheckoutAwareThreadCreation" | "enableCompletionSounds" @@ -141,6 +142,11 @@ const PERSONAL_FEATURE_SETTINGS = [ title: "Native macOS sidebar", description: "Use the wider, spacious macOS-inspired sidebar presentation.", }, + { + key: "enableMacosCompletionNotifications", + title: "macOS completion notifications", + description: "Show a native macOS notification when a thread finishes.", + }, { key: "enableSidebarWorktreeNavigation", title: "Sidebar worktree navigation", @@ -527,6 +533,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.wordWrap, settings.enableStandaloneChats, settings.enableNativeMacSidebar, + settings.enableMacosCompletionNotifications, settings.enableSidebarWorktreeNavigation, settings.enableCheckoutAwareThreadCreation, settings.enableForkPullRequests, @@ -567,6 +574,8 @@ export function useSettingsRestore(onRestored?: () => void) { textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, enableStandaloneChats: DEFAULT_UNIFIED_SETTINGS.enableStandaloneChats, enableNativeMacSidebar: DEFAULT_UNIFIED_SETTINGS.enableNativeMacSidebar, + enableMacosCompletionNotifications: + DEFAULT_UNIFIED_SETTINGS.enableMacosCompletionNotifications, enableSidebarWorktreeNavigation: DEFAULT_UNIFIED_SETTINGS.enableSidebarWorktreeNavigation, enableCheckoutAwareThreadCreation: DEFAULT_UNIFIED_SETTINGS.enableCheckoutAwareThreadCreation, enableForkPullRequests: DEFAULT_UNIFIED_SETTINGS.enableForkPullRequests, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8b76cf42d08..1e2dc854cbc 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -575,6 +575,14 @@ body { line-height: 20px; } +.native-macos-sidebar .native-sidebar-thread-row[data-active="true"] .native-sidebar-thread-title, +.native-macos-sidebar .native-sidebar-thread-row[data-selected="true"] .native-sidebar-thread-title, +.native-macos-sidebar + .native-sidebar-thread-row[data-finished="true"] + .native-sidebar-thread-title { + font-weight: 500; +} + .native-macos-sidebar [data-thread-status-label] { width: 12px; height: 12px; diff --git a/apps/web/src/interactionSounds.test.ts b/apps/web/src/interactionSounds.test.ts index 36edb11ece0..73930d8a696 100644 --- a/apps/web/src/interactionSounds.test.ts +++ b/apps/web/src/interactionSounds.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from "vite-plus/test"; import { captureThreadSoundState, captureThreadSoundStateWhileSettingsHydrating, + COMPLETION_SOUND_VOLUME, deriveInteractionSoundCues, + deriveThreadFeedbackEvents, + shouldPostThreadCompletionNotification, } from "./interactionSounds"; function makeThread(overrides: Partial = {}): EnvironmentThreadShell { @@ -54,6 +57,25 @@ describe("interaction sounds", () => { expect(deriveInteractionSoundCues(captureThreadSoundState([running]), [completed])).toEqual([ "success", ]); + expect(deriveThreadFeedbackEvents(captureThreadSoundState([running]), [completed])).toEqual([ + { cue: "success", thread: completed }, + ]); + }); + + it("plays the completion cue at 110% of its original gain", () => { + expect(COMPLETION_SOUND_VOLUME).toBe(1.1); + }); + + it("posts completion notifications only when the flag and desktop bridge are available", () => { + expect( + shouldPostThreadCompletionNotification({ enabled: true, desktopBridgeAvailable: true }), + ).toBe(true); + expect( + shouldPostThreadCompletionNotification({ enabled: false, desktopBridgeAvailable: true }), + ).toBe(false); + expect( + shouldPostThreadCompletionNotification({ enabled: true, desktopBridgeAvailable: false }), + ).toBe(false); }); it("plays bloom when a thread starts requesting user input", () => { diff --git a/apps/web/src/interactionSounds.ts b/apps/web/src/interactionSounds.ts index eedf1f42dc9..f000a05010f 100644 --- a/apps/web/src/interactionSounds.ts +++ b/apps/web/src/interactionSounds.ts @@ -1,6 +1,12 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; export type InteractionSoundCue = "bloom" | "success"; +export const COMPLETION_SOUND_VOLUME = 1.1; + +export interface ThreadFeedbackEvent { + readonly cue: InteractionSoundCue; + readonly thread: EnvironmentThreadShell; +} interface ThreadSoundState { readonly completedTurn: string | null; @@ -58,24 +64,38 @@ export function captureThreadSoundStateWhileSettingsHydrating( return merged; } -export function deriveInteractionSoundCues( +export function deriveThreadFeedbackEvents( previous: ThreadSoundStateByKey, threads: ReadonlyArray, -): InteractionSoundCue[] { - const cues: InteractionSoundCue[] = []; +): ThreadFeedbackEvent[] { + const events: ThreadFeedbackEvent[] = []; for (const thread of threads) { const prior = previous.get(threadKey(thread)); const nextCompletedTurn = completedTurn(thread); if (prior && nextCompletedTurn !== null && prior.completedTurn !== nextCompletedTurn) { - cues.push("success"); + events.push({ cue: "success", thread }); } const hasPendingUserAction = thread.hasPendingUserInput || thread.hasPendingApprovals; if (prior && hasPendingUserAction && !prior.hasPendingUserAction) { - cues.push("bloom"); + events.push({ cue: "bloom", thread }); } } - return cues; + return events; +} + +export function deriveInteractionSoundCues( + previous: ThreadSoundStateByKey, + threads: ReadonlyArray, +): InteractionSoundCue[] { + return deriveThreadFeedbackEvents(previous, threads).map((event) => event.cue); +} + +export function shouldPostThreadCompletionNotification(input: { + readonly enabled: boolean; + readonly desktopBridgeAvailable: boolean; +}): boolean { + return input.enabled && input.desktopBridgeAvailable; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 38579dc7c83..46fe0500921 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,7 +28,11 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { useClientSettings, useClientSettingsHydrated } from "../hooks/useSettings"; +import { + useClientSettings, + useClientSettingsHydrated, + usePrimarySettings, +} from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -57,7 +61,9 @@ import { import { captureThreadSoundState, captureThreadSoundStateWhileSettingsHydrating, - deriveInteractionSoundCues, + COMPLETION_SOUND_VOLUME, + deriveThreadFeedbackEvents, + shouldPostThreadCompletionNotification, type ThreadSoundStateByKey, } from "../interactionSounds"; import { @@ -146,7 +152,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} - + {primaryEnvironmentAuthenticated ? : null} {appShell} @@ -154,12 +160,31 @@ function RootRouteView() { ); } -function InteractionSoundCoordinator() { +function ThreadCompletionFeedbackCoordinator() { const threads = useThreadShells(); + const navigate = useNavigate(); const completionSoundEnabled = useClientSettings((settings) => settings.enableCompletionSounds); + const completionNotificationsEnabled = usePrimarySettings( + (settings) => settings.enableMacosCompletionNotifications, + ); const settingsHydrated = useClientSettingsHydrated(); const previousStateRef = useRef(null); + useEffect(() => { + const subscribe = window.desktopBridge?.onThreadCompletionNotificationClick; + if (typeof subscribe !== "function") return; + + return subscribe((threadRef) => { + void navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }, + }); + }); + }, [navigate]); + useEffect(() => { if (!settingsHydrated) { previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating( @@ -170,13 +195,32 @@ function InteractionSoundCoordinator() { } const previous = previousStateRef.current; - if (completionSoundEnabled && previous !== null) { - for (const cue of deriveInteractionSoundCues(previous, threads)) { - play(cue); + if (previous !== null) { + for (const event of deriveThreadFeedbackEvents(previous, threads)) { + if (completionSoundEnabled) { + play(event.cue, event.cue === "success" ? COMPLETION_SOUND_VOLUME : 1); + } + const showNotification = window.desktopBridge?.showThreadCompletionNotification; + if ( + event.cue === "success" && + shouldPostThreadCompletionNotification({ + enabled: completionNotificationsEnabled, + desktopBridgeAvailable: typeof showNotification === "function", + }) && + showNotification + ) { + void showNotification({ + threadRef: { + environmentId: event.thread.environmentId, + threadId: event.thread.id, + }, + threadTitle: event.thread.title, + }).catch(() => undefined); + } } } previousStateRef.current = captureThreadSoundState(threads); - }, [completionSoundEnabled, settingsHydrated, threads]); + }, [completionNotificationsEnabled, completionSoundEnabled, settingsHydrated, threads]); return null; } diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index 085cd1b2571..9c92b1fa9e0 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -2,19 +2,20 @@ Turning a flag off preserves upstream behavior. -| Feature | Flag | Default | -| ---------------------------------- | ----------------------------------- | ------- | -| Projectless standalone chats | `enableStandaloneChats` | On | -| Native macOS sidebar | `enableNativeMacSidebar` | On | -| Sidebar worktree navigation | `enableSidebarWorktreeNavigation` | On | -| Checkout-aware thread creation | `enableCheckoutAwareThreadCreation` | On | -| Completion and attention sounds | `enableCompletionSounds` | On | -| Fork-aware pull requests | `enableForkPullRequests` | On | -| Project provider skill discovery | `enableProviderSkillDiscovery` | On | -| Markdown and text file attachments | `enableTextFileAttachments` | On | -| Inline generated-image rendering | `enableGeneratedImageRendering` | On | -| Project file and content search | `enableProjectSearch` | On | -| Working-change diff workflow | `enablePersonalDiffWorkflow` | On | +| Feature | Flag | Default | +| ---------------------------------- | ------------------------------------ | ------- | +| Projectless standalone chats | `enableStandaloneChats` | On | +| Native macOS sidebar | `enableNativeMacSidebar` | On | +| macOS completion notifications | `enableMacosCompletionNotifications` | On | +| Sidebar worktree navigation | `enableSidebarWorktreeNavigation` | On | +| Checkout-aware thread creation | `enableCheckoutAwareThreadCreation` | On | +| Completion and attention sounds | `enableCompletionSounds` | On | +| Fork-aware pull requests | `enableForkPullRequests` | On | +| Project provider skill discovery | `enableProviderSkillDiscovery` | On | +| Markdown and text file attachments | `enableTextFileAttachments` | On | +| Inline generated-image rendering | `enableGeneratedImageRendering` | On | +| Project file and content search | `enableProjectSearch` | On | +| Working-change diff workflow | `enablePersonalDiffWorkflow` | On | ## Desktop fork identity @@ -38,8 +39,9 @@ Turning a flag off preserves upstream behavior. ## Native macOS sidebar -- Inactive thread titles are subdued so the focused thread and newly completed threads retain the - strongest visual emphasis. Hovering an inactive thread keeps that hierarchy intact. +- Inactive thread titles are regular weight and subdued. The focused thread, multi-selected + threads, and newly completed threads use a medium, bold-ish weight and full emphasis. Hovering an + inactive thread keeps that hierarchy intact. - Project titles use regular weight and remain subdued until one of their threads has an unseen completion. Worktree labels remain quieter than inactive thread titles so conversation names carry more visual weight. @@ -60,3 +62,11 @@ Turning a flag off preserves upstream behavior. registration intact. Worktree rows do not show inline archive buttons; every non-main worktree keeps the explicit worktree-archive action in its context menu, which deletes the checkout and its Git registration. + +## macOS completion notifications and sounds + +- With macOS completion notifications enabled, the Electron host posts a native, silent macOS + notification whenever a turn transitions to completed. Clicking it reveals the app and opens the + exact environment-scoped thread. Web and non-macOS runtimes retain upstream behavior. +- The synthesized completion cue plays at 110% of its original gain; attention cues retain their + original gain. Disabling completion sounds preserves the upstream silent behavior. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4c2e688d061..1396272f30d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -105,7 +105,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; -import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ExecutionEnvironmentDescriptor, ScopedThreadRef } from "./environment.ts"; import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; import type { SourceControlCloneRepositoryInput, @@ -948,6 +948,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ input: PreviewAutomationWaitForInput, }); +export const DesktopThreadCompletionNotificationInput = Schema.Struct({ + threadRef: ScopedThreadRef, + threadTitle: Schema.String, +}); +export type DesktopThreadCompletionNotificationInput = + typeof DesktopThreadCompletionNotificationInput.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; // One bootstrap per pool instance currently registered with bootstrap @@ -998,6 +1005,12 @@ export interface DesktopBridge { ) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + showThreadCompletionNotification: ( + input: DesktopThreadCompletionNotificationInput, + ) => Promise; + onThreadCompletionNotificationClick: ( + listener: (threadRef: typeof ScopedThreadRef.Type) => void, + ) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7ef6da0547f..656b98c711b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -47,6 +47,7 @@ describe("personal fork feature flags", () => { const flagNames = [ "enableStandaloneChats", "enableNativeMacSidebar", + "enableMacosCompletionNotifications", "enableSidebarWorktreeNavigation", "enableCheckoutAwareThreadCreation", "enableForkPullRequests", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8b4196e50d6..27393a43e68 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -376,6 +376,9 @@ export const ServerSettings = Schema.Struct({ enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), enableStandaloneChats: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), enableNativeMacSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + enableMacosCompletionNotifications: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), enableSidebarWorktreeNavigation: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), @@ -533,6 +536,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), enableStandaloneChats: Schema.optionalKey(Schema.Boolean), enableNativeMacSidebar: Schema.optionalKey(Schema.Boolean), + enableMacosCompletionNotifications: Schema.optionalKey(Schema.Boolean), enableSidebarWorktreeNavigation: Schema.optionalKey(Schema.Boolean), enableCheckoutAwareThreadCreation: Schema.optionalKey(Schema.Boolean), enableForkPullRequests: Schema.optionalKey(Schema.Boolean), diff --git a/patches/cuelume@0.1.0.patch b/patches/cuelume@0.1.0.patch new file mode 100644 index 00000000000..d87c80ff2ef --- /dev/null +++ b/patches/cuelume@0.1.0.patch @@ -0,0 +1,53 @@ +diff --git a/dist/audio/engine.d.ts b/dist/audio/engine.d.ts +index 45bb5bf86..b9fa248a8 100644 +--- a/dist/audio/engine.d.ts ++++ b/dist/audio/engine.d.ts +@@ -13,4 +13,4 @@ export declare function setEnabled(value: boolean): void; + * started it suspended (e.g. before any user gesture), and is a no-op + * when Web Audio is unavailable (SSR, old browsers). + */ +-export declare function play(sound?: SoundName): void; ++export declare function play(sound?: SoundName, volume?: number): void; +diff --git a/dist/audio/engine.js b/dist/audio/engine.js +index c309916a1..c963d96e3 100644 +--- a/dist/audio/engine.js ++++ b/dist/audio/engine.js +@@ -77,10 +77,10 @@ function shimmerTail(shimmer) { + return shimmer.delay; + return shimmer.delay * (1 + Math.ceil(Math.log(INAUDIBLE_GAIN) / Math.log(shimmer.feedback))); + } +-function renderRecipe(context, recipe) { ++function renderRecipe(context, recipe, volume) { + const now = context.currentTime; + const master = context.createGain(); +- master.gain.value = recipe.masterGain; ++ master.gain.value = recipe.masterGain * volume; + master.connect(context.destination); + const shimmerNodes = recipe.shimmer ? attachShimmer(context, master, context.destination, recipe.shimmer) : []; + for (const layer of recipe.layers) { +@@ -126,21 +126,22 @@ function getAudioContext() { + * started it suspended (e.g. before any user gesture), and is a no-op + * when Web Audio is unavailable (SSR, old browsers). + */ +-export function play(sound = "chime") { ++export function play(sound = "chime", volume = 1) { + if (!enabled || !isSoundName(sound)) + return; + const context = getAudioContext(); + if (!context) + return; + const recipe = RECIPES[sound]; ++ const safeVolume = typeof volume === "number" && Number.isFinite(volume) ? Math.max(0, volume) : 1; + if (context.state === "running") { +- renderRecipe(context, recipe); ++ renderRecipe(context, recipe, safeVolume); + } + else { + try { + void context.resume().then(() => { + if (enabled && context.state === "running") +- renderRecipe(context, recipe); ++ renderRecipe(context, recipe, safeVolume); + }, () => { }); + } + catch { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4dbac145f5..8d4a0c23ede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,7 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.9': d6e53a0dfd9226d15e0f2ec03a9947239003125bb51e4b5df3e246595eeff6f4 '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca + cuelume@0.1.0: 28666ae78f0737c050abdb6e83ec0c8ba16237d306d075dda56040a636449867 effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 @@ -576,7 +577,7 @@ importers: version: 0.7.1 cuelume: specifier: ^0.1.0 - version: 0.1.0 + version: 0.1.0(patch_hash=28666ae78f0737c050abdb6e83ec0c8ba16237d306d075dda56040a636449867) effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) @@ -16220,7 +16221,7 @@ snapshots: csstype@3.2.3: {} - cuelume@0.1.0: {} + cuelume@0.1.0(patch_hash=28666ae78f0737c050abdb6e83ec0c8ba16237d306d075dda56040a636449867): {} culori@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f1250e3c7cc..c8ad455d1ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -104,6 +104,7 @@ packageExtensions: vite: "catalog:" patchedDependencies: + cuelume@0.1.0: patches/cuelume@0.1.0.patch "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch