feat(floating-terminal): amber attention dot for unacknowledged bells and completions (#7888)

Show a small amber dot on the floating-workspace launcher (both the
floating-button and status-bar triggers) whenever any floating-workspace tab
still has an unacknowledged terminal bell or agent completion, and a
composited amber dot on the Windows tray icon when the window is
minimized/hidden. Both clear through the existing show-until-interact paths —
engaging with or closing the offending tab drops the dot with no stale unread
state left behind.

The launcher dot derives from the existing per-tab/per-pane unread maps via a
new selectFloatingWorkspaceHasUnread selector (primitive boolean, empty-
workspace early return, no bespoke state). The tray dot rides the notification
dispatch and clears on window show/restore.
This commit is contained in:
Neil 2026-07-08 21:39:49 -07:00 committed by GitHub
parent 5c6f0ad52d
commit 8dbcb2be3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 575 additions and 22 deletions

View File

@ -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,

View File

@ -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: () => ({

View File

@ -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<NotificationDispatchResult> => {
// 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' }

View File

@ -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')

View File

@ -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
}

View File

@ -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
})
})

View File

@ -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 })
}

View File

@ -88,6 +88,18 @@ vi.mock('./FloatingTerminalIconContextMenu', () => ({
}
}))
const storeState = vi.hoisted(() => ({ hasFloatingUnread: false }))
vi.mock('@/store', () => ({
useAppStore: <T,>(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)
})
})

View File

@ -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<FloatingTerminalTriggerPositionState | null>(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}
>
<PanelsTopLeft className="size-4" />
{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).
<span
aria-hidden
data-floating-terminal-attention
className="pointer-events-none absolute right-1 top-1 size-2 rounded-full bg-amber-500 ring-2 ring-card dark:ring-accent"
/>
) : null}
</Button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>

View File

@ -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 (
<div
@ -2032,13 +2040,26 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-5 cursor-pointer items-center justify-center rounded border border-border bg-secondary text-secondary-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={floatingTerminalActionLabel}
className="relative inline-flex size-5 cursor-pointer items-center justify-center rounded border border-border bg-secondary text-secondary-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={
showFloatingWorkspaceAttentionDot
? `${floatingTerminalActionLabel}, new activity`
: floatingTerminalActionLabel
}
onClick={() => {
window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT))
}}
>
<PanelsTopLeft className="size-3.5" />
{showFloatingWorkspaceAttentionDot ? (
// Why: amber = Orca's "needs attention" convention; ring
// matches the button fill so the dot reads on the icon.
<span
aria-hidden
data-floating-terminal-attention
className="pointer-events-none absolute right-0.5 top-0.5 size-1.5 rounded-full bg-amber-500 ring-1 ring-secondary"
/>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>

View File

@ -10429,6 +10429,7 @@
},
"FloatingTerminalToggleButton": {
"3b04b065b5": "Show floating workspace",
"4cb418b991": "Show floating workspace, new activity",
"5785dd9148": "Minimize floating workspace",
"bfe7809a70": "{{value0}} floating workspace ({{value1}})"
},

View File

@ -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}})"
},

View File

@ -10429,6 +10429,7 @@
},
"FloatingTerminalToggleButton": {
"3b04b065b5": "フローティングワークスペースを表示",
"4cb418b991": "Show floating workspace, new activity",
"5785dd9148": "フローティングワークスペースを最小限に抑える",
"bfe7809a70": "{{value0}} フローティング ワークスペース ({{value1}})"
},

View File

@ -10429,6 +10429,7 @@
},
"FloatingTerminalToggleButton": {
"3b04b065b5": "플로팅 워크스페이스 표시",
"4cb418b991": "Show floating workspace, new activity",
"5785dd9148": "플로팅 워크스페이스 최소화",
"bfe7809a70": "{{value0}} 플로팅 워크스페이스({{value1}})"
},

View File

@ -10429,6 +10429,7 @@
},
"FloatingTerminalToggleButton": {
"3b04b065b5": "显示浮动工作区",
"4cb418b991": "Show floating workspace, new activity",
"5785dd9148": "最小化浮动工作区",
"bfe7809a70": "{{value0}} 浮动工作区 ({{value1}})"
},

View File

@ -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<typeof selectFloatingWorkspaceHasUnread>[0]
function makeState(overrides: Partial<UnreadState>): 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)
})
})

View File

@ -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<string>()
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<AppState, 'worktreesByRepo'>): Worktree[] {
return getCachedAllWorktrees(state.worktreesByRepo)
}