From 7395d30dc7972c35045c36eb622bae1c803983a2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:58:52 -0400 Subject: [PATCH] The host raises the shared unread dot whatever each client's Filters say (dark) (#30581) * fix(sidebar): the host raises the shared unread dot regardless of Filters (dark) * fix(sidebar): tighten the shared-dot comment and tests after review --- .../orca-runtime-sidebar-temporal.ts | 1 - .../sort-stamps-producer.test.ts | 27 -------------- .../sidebar-inputs/sort-stamps-producer.ts | 8 ++--- .../runtime/workspace-notification-mute.ts | 3 +- .../workspaces/workspace-process-listing.ts | 2 +- .../hidden-workspace-notifications.spec.ts | 9 +++-- tests/e2e/sidebar-host-unread.spec.ts | 35 ++++++++++--------- 7 files changed, 31 insertions(+), 54 deletions(-) diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts index ec2096075c4..e3a21711249 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts @@ -48,7 +48,6 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork subscribeToPtyExit: (ptyId, listener) => this.subscribeToPtyExit(ptyId, listener), onClientEvent: (listener, options) => this.onClientEvent(listener, options), isViewing: (worktreeId, paneKey) => this.workspaceViewers.isViewing(worktreeId, paneKey), - isNotificationMuted: (worktreeId) => this.isWorkspaceNotificationMuted(worktreeId), onStampsWritten: (worktreeId) => this.notifySortStampsChanged(worktreeId), now: Date.now }) diff --git a/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts b/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts index e405d44164d..79aebdd7aae 100644 --- a/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts @@ -46,7 +46,6 @@ function createHarness() { let listener: (event: RuntimeClientEvent) => void = () => {} // Stamping tests watch every workspace, so no unread flag is raised; the unread tests unwatch. const viewing = { isViewing: (_id: string, _pane: string | null): boolean => true } - const muted = new Set() const store: SortStampsStore = { getWorktreeMeta: (id) => meta.get(id), setWorktreeMeta: (id, updates) => { @@ -83,7 +82,6 @@ function createHarness() { return () => {} }, isViewing: (id, pane) => viewing.isViewing(id, pane), - isNotificationMuted: (id) => muted.has(id), onStampsWritten: (id) => notified.push(id), now: () => Date.now() }) @@ -102,7 +100,6 @@ function createHarness() { fenced, notified, viewing, - muted, sideEffects: ( facts: TerminalSideEffectFact[], extra: { pane?: string; ptyId?: string; connectionId?: string } = {} @@ -632,30 +629,6 @@ describe('SortStampsProducer: unread flag', () => { expect(h.folders.get(FOLDER_ID)?.isUnread).toBe(true) }) - it('raises no flag for a muted workspace, but still stamps it', () => { - const h = unwatched() - h.muted.add(WT) - h.muted.add(FOLDER) - hook(h.server, 'working') - advance(20_000) - hook(h.server, 'done') - h.sideEffects([{ kind: 'bell' }]) - expect(h.meta.get(WT)?.isUnread).toBeUndefined() - expect(h.meta.get(WT)?.lastActivityAt).toBeGreaterThan(0) - - h.server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-a', - worktreeId: FOLDER, - payload: { state: 'waiting', prompt: 'task', agentType: 'codex' } - }) - expect(h.folders.get(FOLDER_ID)?.isUnread).toBeUndefined() - - h.muted.delete(WT) - h.sideEffects([{ kind: 'bell' }]) - expect(h.meta.get(WT)?.isUnread).toBe(true) - }) - it('raises nothing for a restored status, a frozen project or a workspace with no record', () => { const h = createHarness() h.viewing.isViewing = () => false diff --git a/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts b/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts index 67c9fd5e864..c8536ae55a3 100644 --- a/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts +++ b/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts @@ -49,8 +49,6 @@ export type SortStampsSources = { ) => () => void /** Whether a client is looking at the workspace, or at the pane's tab. */ isViewing: (worktreeId: string, paneKey: string | null) => boolean - /** "Notify About Hidden Workspaces" is off and the Filters hide it: news raises no flag. */ - isNotificationMuted: (worktreeId: string) => boolean /** A record's stamps or unread flag changed; clients re-list it. */ onStampsWritten: (worktreeId: string) => void now: () => number @@ -63,8 +61,8 @@ type TitlePane = { ptyId: string; releaseExit: () => void } * Writes the sort stamps of every workspace this host owns, at the source: the transition * detector's hook panes (terminal agents and structured chats), hookless agents' titles, bells, * and terminals a CLI starts. Creation stamps itself where the record is made. Rest panes are not - * kept. The same news raises the workspace's unread flag unless a client is looking at it or its - * notifications are muted; clients clear it. + * kept. The same news raises the workspace's unread flag unless a client is looking at it; clients + * clear it. */ export class SortStampsProducer { private readonly panes = new Map() @@ -301,10 +299,10 @@ export class SortStampsProducer { if (next) { writeSortStamps(this.sources.store(), workspace, next) } + // Filters never gate this shared dot (clients' Filters differ); banners, sounds and pushes mute per surface. const raised = isUnreadNews(event) && !this.sources.isViewing(workspace.worktreeId, paneKey) && - !this.sources.isNotificationMuted(workspace.worktreeId) && writeUnreadFlag(this.sources.store(), workspace, true) if (next || raised) { this.sources.onStampsWritten(workspace.worktreeId) diff --git a/src/main/runtime/workspace-notification-mute.ts b/src/main/runtime/workspace-notification-mute.ts index 0dbff402b6c..985d5554447 100644 --- a/src/main/runtime/workspace-notification-mute.ts +++ b/src/main/runtime/workspace-notification-mute.ts @@ -1,5 +1,6 @@ // The host's read of "Notify About Hidden Workspaces": with it off, news from a workspace the -// desktop's sidebar Filters switches hide raises nothing the host decides (phone push, unread flag). +// desktop's sidebar Filters switches hide sends no phone push from the host. The shared unread dot +// ignores it: clients' Filters differ. // The setting and the switches are this host's own persisted state; a paired client's never apply. // // WHY NOT "HIDE SLEEPING": main sees the persisted switches and its in-memory workspace records, diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 4f59cdbdb9c..52078926e78 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -198,7 +198,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr }) } - /** Host decisions (phone push, unread flag) raise nothing for this workspace; see the module. */ + /** The host pushes no phone notification for this workspace; see the module. */ isWorkspaceNotificationMuted(workspaceId: string): boolean { const store = this.store return ( diff --git a/tests/e2e/hidden-workspace-notifications.spec.ts b/tests/e2e/hidden-workspace-notifications.spec.ts index a2ad7da3434..77ef53fd791 100644 --- a/tests/e2e/hidden-workspace-notifications.spec.ts +++ b/tests/e2e/hidden-workspace-notifications.spec.ts @@ -4,6 +4,7 @@ import { execInTerminal, waitForActivePanePtyId } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log' import { waitForPtyShellEcho } from './terminal-pty-readiness' +import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build' type NotificationDispatch = { source?: string; worktreeId?: string } @@ -117,12 +118,16 @@ test.describe('Hidden workspace notifications', () => { await expect.poll(() => bellRequests(electronApp, worktreeId), { timeout: 5_000 }).toBe(1) expect(await isWorkspaceUnread(orcaPage, worktreeId)).toBe(true) - // Hidden and the setting off: no unread dot and no request, past the bell's 250 ms delay. + // Hidden and the setting off: no request, past the bell's 250 ms delay. The window raises no + // dot; a switch-on host raises its shared dot, which ignores every client's Filters. await setFilterAndSetting(orcaPage, true, false) await ringBellOffScreen(orcaPage, ptyId, worktreeId, 'hwn-hidden') + if (SIDEBAR_INPUTS_BUILD) { + await expect.poll(() => isWorkspaceUnread(orcaPage, worktreeId)).toBe(true) + } await orcaPage.waitForTimeout(1_500) expect(await bellRequests(electronApp, worktreeId)).toBe(1) - expect(await isWorkspaceUnread(orcaPage, worktreeId)).toBe(false) + expect(await isWorkspaceUnread(orcaPage, worktreeId)).toBe(SIDEBAR_INPUTS_BUILD) // Hidden with "Notify About Hidden Workspaces" on: both come back. await setFilterAndSetting(orcaPage, true, true) diff --git a/tests/e2e/sidebar-host-unread.spec.ts b/tests/e2e/sidebar-host-unread.spec.ts index 048756e4d1a..fa9bf6a1a4c 100644 --- a/tests/e2e/sidebar-host-unread.spec.ts +++ b/tests/e2e/sidebar-host-unread.spec.ts @@ -80,7 +80,7 @@ test('the host raises the unread flag where the window’s user is not looking, await expect.poll(unread).toBe(true) }) -test('the host raises no flag for a workspace the Filters hide while hidden workspaces stay quiet', async ({ +test('the host raises the flag even for a workspace the Filters hide while hidden workspaces stay quiet', async ({ orcaPage, electronApp }) => { @@ -94,20 +94,15 @@ test('the host raises no flag for a workspace the Filters hide while hidden work const rowKey = `local|${pane.worktreeId}` const unread = async (): Promise => (await readHostRow(orcaPage, rowKey))?.isUnread - const setNotifyHidden = (notify: boolean): Promise => - orcaPage.evaluate(async (value) => { - const state = window.__store!.getState() - await state.updateSettings({ - notifications: { ...state.settings!.notifications, notifyHiddenWorkspaces: value } - }) - }, notify) // The seeded workspace is the primary checkout, which "Hide default branch" hides. - await orcaPage.evaluate(() => { + await orcaPage.evaluate(async () => { const state = window.__store!.getState() state.setHideDefaultBranchWorkspace(true) state.setActiveView('tasks') + await state.updateSettings({ + notifications: { ...state.settings!.notifications, notifyHiddenWorkspaces: false } + }) }) - await setNotifyHidden(false) // The host reads the persisted switch, not the window's copy. await expect .poll(() => @@ -116,14 +111,20 @@ test('the host raises no flag for a workspace the Filters hide while hidden work .toBe(true) await expect.poll(unread).toBe(false) + // The flag is shared by every client, whatever its Filters; only banners and sounds are muted. await postClaudeHook(endpoint, pane, { hook_event_name: 'UserPromptSubmit', prompt: 'go' }) - await expect.poll(async () => (await readHostRow(orcaPage, rowKey))?.smartTier).toBe('working') - await postClaudeHook(endpoint, pane, { hook_event_name: 'Stop', last_assistant_message: 'ok' }) - await expect.poll(async () => (await readHostRow(orcaPage, rowKey))?.smartTier).toBe('done') - expect(await unread()).toBe(false) - - await setNotifyHidden(true) - await postClaudeHook(endpoint, pane, { hook_event_name: 'UserPromptSubmit', prompt: 'more' }) await postClaudeHook(endpoint, pane, { hook_event_name: 'Stop', last_assistant_message: 'ok' }) await expect.poll(unread).toBe(true) + // The window keeps the host's dot; its Filters mute only its own banner and sound. + await expect + .poll(() => + orcaPage.evaluate( + (id) => + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .find((worktree) => worktree.id === id)?.isUnread, + pane.worktreeId + ) + ) + .toBe(true) })