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:
parent
5c6f0ad52d
commit
8dbcb2be3e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -10429,6 +10429,7 @@
|
|||
},
|
||||
"FloatingTerminalToggleButton": {
|
||||
"3b04b065b5": "Show floating workspace",
|
||||
"4cb418b991": "Show floating workspace, new activity",
|
||||
"5785dd9148": "Minimize floating workspace",
|
||||
"bfe7809a70": "{{value0}} floating workspace ({{value1}})"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}})"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10429,6 +10429,7 @@
|
|||
},
|
||||
"FloatingTerminalToggleButton": {
|
||||
"3b04b065b5": "フローティングワークスペースを表示",
|
||||
"4cb418b991": "Show floating workspace, new activity",
|
||||
"5785dd9148": "フローティングワークスペースを最小限に抑える",
|
||||
"bfe7809a70": "{{value0}} フローティング ワークスペース ({{value1}})"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10429,6 +10429,7 @@
|
|||
},
|
||||
"FloatingTerminalToggleButton": {
|
||||
"3b04b065b5": "플로팅 워크스페이스 표시",
|
||||
"4cb418b991": "Show floating workspace, new activity",
|
||||
"5785dd9148": "플로팅 워크스페이스 최소화",
|
||||
"bfe7809a70": "{{value0}} 플로팅 워크스페이스({{value1}})"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10429,6 +10429,7 @@
|
|||
},
|
||||
"FloatingTerminalToggleButton": {
|
||||
"3b04b065b5": "显示浮动工作区",
|
||||
"4cb418b991": "Show floating workspace, new activity",
|
||||
"5785dd9148": "最小化浮动工作区",
|
||||
"bfe7809a70": "{{value0}} 浮动工作区 ({{value1}})"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue