diff --git a/src/main/index.ts b/src/main/index.ts index 70cf0475c..d1d82a686 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -110,7 +110,7 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { createSystemTray, destroySystemTray } from './tray/system-tray' +import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' @@ -1002,6 +1002,10 @@ function openMainWindow(): BrowserWindow { // macOS dock re-activation recreates the BrowserWindow. window.on('show', notifyMainWindowBecameVisible) window.on('restore', notifyMainWindowBecameVisible) + // Why: showing/restoring the window means the user is back, so clear the + // tray attention dot set while it was minimized/hidden (see notifications.ts). + window.on('show', () => setTrayAttention(false)) + window.on('restore', () => setTrayAttention(false)) agentHookServer.setListener( ({ paneKey, diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index 3ada19171..9a6dcd1f9 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -70,6 +70,14 @@ vi.mock('electron', () => ({ } })) +// Why: notifications.ts pulls in the tray module (for the minimized attention +// dot), which transitively loads app-icon/electron-toolkit; stub it so this +// suite stays focused on notification dispatch and avoids that import chain. +const setTrayAttentionMock = vi.hoisted(() => vi.fn()) +vi.mock('../tray/system-tray', () => ({ + setTrayAttention: setTrayAttentionMock +})) + import { registerNotificationHandlers, triggerStartupNotificationRegistration @@ -101,6 +109,7 @@ describe('registerNotificationHandlers', () => { getAllWindowsMock.mockReset() getAllWindowsMock.mockReturnValue([]) shellOpenExternalMock.mockClear() + setTrayAttentionMock.mockClear() }) afterEach(() => { @@ -287,6 +296,65 @@ describe('registerNotificationHandlers', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) + describe('minimized tray attention dot', () => { + function registerEnabledNotifications(): void { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true + } + }) + } as never) + } + + it('lights the tray dot for an agent completion while the window is hidden', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'agent-task-complete' }) + + expect(setTrayAttentionMock).toHaveBeenCalledWith(true) + }) + + it('lights the tray dot for a terminal bell while the window is minimized', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => true, isMinimized: () => true } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'terminal-bell' }) + + expect(setTrayAttentionMock).toHaveBeenCalledWith(true) + }) + + it('does not light the tray dot while the window is visible', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => true, isMinimized: () => false } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'agent-task-complete' }) + + expect(setTrayAttentionMock).not.toHaveBeenCalled() + }) + + it('does not light the tray dot for non-bell/completion sources', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'test' }) + + expect(setTrayAttentionMock).not.toHaveBeenCalled() + }) + }) + it('delivers a notification when the event is allowed', () => { registerNotificationHandlers({ getSettings: () => ({ diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index b9490da86..94ff8c636 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -24,6 +24,8 @@ import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { buildNotificationOptions } from './notification-options' import { parsePaneKey } from '../../shared/stable-pane-id' +import { setTrayAttention } from '../tray/system-tray' +import { isMainWindowVisible } from '../window/main-window-visibility' const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_RECENT_NOTIFICATION_KEYS = 50 @@ -255,6 +257,22 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime _event, args: NotificationDispatchRequest ): NotificationDispatchResult | Promise => { + // Why: a terminal bell or agent completion that arrives while the window + // is minimized/hidden lights the tray attention dot — a passive cue that + // clears on window show/restore (see index.ts). Placed before the + // focus-suppression, cooldown, and enabled gates below so those do not + // hold back the dot. It rides the notification dispatch, so it follows the + // renderer's per-source decision to notify: bells always reach here, while + // an agent completion is suppressed upstream when its notification is + // disabled. Tray exists only on Windows, so setTrayAttention no-ops + // elsewhere. + if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { + const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null + if (!isMainWindowVisible(activeWindow)) { + setTrayAttention(true) + } + } + const settings = store.getSettings().notifications if (!settings.enabled) { return { delivered: false, reason: 'disabled' } diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts index e269d9307..9f5ad72ca 100644 --- a/src/main/tray/system-tray.test.ts +++ b/src/main/tray/system-tray.test.ts @@ -1,21 +1,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SystemTrayModule from './system-tray' -const { trayInstances, menuFromTemplateMock, createAppIconImageMock, resizedImage } = vi.hoisted( - () => { - const resizedImage = { resized: true } - return { - trayInstances: [] as FakeTray[], - menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), - createAppIconImageMock: vi.fn(), - resizedImage - } +const { + trayInstances, + menuFromTemplateMock, + createAppIconImageMock, + composeAttentionMock, + resizedImage +} = vi.hoisted(() => { + const resizedImage = { resized: true } + return { + trayInstances: [] as FakeTray[], + menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + createAppIconImageMock: vi.fn(), + composeAttentionMock: vi.fn((image: unknown) => ({ dotted: image })), + resizedImage } -) +}) class FakeTray { setToolTip = vi.fn() setContextMenu = vi.fn() + setImage = vi.fn() on = vi.fn() destroy = vi.fn() isDestroyed = vi.fn(() => false) @@ -33,6 +39,10 @@ vi.mock('../app-icon', () => ({ createAppIconImage: createAppIconImageMock })) +vi.mock('./tray-attention-icon', () => ({ + composeTrayAttentionIcon: composeAttentionMock +})) + type TrayModule = typeof SystemTrayModule const originalPlatform = process.platform @@ -55,6 +65,7 @@ function builtMenuItems(): MenuItem[] { beforeEach(() => { trayInstances.length = 0 menuFromTemplateMock.mockClear() + composeAttentionMock.mockClear() createAppIconImageMock.mockReset() createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) }) }) @@ -126,6 +137,57 @@ describe('createSystemTray', () => { }) }) +describe('setTrayAttention', () => { + it('swaps in the dotted icon when active and restores the base when cleared', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + const tray = trayInstances[0] + tray.setImage.mockClear() + + setTrayAttention(true) + expect(composeAttentionMock).toHaveBeenCalledWith(resizedImage) + expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) + + tray.setImage.mockClear() + setTrayAttention(false) + expect(tray.setImage).toHaveBeenCalledWith(resizedImage) + }) + + it('ignores repeated same-state calls', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + const tray = trayInstances[0] + tray.setImage.mockClear() + + setTrayAttention(true) + setTrayAttention(true) + + expect(tray.setImage).toHaveBeenCalledTimes(1) + }) + + it('reflects attention that was requested before the tray was created', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + + // Fire the event before the (deferred) tray exists. + setTrayAttention(true) + createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + const tray = trayInstances[0] + + expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) + }) + + it('is a safe no-op on non-win32 platforms', async () => { + setPlatform('darwin') + const { setTrayAttention } = await loadModule() + + expect(() => setTrayAttention(true)).not.toThrow() + expect(composeAttentionMock).not.toHaveBeenCalled() + }) +}) + describe('destroySystemTray', () => { it('destroys an existing tray and is safe to call without one', async () => { setPlatform('win32') diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts index d531d2c59..ada6f58c4 100644 --- a/src/main/tray/system-tray.ts +++ b/src/main/tray/system-tray.ts @@ -1,6 +1,7 @@ -import { Menu, Tray } from 'electron' +import { Menu, Tray, type NativeImage } from 'electron' import { createAppIconImage } from '../app-icon' import { translateMain } from '../i18n/main-i18n' +import { composeTrayAttentionIcon } from './tray-attention-icon' type SystemTrayOptions = { /** App icon id from settings; the tray reuses the app icon image. */ @@ -15,10 +16,28 @@ type SystemTrayOptions = { // reference is kept, so hold it at module scope for the app's lifetime. let tray: Tray | null = null +// Why: hold the plain (dot-free) icon so we can toggle the attention dot on and +// off with tray.setImage without rebuilding the icon from the app-icon PNG. +let baseTrayImage: NativeImage | null = null + +// Why: an attention event can fire while the tray is still being created +// (creation is deferred ~ready-to-show); remember the desired state so a +// freshly created tray reflects it immediately. +let attentionActive = false + // Why: on Windows the notification area expects a 16px icon; the app icon PNG // is larger, so downscale to avoid a cropped/blurry tray glyph. const TRAY_ICON_SIZE = 16 +// Why: centralize which image the tray shows so both creation and attention +// toggling stay in sync. No-ops safely when the tray or base image is missing. +function applyTrayImage(): void { + if (!tray || tray.isDestroyed() || !baseTrayImage) { + return + } + tray.setImage(attentionActive ? composeTrayAttentionIcon(baseTrayImage) : baseTrayImage) +} + /** * Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a * second call while a tray is alive returns the existing one instead of @@ -31,11 +50,13 @@ export function createSystemTray(opts: SystemTrayOptions): Tray | null { if (tray && !tray.isDestroyed()) { return tray } - const image = createAppIconImage(opts.appIcon).resize({ + baseTrayImage = createAppIconImage(opts.appIcon).resize({ width: TRAY_ICON_SIZE, height: TRAY_ICON_SIZE }) - tray = new Tray(image) + tray = new Tray(baseTrayImage) + // Why: reflect any attention event that fired before the tray existed. + applyTrayImage() tray.setToolTip('Orca') const menu = Menu.buildFromTemplate([ { label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() }, @@ -49,10 +70,26 @@ export function createSystemTray(opts: SystemTrayOptions): Tray | null { return tray } +/** + * Shows or hides a red/amber attention dot on the tray icon. Call with `true` + * when a terminal bell or agent completion fires while the window is + * minimized/hidden, and `false` once the window is shown again. No-op on + * macOS/Linux (no tray) and safe to call before the tray is created. + */ +export function setTrayAttention(active: boolean): void { + if (attentionActive === active) { + return + } + attentionActive = active + applyTrayImage() +} + /** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */ export function destroySystemTray(): void { if (tray && !tray.isDestroyed()) { tray.destroy() } tray = null + baseTrayImage = null + attentionActive = false } diff --git a/src/main/tray/tray-attention-icon.test.ts b/src/main/tray/tray-attention-icon.test.ts new file mode 100644 index 000000000..42d554417 --- /dev/null +++ b/src/main/tray/tray-attention-icon.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' + +const createFromBitmapMock = vi.hoisted(() => + vi.fn((buffer: Buffer, options: { width: number; height: number }) => ({ + __image: true, + buffer, + ...options + })) +) + +vi.mock('electron', () => ({ + nativeImage: { createFromBitmap: createFromBitmapMock } +})) + +import { composeTrayAttentionIcon } from './tray-attention-icon' + +type FakeImage = { + getSize: () => { width: number; height: number } + toBitmap: () => Buffer +} + +function fakeBase(width: number, height: number): FakeImage { + // All-transparent base so any non-zero pixel in the result is the dot/ring. + return { + getSize: () => ({ width, height }), + toBitmap: () => Buffer.alloc(width * height * 4, 0) + } +} + +// The compositor receives BGRA. amber-500 = #f59e0b. +const AMBER = { b: 0x0b, g: 0x9e, r: 0xf5 } + +describe('composeTrayAttentionIcon', () => { + it('returns the base unchanged when it has no pixels', () => { + const base = { getSize: () => ({ width: 0, height: 0 }), toBitmap: () => Buffer.alloc(0) } + + expect(composeTrayAttentionIcon(base as never)).toBe(base) + expect(createFromBitmapMock).not.toHaveBeenCalled() + }) + + it('builds a new image of the same size', () => { + createFromBitmapMock.mockClear() + const result = composeTrayAttentionIcon(fakeBase(16, 16) as never) + + expect(createFromBitmapMock).toHaveBeenCalledTimes(1) + const [, options] = createFromBitmapMock.mock.calls[0] + expect(options).toEqual({ width: 16, height: 16 }) + expect(result).toMatchObject({ __image: true, width: 16, height: 16 }) + }) + + it('paints an amber dot in the top-right corner and leaves the rest untouched', () => { + createFromBitmapMock.mockClear() + const width = 16 + const height = 16 + composeTrayAttentionIcon(fakeBase(width, height) as never) + const bitmap = createFromBitmapMock.mock.calls[0][0] + + const pixel = (x: number, y: number): [number, number, number, number] => { + const o = (y * width + x) * 4 + return [bitmap[o], bitmap[o + 1], bitmap[o + 2], bitmap[o + 3]] + } + + // The dot must exist, be centered in the top-right, and never touch the + // opposite (bottom-left) corner where the app glyph is most visible. + let amberCount = 0 + let sumX = 0 + let sumY = 0 + let paintedInBottomLeft = 0 + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const [b, g, r, a] = pixel(x, y) + if (b === AMBER.b && g === AMBER.g && r === AMBER.r && a === 0xff) { + amberCount++ + sumX += x + sumY += y + } + if (a !== 0 && x < width / 2 && y >= height / 2) { + paintedInBottomLeft++ + } + } + } + + expect(amberCount).toBeGreaterThan(0) + expect(sumX / amberCount).toBeGreaterThan(width / 2) // centroid sits right of center + expect(sumY / amberCount).toBeLessThan(height / 2) // centroid sits above center + expect(paintedInBottomLeft).toBe(0) // the opposite corner is never touched + }) +}) diff --git a/src/main/tray/tray-attention-icon.ts b/src/main/tray/tray-attention-icon.ts new file mode 100644 index 000000000..d9b042db3 --- /dev/null +++ b/src/main/tray/tray-attention-icon.ts @@ -0,0 +1,52 @@ +import { nativeImage, type NativeImage } from 'electron' + +// Why: amber-500 (#f59e0b) is Orca's "needs attention / unread" color, matching +// the renderer launcher dot and the tab-unread bell. Kept in sync with the +// bg-amber-500 used in FloatingTerminalToggleButton. +const DOT_RGB = { r: 0xf5, g: 0x9e, b: 0x0b } +// A near-white halo separates the dot from the icon glyph on any tray theme. +const RING_RGB = { r: 0xff, g: 0xff, b: 0xff } + +/** + * Returns a copy of `base` with a small amber attention dot composited into the + * top-right corner. Electron's NativeImage has no compositing API, so we merge + * the dot directly into the raw BGRA bitmap. Returns `base` unchanged if it has + * no pixels (e.g. a failed icon load) so the tray never shows a blank image. + */ +export function composeTrayAttentionIcon(base: NativeImage): NativeImage { + const { width, height } = base.getSize() + if (width <= 0 || height <= 0) { + return base + } + + // toBitmap()/createFromBitmap both use BGRA; the round-trip preserves format. + const bitmap = Buffer.from(base.toBitmap()) + const dotRadius = Math.max(2, Math.round(Math.min(width, height) * 0.2)) + const ringRadius = dotRadius + 1 + // Hug the top-right corner so the dot reads as a badge and leaves the app + // glyph (bottom-left) visible. The dot stays fully on-canvas; the outer ring + // may clip a pixel at the very corner, which is expected for a corner badge. + const centerX = width - 1 - dotRadius + const centerY = dotRadius + const dotRadiusSq = dotRadius * dotRadius + const ringRadiusSq = ringRadius * ringRadius + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const dx = x - centerX + const dy = y - centerY + const distSq = dx * dx + dy * dy + if (distSq > ringRadiusSq) { + continue + } + const offset = (y * width + x) * 4 + const color = distSq <= dotRadiusSq ? DOT_RGB : RING_RGB + bitmap[offset] = color.b + bitmap[offset + 1] = color.g + bitmap[offset + 2] = color.r + bitmap[offset + 3] = 0xff + } + } + + return nativeImage.createFromBitmap(bitmap, { width, height }) +} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx index 7f6985bdc..0db0de496 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx @@ -88,6 +88,18 @@ vi.mock('./FloatingTerminalIconContextMenu', () => ({ } })) +const storeState = vi.hoisted(() => ({ hasFloatingUnread: false })) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof storeState) => T): T => selector(storeState) +})) + +// The real selector is covered in store/selectors.test.ts; here it just reads +// the mocked flag so the dot's show/hide logic can be exercised in isolation. +vi.mock('@/store/selectors', () => ({ + selectFloatingWorkspaceHasUnread: (state: typeof storeState): boolean => state.hasFloatingUnread +})) + function visit(node: unknown, cb: (node: ReactElementLike) => void): void { if (node == null || typeof node === 'string' || typeof node === 'number') { return @@ -117,6 +129,16 @@ function findByProp(node: unknown, propName: string): ReactElementLike { return found } +function hasProp(node: unknown, propName: string): boolean { + let found = false + visit(node, (entry) => { + if (entry.props[propName]) { + found = true + } + }) + return found +} + function runEffects(): void { const layoutEffects = hookRuntime.layoutEffects.splice(0) for (const effect of layoutEffects) { @@ -233,3 +255,43 @@ describe('FloatingTerminalToggleButton positioning', () => { ) }) }) + +describe('FloatingTerminalToggleButton attention dot', () => { + beforeEach(() => { + vi.clearAllMocks() + hookRuntime.effects = [] + hookRuntime.layoutEffects = [] + hookRuntime.index = 0 + hookRuntime.values = [] + storeState.hasFloatingUnread = false + vi.stubGlobal('window', { + addEventListener: vi.fn(), + innerHeight: 800, + innerWidth: 1200, + localStorage: { getItem: vi.fn(() => null), setItem: vi.fn() }, + removeEventListener: vi.fn() + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders the attention dot when closed with pending floating activity', async () => { + storeState.hasFloatingUnread = true + const element = await renderToggle(false) + expect(hasProp(element, 'data-floating-terminal-attention')).toBe(true) + }) + + it('hides the dot when the panel is open even with pending activity', async () => { + storeState.hasFloatingUnread = true + const element = await renderToggle(true) + expect(hasProp(element, 'data-floating-terminal-attention')).toBe(false) + }) + + it('hides the dot when there is no pending activity', async () => { + storeState.hasFloatingUnread = false + const element = await renderToggle(false) + expect(hasProp(element, 'data-floating-terminal-attention')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx index 8d4fe3980..285ff0377 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx @@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { FloatingTerminalIconContextMenu } from './FloatingTerminalIconContextMenu' import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useAppStore } from '@/store' +import { selectFloatingWorkspaceHasUnread } from '@/store/selectors' import { anchorFloatingTerminalTriggerPosition, clampFloatingTerminalTriggerPosition, @@ -62,6 +64,12 @@ export function FloatingTerminalToggleButton({ onToggle: () => void }): React.JSX.Element { const shortcutLabel = useShortcutLabel('floatingTerminal.toggle') + // Why: show an attention dot while minimized (closed) when any floating- + // workspace tab still has an unacknowledged bell or agent completion. Derived + // from the shared unread maps, so it clears when the user engages with — or + // closes — the offending tab (see selectFloatingWorkspaceHasUnread). + const hasFloatingUnread = useAppStore(selectFloatingWorkspaceHasUnread) + const showAttentionDot = !open && hasFloatingUnread const initialPositionState = useRef(null) if (initialPositionState.current === null) { initialPositionState.current = readInitialTriggerPosition() @@ -200,7 +208,7 @@ export function FloatingTerminalToggleButton({ // pages a soft drop shadow lifts it; on near-black dark surfaces a // drop shadow vanishes, so use a distinctly lighter fill plus a // bright hairline ring to define the edge. - className="cursor-grab rounded-lg border-transparent text-foreground bg-card shadow-[0_4px_12px_rgb(0_0_0_/_0.22),0_0_0_1px_color-mix(in_srgb,var(--foreground)_12%,transparent)] hover:-translate-y-0.5 hover:bg-accent active:translate-y-0 active:cursor-grabbing dark:bg-accent dark:shadow-[0_6px_16px_rgb(0_0_0_/_0.55),0_0_0_1px_rgb(255_255_255_/_0.22)] dark:hover:bg-[color-mix(in_srgb,var(--accent)_82%,white)]" + className="relative cursor-grab rounded-lg border-transparent text-foreground bg-card shadow-[0_4px_12px_rgb(0_0_0_/_0.22),0_0_0_1px_color-mix(in_srgb,var(--foreground)_12%,transparent)] hover:-translate-y-0.5 hover:bg-accent active:translate-y-0 active:cursor-grabbing dark:bg-accent dark:shadow-[0_6px_16px_rgb(0_0_0_/_0.55),0_0_0_1px_rgb(255_255_255_/_0.22)] dark:hover:bg-[color-mix(in_srgb,var(--accent)_82%,white)]" data-floating-terminal-toggle aria-label={ open @@ -208,10 +216,17 @@ export function FloatingTerminalToggleButton({ 'auto.components.floating.terminal.FloatingTerminalToggleButton.5785dd9148', 'Minimize floating workspace' ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalToggleButton.3b04b065b5', - 'Show floating workspace' - ) + : showAttentionDot + ? // Why: announce pending activity to assistive tech; the dot + // itself is aria-hidden decoration. + translate( + 'auto.components.floating.terminal.FloatingTerminalToggleButton.4cb418b991', + 'Show floating workspace, new activity' + ) + : translate( + 'auto.components.floating.terminal.FloatingTerminalToggleButton.3b04b065b5', + 'Show floating workspace' + ) } aria-pressed={open} onPointerDown={handlePointerDown} @@ -221,6 +236,16 @@ export function FloatingTerminalToggleButton({ onClick={handleClick} > + {showAttentionDot ? ( + // Why: amber matches Orca's "needs attention / unread" convention + // (the tab-unread bell); the ring matches the button fill so the + // dot reads on both light (bg-card) and dark (dark:bg-accent). + + ) : null} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 6dd7c102d..5e333f2ff 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -36,6 +36,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useAppStore } from '../../store' +import { selectFloatingWorkspaceHasUnread } from '../../store/selectors' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, @@ -1747,6 +1748,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const statusBarVisible = useAppStore((s) => s.statusBarVisible) const statusBarItems = useAppStore((s) => s.statusBarItems) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + // Why: same launcher attention dot as the floating-button trigger, so an + // unacknowledged bell/agent-completion in the floating workspace is visible + // whichever trigger location the user picked (see FloatingTerminalToggleButton). + const hasFloatingUnread = useAppStore(selectFloatingWorkspaceHasUnread) const floatingTerminalEnabled = settings?.floatingTerminalEnabled === true const floatingTerminalTriggerLocation = settings?.floatingTerminalTriggerLocation ?? 'floating-button' @@ -1908,6 +1913,9 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const floatingTerminalActionLabel = floatingTerminalOpen ? 'Minimize Floating Workspace' : 'Show Floating Workspace' + // Why: only while the panel is closed; the dot reflects unacknowledged + // floating-workspace activity and clears via the shared unread paths. + const showFloatingWorkspaceAttentionDot = !floatingTerminalOpen && hasFloatingUnread return (
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6bde24123..0ab7e8d57 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10429,6 +10429,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "Show floating workspace", + "4cb418b991": "Show floating workspace, new activity", "5785dd9148": "Minimize floating workspace", "bfe7809a70": "{{value0}} floating workspace ({{value1}})" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ecd6d2e91..d167b12eb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10429,6 +10429,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "Mostrar espacio de trabajo flotante", + "4cb418b991": "Show floating workspace, new activity", "5785dd9148": "Minimizar el espacio de trabajo flotante", "bfe7809a70": "{{value0}} espacio de trabajo flotante ({{value1}})" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6e5a517c5..1a5f93b5d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10429,6 +10429,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "フローティングワークスペースを表示", + "4cb418b991": "Show floating workspace, new activity", "5785dd9148": "フローティングワークスペースを最小限に抑える", "bfe7809a70": "{{value0}} フローティング ワークスペース ({{value1}})" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a3bad662c..2a869c6dd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10429,6 +10429,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "플로팅 워크스페이스 표시", + "4cb418b991": "Show floating workspace, new activity", "5785dd9148": "플로팅 워크스페이스 최소화", "bfe7809a70": "{{value0}} 플로팅 워크스페이스({{value1}})" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f8e36bd08..033ee3e01 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10429,6 +10429,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "显示浮动工作区", + "4cb418b991": "Show floating workspace, new activity", "5785dd9148": "最小化浮动工作区", "bfe7809a70": "{{value0}} 浮动工作区 ({{value1}})" }, diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index 0d2b95d86..8059cf68d 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -8,7 +8,8 @@ import { getProjectHostSetupProjectionFromState, getWorktreeMapFromState, resetFloatingVisibleTabCountSelectorCacheForTest, - selectFloatingVisibleTabCount + selectFloatingVisibleTabCount, + selectFloatingWorkspaceHasUnread } from './selectors' import { selectActiveTerminalChromeState } from './active-terminal-chrome-selector' @@ -457,3 +458,70 @@ describe('store selectors', () => { ) }) }) + +describe('selectFloatingWorkspaceHasUnread', () => { + const FLOATING = FLOATING_TERMINAL_WORKTREE_ID + + type UnreadState = Parameters[0] + + function makeState(overrides: Partial): UnreadState { + return { + tabsByWorktree: {}, + unreadTerminalTabs: {}, + unreadAgentCompletionPanes: {}, + ...overrides + } as UnreadState + } + + function floatingTab(id: string): TerminalTab { + return { id, title: id, ptyId: null } as unknown as TerminalTab + } + + it('is false when the floating workspace has no tabs', () => { + expect(selectFloatingWorkspaceHasUnread(makeState({}))).toBe(false) + }) + + it('is true for a bell — an unread floating tab', () => { + const state = makeState({ + tabsByWorktree: { [FLOATING]: [floatingTab('ft1')] }, + unreadTerminalTabs: { ft1: true } + }) + expect(selectFloatingWorkspaceHasUnread(state)).toBe(true) + }) + + it('is true for an agent completion — an unread floating pane', () => { + const state = makeState({ + tabsByWorktree: { [FLOATING]: [floatingTab('ft1')] }, + unreadAgentCompletionPanes: { 'ft1:leaf-a': true } + }) + expect(selectFloatingWorkspaceHasUnread(state)).toBe(true) + }) + + it('stays true while any of several floating tabs is still unacknowledged', () => { + const state = makeState({ + tabsByWorktree: { [FLOATING]: [floatingTab('ft1'), floatingTab('ft2'), floatingTab('ft3')] }, + unreadTerminalTabs: { ft3: true } + }) + expect(selectFloatingWorkspaceHasUnread(state)).toBe(true) + }) + + it('ignores unread that belongs to non-floating (main workspace) tabs', () => { + const state = makeState({ + tabsByWorktree: { [FLOATING]: [floatingTab('ft1')] }, + unreadTerminalTabs: { 'main-tab': true }, + unreadAgentCompletionPanes: { 'main-tab:leaf-x': true } + }) + expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) + }) + + it('does not light for a stale unread entry whose floating tab no longer exists', () => { + // Mirrors closing a floating tab: the tab is gone from tabsByWorktree, so even + // a lingering map entry (there should be none — closeTab purges) cannot show. + const state = makeState({ + tabsByWorktree: { [FLOATING]: [floatingTab('ft-live')] }, + unreadTerminalTabs: { 'ft-closed': true }, + unreadAgentCompletionPanes: { 'ft-closed:leaf-a': true } + }) + expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index e5a30fef6..1ffc7c40d 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -158,6 +158,49 @@ export function resetFloatingVisibleTabCountSelectorCacheForTest(): void { floatingVisibleTabCountCache = null } +type FloatingWorkspaceUnreadState = Pick< + AppState, + 'tabsByWorktree' | 'unreadTerminalTabs' | 'unreadAgentCompletionPanes' +> + +/** + * True when any terminal tab in the floating workspace has an unacknowledged + * bell or agent completion — the signal behind the launcher attention dot. + * + * Derives from the existing "show until interact" unread maps rather than a + * bespoke flag, so it clears exactly when the user engages with (or closes) the + * offending tab, and reflects only tabs that still exist (stale map entries for + * removed tabs cannot light it). Bells mark `unreadTerminalTabs[tabId]`; + * completions mark `unreadAgentCompletionPanes[paneKey]` — both ungated. + * + * Returns a primitive boolean, so subscribers re-render only when it flips, and + * the empty-workspace early return keeps the common case O(1) despite Zustand + * rerunning selectors on every write. + */ +export function selectFloatingWorkspaceHasUnread(state: FloatingWorkspaceUnreadState): boolean { + const tabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + if (!tabs || tabs.length === 0) { + return false + } + const floatingTabIds = new Set() + for (const tab of tabs) { + if (state.unreadTerminalTabs[tab.id]) { + return true + } + floatingTabIds.add(tab.id) + } + // paneKey is `${tabId}:${leafId}` and tabIds never contain ":", so the prefix + // up to the first ":" is the owning tab id. + for (const paneKey of Object.keys(state.unreadAgentCompletionPanes)) { + const separatorIndex = paneKey.indexOf(':') + const tabId = separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) + if (floatingTabIds.has(tabId)) { + return true + } + } + return false +} + export function getAllWorktreesFromState(state: Pick): Worktree[] { return getCachedAllWorktrees(state.worktreesByRepo) }