diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 36b9c3bd68c..11440e01fe1 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -22,6 +22,7 @@ import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/struc import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { createSessionTabsTabDeltaEncoder } from '../../../../shared/session-tabs-tab-delta' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-switch' export const SESSION_TAB_METHODS = [ defineMethod({ @@ -128,6 +129,11 @@ export const SESSION_TAB_METHODS = [ // Why: shared-control can carry multiple subscribers for one worktree on // one socket; include the RPC id so one subscriber cannot evict another. subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix + // Switch point: a phone holding the workspace's session open is looking at it. + const closePhoneSession = + ORCA_SIDEBAR_INPUTS_IS_MAIN && clientKind === 'mobile' + ? runtime.sidebarFacts?.readAndHoldPhoneSession(worktreeId) + : undefined runtime.registerSubscriptionCleanup( subscriptionId, () => { @@ -135,6 +141,7 @@ export const SESSION_TAB_METHODS = [ return } released = true + closePhoneSession?.() signal?.removeEventListener('abort', release) stopListening() if (endOnRelease) { diff --git a/src/main/runtime/rpc/methods/sidebar-inputs.ts b/src/main/runtime/rpc/methods/sidebar-inputs.ts index fa3c5a35d00..3ee1519c57a 100644 --- a/src/main/runtime/rpc/methods/sidebar-inputs.ts +++ b/src/main/runtime/rpc/methods/sidebar-inputs.ts @@ -7,6 +7,7 @@ import { SidebarSessionBeginParams } from '../../../../shared/rpc-contract/sideb import { RepoIconDataUrlsParams } from '../../../../shared/sidebar-list/repo-icon-data-urls-contract' import { SetCollapsedParams } from '../../../../shared/rpc-contract/workspace-view-collapse-params' import { setCollapsedSection } from '../../workspace-view-collapse-writes' +import { ClientWorkspaceViewParams } from '../../../../shared/rpc-contract/client-workspace-view-params' // Registered only with ORCA_SIDEBAR_INPUTS=main, so the producers below always exist. @@ -101,6 +102,27 @@ export const SIDEBAR_INPUT_METHODS = [ handler: async (_params, { runtime }) => requireSwitchedOn(runtime.workspaceCreations).snapshot() }), + defineMethod({ + name: 'sidebarInputs.reportView', + permission: 'workspace', + params: ClientWorkspaceViewParams, + // What a client window shows, so this host raises no unread flag on it; held until it closes. + handler: async (params, { runtime, connectionId, signal }) => { + const viewers = requireSwitchedOn(runtime.sidebarFacts).workspaceViewers + // A view outlives the call, so it needs a connection whose close drops it. + if (connectionId === undefined || signal?.aborted) { + throw new Error('client_disconnected') + } + if (viewers.reportClientView(connectionId, params)) { + runtime.registerSubscriptionCleanup( + `sidebar-view-${connectionId}`, + () => viewers.dropClient(connectionId), + connectionId + ) + } + return { ok: true } + } + }), defineMethod({ name: 'ui.setCollapsed', permission: 'workspace', 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 94cfd7765ce..d3dbb62807c 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts @@ -7,7 +7,9 @@ import { getConnectionExecutionHostId, getRepoExecutionHostId, isLocalExecutionHost, - LOCAL_EXECUTION_HOST_ID + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId } from '../../../shared/execution-host' import type { SidebarTemporalSnapshot } from '../../../shared/sidebar-list/sidebar-temporal-snapshot' import type { Worktree } from '../../../shared/worktree/types' @@ -15,6 +17,8 @@ import type { Repo } from '../../../shared/repo-types' import { SidebarRetentionOwner, type SidebarRetentionCandidates } from './sidebar-retention-owner' import type { AgentStatusStoreView } from './workspace-activity-facts-producer' import { SortStampsProducer } from './sort-stamps-producer' +import { writeUnreadFlag } from './sort-stamp-records' +import { WorkspaceViewers } from './workspace-viewers' import { TerminalUseProducer } from './terminal-use-producer' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' @@ -36,6 +40,7 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork listRows: () => this.listSidebarRows(), retentionCandidates: () => this.readSidebarRetentionCandidates() }) + readonly workspaceViewers = new WorkspaceViewers(Date.now) readonly sortStamps = new SortStampsProducer({ store: () => this.store, isWriteFenced: (worktreeId) => @@ -43,6 +48,7 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork resolveTabWorktreeId: (tabId) => this.tabs.get(tabId)?.worktreeId, subscribeToPtyExit: (ptyId, listener) => this.subscribeToPtyExit(ptyId, listener), onClientEvent: (listener, options) => this.onClientEvent(listener, options), + isViewing: (worktreeId, paneKey) => this.workspaceViewers.isViewing(worktreeId, paneKey), onStampsWritten: (worktreeId) => this.notifySortStampsChanged(worktreeId), now: Date.now }) @@ -100,15 +106,7 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork await this.sortStamps .reconcileStoredTiers({ isLocalWorkspace: (worktreeId) => - !!store && - isLocalExecutionHost( - resolveAgentWorkspaceExecutionHostId(worktreeId, { - getRepo: (repoId) => store.getRepo(repoId), - getWorktreeMeta: (id) => store.getWorktreeMeta(id), - getFolderWorkspace: (id) => store.getFolderWorkspace?.(id), - getProjectGroups: () => store.getProjectGroups?.() ?? [] - }) - ), + !!store && isLocalExecutionHost(this.workspaceHostId(worktreeId)), terminalPtyIds: (worktreeId) => { const session = store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) const ptyIds = new Set() @@ -135,6 +133,31 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork .catch((error: unknown) => console.warn('[sidebar] sort tier reconcile failed:', error)) } + /** A phone holding the workspace's session open has it on screen: its flag is read and stays down. */ + readAndHoldPhoneSession(worktreeId: string): () => void { + const hostId = this.workspaceHostId(worktreeId) + const workspace = { + worktreeId, + sshHostId: hostId && parseExecutionHostId(hostId)?.kind === 'ssh' ? hostId : null + } + if (writeUnreadFlag(this.store, workspace, false)) { + this.notifySortStampsChanged(worktreeId) + } + return this.workspaceViewers.openPhoneSession(worktreeId) + } + + private workspaceHostId(worktreeId: string): ExecutionHostId | null { + const store = this.store + return store + ? resolveAgentWorkspaceExecutionHostId(worktreeId, { + getRepo: (repoId) => store.getRepo(repoId), + getWorktreeMeta: (id) => store.getWorktreeMeta(id), + getFolderWorkspace: (id) => store.getFolderWorkspace?.(id), + getProjectGroups: () => store.getProjectGroups?.() ?? [] + }) + : null + } + // Metadata only: clients re-list the record, and the cached git scans stay. private notifySortStampsChanged(worktreeId: string): void { if (parseWorkspaceKey(worktreeId)?.type === 'folder') { diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts index 3b644580453..f6543d6a603 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts @@ -98,7 +98,13 @@ describe('host sort stamps on a headless runtime', () => { worktreeId: WT, payload: { state: 'waiting', prompt: 'task', agentType: 'claude' } }) - expect(meta[WT]).toEqual({ lastActivityAt: 70_000, smartTier: 'needs-you', smartAt: 70_000 }) + // No client is looking, so the request for input also raises the unread flag. + expect(meta[WT]).toEqual({ + lastActivityAt: 70_000, + smartTier: 'needs-you', + smartAt: 70_000, + isUnread: true + }) } finally { vi.useRealTimers() } diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts new file mode 100644 index 00000000000..5cb566467ef --- /dev/null +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts @@ -0,0 +1,184 @@ +// Runs on plain Node, as orcad does: a host with no window raises and clears its own unread flags. +import '../rpc/unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-session-contracts' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { AgentHookServer } from '../../agent-hooks/server' +import { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeStore } from '../runtime-store-contract' +import { RpcDispatcher } from '../rpc/dispatcher' +import type { RpcDispatchStreamingOptions } from '../rpc/dispatcher-stream-options' +import { SESSION_TAB_METHODS } from '../rpc/methods/session-tabs' +import { SIDEBAR_INPUT_METHODS } from '../rpc/methods/sidebar-inputs' + +vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) + +const WT = 'repo-1::/tmp/worktree-a' +const SSH_WT = 'repo-ssh::/home/me/worktree-b' +const FOLDER_ID = '11111111-1111-4111-8111-111111111111' +const FOLDER = folderWorkspaceKey(FOLDER_ID) +const LEAF = '22222222-2222-4222-8222-222222222222' +const PANE = `tab-1:${LEAF}` +const PHONE: RpcDispatchStreamingOptions = { clientKind: 'mobile', connectionId: 'phone-1' } + +type Flag = { lastActivityAt: number; isUnread: boolean } + +function createHeadlessHost() { + const meta: Record = { [WT]: { lastActivityAt: 0, isUnread: false } } + const sshMeta: Record = { [SSH_WT]: { lastActivityAt: 0, isUnread: false } } + const folders: Record = { + [FOLDER_ID]: { lastActivityAt: 0, isUnread: false, projectGroupId: 'group-1' } + } + const repos = [ + { id: 'repo-1', connectionId: null }, + { id: 'repo-ssh', connectionId: 'build-box' } + ] + const store = { + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getProjectGroups: () => [{ id: 'group-1', connectionId: null }], + getSettings: () => ({}), + getWorktreeMeta: (id: string) => meta[id], + getAllWorktreeMeta: () => meta, + setWorktreeMeta: (id: string, updates: Partial) => Object.assign(meta[id], updates), + getWorktreeMetaForHost: (id: string, hostId: string) => + hostId === 'ssh:build-box' ? sshMeta[id] : undefined, + setWorktreeMetaForHost: (id: string, _hostId: string, updates: Partial) => + Object.assign(sshMeta[id], updates), + getFolderWorkspace: (id: string) => folders[id], + updateFolderWorkspace: (id: string, updates: Partial) => + Object.assign(folders[id], updates) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the unread path reads only these members. + const runtime = new OrcaRuntimeService(store as unknown as RuntimeStore) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { + tabs: [{ tabId: 'tab-1', worktreeId: WT, title: 'Agent', activeLeafId: LEAF, layout: null }], + leaves: [ + { + tabId: 'tab-1', + worktreeId: WT, + leafId: LEAF, + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: null + } + ] + }) + const server = new AgentHookServer() + runtime.sidebarFacts?.attachSidebarAgentStatusStore(server) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [...SIDEBAR_INPUT_METHODS, ...SESSION_TAB_METHODS] + }) + let requestId = 0 + const finishTurn = (worktreeId = WT, connectionId?: string): void => { + for (const state of ['working', 'done'] as const) { + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId, + ...(connectionId ? { connectionId } : {}), + payload: { state, prompt: 'task', agentType: 'claude' } + }) + } + } + return { + runtime, + meta, + sshMeta, + folders, + finishTurn, + // The phone's session screen holds this stream while the workspace is open on it. + openOnPhone: (worktreeId: string) => + dispatcher.dispatchStreaming( + { + id: `sub-${++requestId}`, + authToken: 'tok', + method: 'session.tabs.subscribe', + params: { worktree: `id:${worktreeId}` } + }, + () => {}, + PHONE + ), + reportView: (params: unknown, connectionId: string | undefined) => + dispatcher.dispatch( + { id: `view-${++requestId}`, authToken: 'tok', method: 'sidebarInputs.reportView', params }, + { clientKind: 'runtime', connectionId } + ) + } +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('workspace unread flag on a headless host', () => { + it('flags a finish no one sees, clears it when the phone opens it, and spares it while open', async () => { + const host = createHeadlessHost() + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(true) + + await host.openOnPhone(WT) + expect(host.meta[WT].isUnread).toBe(false) + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(false) + + // Leaving the session (its stream closes with the connection) makes the next finish news. + host.runtime.cleanupSubscriptionsForConnection('phone-1') + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(true) + }) + + it('clears a folder workspace and an SSH worktree on their own records when the phone opens them', async () => { + const host = createHeadlessHost() + host.folders[FOLDER_ID].isUnread = true + host.sshMeta[SSH_WT].isUnread = true + await host.openOnPhone(FOLDER) + expect(host.folders[FOLDER_ID].isUnread).toBe(false) + await host.openOnPhone(SSH_WT) + expect(host.sshMeta[SSH_WT].isUnread).toBe(false) + + // The SSH finish raises the SSH host's record once the phone has left. + host.runtime.cleanupSubscriptionsForConnection('phone-1') + host.finishTurn(SSH_WT, 'build-box') + expect(host.sshMeta[SSH_WT].isUnread).toBe(true) + }) + + it('spares the tab a client window shows while its user is present', async () => { + vi.useFakeTimers({ now: 10_000, toFake: ['Date'] }) + try { + const host = createHeadlessHost() + const view = { workspaceIds: [WT], tabIds: ['tab-1'], presentForMs: 60_000 } + expect(await host.reportView(view, 'desktop-1')).toMatchObject({ ok: true }) + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(false) + + // Another tab on screen is not this pane's. + await host.reportView({ ...view, tabIds: ['tab-2'] }, 'desktop-1') + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(true) + + host.meta[WT].isUnread = false + await host.reportView(view, 'desktop-1') + vi.setSystemTime(10_000 + 60_000) + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(true) + + // A client that disconnects stops counting; one with no connection cannot report. + expect(await host.reportView(view, undefined)).toMatchObject({ ok: false }) + host.meta[WT].isUnread = false + await host.reportView({ ...view, presentForMs: null }, 'desktop-1') + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(false) + host.runtime.cleanupSubscriptionsForConnection('desktop-1') + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(true) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/sidebar-inputs/sort-stamp-records.ts b/src/main/runtime/sidebar-inputs/sort-stamp-records.ts index fe8a2dd7665..afbb7da86f1 100644 --- a/src/main/runtime/sidebar-inputs/sort-stamp-records.ts +++ b/src/main/runtime/sidebar-inputs/sort-stamp-records.ts @@ -6,7 +6,7 @@ import type { } from '../../../shared/sidebar-list/sort-stamps' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' -type StampedRecord = { lastActivityAt: number } & SmartStampFields +type StampedRecord = { lastActivityAt: number; isUnread?: boolean } & SmartStampFields type StampUpdates = { lastActivityAt: number smartTier: SmartTier | undefined @@ -27,8 +27,10 @@ export type WorkspaceRecordStore = { updateFolderWorkspace?: (folderWorkspaceId: string, updates: TUpdates) => unknown } -/** The record reads and writes stamping needs. */ -export type SortStampsStore = WorkspaceRecordStore & { +type UnreadUpdate = { isUnread: boolean } + +/** The record reads and writes stamping and the unread flag need. */ +export type SortStampsStore = WorkspaceRecordStore & { getAllWorktreeMeta?: () => Record getFolderWorkspaces?: () => readonly (StampedRecord & { id: string })[] } @@ -64,6 +66,21 @@ export function writeWorkspaceRecord( } } +/** Sets the workspace's unread flag; true when its record held the other value. */ +export function writeUnreadFlag( + store: SortStampsStore | null, + workspace: WorkspaceRef, + isUnread: boolean +): boolean { + const record = readWorkspaceRecord(store, workspace) + // Only a workspace with a record is written; writing would create one for a stale id. + if (!record || Boolean(record.isUnread) === isUnread) { + return false + } + writeWorkspaceRecord(store, workspace, { isUnread }) + return true +} + // Only a workspace with a record is stamped; writing would create one for a stale id. export function readSortStamps( store: SortStampsStore | null, 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 b061b081c06..a6445352537 100644 --- a/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts @@ -22,7 +22,7 @@ const PANE = 'tab-a:22222222-2222-4222-8222-222222222222' const TITLE_PANE = 'tab-t:33333333-3333-4333-8333-333333333333' const T0 = 1_000_000 -type Stamps = { lastActivityAt: number } & SmartStampFields +type Stamps = { lastActivityAt: number; isUnread?: boolean } & SmartStampFields const SSH_HOST = 'ssh:build-box' function createHarness() { @@ -36,21 +36,23 @@ function createHarness() { const fenced = new Set() const notified: string[] = [] 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 store: SortStampsStore = { getWorktreeMeta: (id) => meta.get(id), setWorktreeMeta: (id, updates) => { writes.push(id) - meta.set(id, { ...meta.get(id), ...updates }) + meta.set(id, { lastActivityAt: 0, ...meta.get(id), ...updates }) }, getWorktreeMetaForHost: (id, hostId) => (hostId === SSH_HOST ? sshMeta.get(id) : undefined), setWorktreeMetaForHost: (id, hostId, updates) => { writes.push(`${hostId}|${id}`) - sshMeta.set(id, { ...sshMeta.get(id), ...updates }) + sshMeta.set(id, { lastActivityAt: 0, ...sshMeta.get(id), ...updates }) }, getFolderWorkspace: (id) => folders.get(id), updateFolderWorkspace: (id, updates) => { writes.push(`folder:${id}`) - folders.set(id, { ...folders.get(id), ...updates }) + folders.set(id, { lastActivityAt: 0, ...folders.get(id), ...updates }) }, getAllWorktreeMeta: () => Object.fromEntries(meta), getFolderWorkspaces: () => [...folders].map(([id, folder]) => ({ id, ...folder })) @@ -68,6 +70,7 @@ function createHarness() { listener = next return () => {} }, + isViewing: (id, pane) => viewing.isViewing(id, pane), onStampsWritten: (id) => notified.push(id), now: () => Date.now() }) @@ -82,6 +85,7 @@ function createHarness() { exits, fenced, notified, + viewing, sideEffects: ( facts: TerminalSideEffectFact[], extra: { pane?: string; ptyId?: string; connectionId?: string } = {} @@ -455,3 +459,86 @@ describe('SortStampsProducer: startup reconcile', () => { expect(h.writes).toEqual([]) }) }) + +describe('SortStampsProducer: unread flag', () => { + function unwatched() { + const h = createHarness() + h.viewing.isViewing = () => false + h.producer.attachAgentStatusStore(h.server) + return h + } + + it('raises the flag on a finish, a request for input and a bell; never on a prompt', () => { + const h = unwatched() + hook(h.server, 'working') + expect(h.meta.get(WT)?.isUnread).toBeUndefined() + + advance(20_000) + hook(h.server, 'done') + expect(h.meta.get(WT)?.isUnread).toBe(true) + // The stamps and the flag are one change to the record. + expect(h.notified).toEqual([WT, WT]) + + for (const news of [() => hook(h.server, 'blocked'), () => h.sideEffects([{ kind: 'bell' }])]) { + h.meta.set(WT, { ...h.meta.get(WT)!, isUnread: false }) + hook(h.server, 'working') + news() + expect(h.meta.get(WT)?.isUnread).toBe(true) + } + + // A flag already up is not written again. + const writes = h.writes.length + h.sideEffects([{ kind: 'bell' }]) + expect(h.writes).toHaveLength(writes) + }) + + it('leaves the flag down where someone is looking: the pane’s tab, or a bell’s workspace', () => { + const h = unwatched() + const seen: [string, string | null][] = [] + h.viewing.isViewing = (id, pane) => { + seen.push([id, pane]) + return pane === PANE + } + hook(h.server, 'working') + advance(20_000) + hook(h.server, 'done') + expect(h.meta.get(WT)?.isUnread).toBeUndefined() + + h.sideEffects([{ kind: 'bell' }]) + expect(h.meta.get(WT)?.isUnread).toBe(true) + expect(seen).toEqual([ + [WT, PANE], + [WT, TITLE_PANE] + ]) + }) + + it('raises an SSH workspace’s flag on its own host’s record, and a folder’s on the folder', () => { + const h = unwatched() + h.sideEffects([{ kind: 'bell' }], { connectionId: 'build-box' }) + expect(h.sshMeta.get(WT)?.isUnread).toBe(true) + expect(h.meta.get(WT)?.isUnread).toBeUndefined() + + h.server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-a', + worktreeId: FOLDER, + payload: { state: 'waiting', prompt: 'task', agentType: 'codex' } + }) + expect(h.folders.get(FOLDER_ID)?.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 + hook(h.server, 'blocked') + h.producer.attachAgentStatusStore(h.server) + expect(h.meta.get(WT)?.isUnread).toBeUndefined() + + h.fenced.add(WT) + h.sideEffects([{ kind: 'bell' }]) + expect(h.meta.get(WT)?.isUnread).toBeUndefined() + + hook(h.server, 'done', 'repo-1::/tmp/gone') + expect(h.writes).not.toContain('repo-1::/tmp/gone') + }) +}) diff --git a/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts b/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts index fe42bd79ea9..0b8b0cc4709 100644 --- a/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts +++ b/src/main/runtime/sidebar-inputs/sort-stamps-producer.ts @@ -10,6 +10,7 @@ import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' import { classifySortStampEvent, hookRowAgentPhase, + isUnreadNews, nextAgentPaneState, REST_PANE, titleAgentPhase, @@ -23,6 +24,7 @@ import { listTieredWorkspaces, readSortStamps, writeSortStamps, + writeUnreadFlag, type SortStampsStore, type WorkspaceRef } from './sort-stamp-records' @@ -50,7 +52,9 @@ export type SortStampsSources = { listener: (event: RuntimeClientEvent) => void, options: { consumesTerminalSideEffects: true } ) => () => void - /** A record's stamps changed; clients re-list it. */ + /** Whether a client is looking at the workspace, or at the pane's tab. */ + isViewing: (worktreeId: string, paneKey: string | null) => boolean + /** A record's stamps or unread flag changed; clients re-list it. */ onStampsWritten: (worktreeId: string) => void now: () => number } @@ -70,7 +74,8 @@ function sameWorkspace(a: WorkspaceRef | undefined, b: WorkspaceRef | null): boo /** * Writes the sort stamps of every workspace this host owns, at the source: hook-store transitions * (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. + * 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; clients clear it. */ export class SortStampsProducer { private readonly panes = new Map() @@ -203,7 +208,7 @@ export class SortStampsProducer { const workspace = { worktreeId, sshHostId: sshHostOfConnection(batch.connectionId) } for (const fact of batch.facts) { if (fact.kind === 'bell') { - this.stamp(workspace, { kind: 'bell' }) + this.stamp(workspace, { kind: 'bell' }, this.sources.now(), paneKey ?? null) } else if (fact.kind === 'title' && paneKey && !this.latestHookRow(paneKey)) { // Hook authority is per pane, as in Smart's attention. A cleared stale working title is a // timer's guess, not a finish. @@ -309,14 +314,15 @@ export class SortStampsProducer { } else { this.panes.set(paneKey, { ...workspace, state }) } - this.writeAgentChange(workspace, before.phase, state.phase, at) + this.writeAgentChange(workspace, before.phase, state.phase, at, paneKey) } private writeAgentChange( workspace: WorkspaceRef, from: AgentPhase, to: AgentPhase, - now: number + now: number, + paneKey: string | null = null ): void { const panes: AgentPaneState[] = [] for (const pane of this.panes.values()) { @@ -324,11 +330,16 @@ export class SortStampsProducer { panes.push(pane.state) } } - this.stamp(workspace, { kind: 'agent', from, to, panes }, now) + this.stamp(workspace, { kind: 'agent', from, to, panes }, now, paneKey) } // One clock read per event, so a pane's time and its Recent stamp agree. - private stamp(workspace: WorkspaceRef, event: SortStampEvent, now = this.sources.now()): void { + private stamp( + workspace: WorkspaceRef, + event: SortStampEvent, + now = this.sources.now(), + paneKey: string | null = null + ): void { // A WSL move only takes this machine's projects. if (!workspace.sshHostId && this.sources.isWriteFenced(workspace.worktreeId)) { return @@ -337,6 +348,12 @@ export class SortStampsProducer { const next = current && classifySortStampEvent(current, event, now) if (next) { writeSortStamps(this.sources.store(), workspace, next) + } + const raised = + isUnreadNews(event) && + !this.sources.isViewing(workspace.worktreeId, paneKey) && + writeUnreadFlag(this.sources.store(), workspace, true) + if (next || raised) { this.sources.onStampsWritten(workspace.worktreeId) } } diff --git a/src/main/runtime/sidebar-inputs/workspace-viewers.test.ts b/src/main/runtime/sidebar-inputs/workspace-viewers.test.ts new file mode 100644 index 00000000000..f8776f282d0 --- /dev/null +++ b/src/main/runtime/sidebar-inputs/workspace-viewers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { WorkspaceViewers } from './workspace-viewers' + +const WT = 'repo-1::/tmp/a' +const PANE = 'tab-a:22222222-2222-4222-8222-222222222222' +const OTHER_PANE = 'tab-b:33333333-3333-4333-8333-333333333333' + +function viewers() { + const clock = { now: 1_000 } + return { clock, viewers: new WorkspaceViewers(() => clock.now) } +} + +describe('WorkspaceViewers', () => { + it('sees a pane whose tab a client shows, and a workspace only for news with no pane', () => { + const { viewers: v } = viewers() + expect( + v.reportClientView('c1', { workspaceIds: [WT], tabIds: ['tab-a'], presentForMs: null }) + ).toBe(true) + expect(v.isViewing(WT, PANE)).toBe(true) + expect(v.isViewing(WT, OTHER_PANE)).toBe(false) + expect(v.isViewing(WT, null)).toBe(true) + expect(v.isViewing('repo-1::/tmp/b', null)).toBe(false) + // A later report replaces the view and needs no second tie to the connection. + expect(v.reportClientView('c1', { workspaceIds: [], tabIds: [], presentForMs: null })).toBe( + false + ) + expect(v.isViewing(WT, PANE)).toBe(false) + }) + + it('stops seeing once the user’s presence lapses or the client goes', () => { + const { clock, viewers: v } = viewers() + v.reportClientView('c1', { workspaceIds: [WT], tabIds: ['tab-a'], presentForMs: 500 }) + expect(v.isViewing(WT, PANE)).toBe(true) + clock.now += 500 + expect(v.isViewing(WT, PANE)).toBe(false) + + v.reportClientView('c1', { workspaceIds: [WT], tabIds: ['tab-a'], presentForMs: null }) + v.dropClient('c1') + expect(v.isViewing(WT, PANE)).toBe(false) + }) + + it('sees every pane of a workspace a phone holds open, by any spelling of its path', () => { + const { viewers: v } = viewers() + const first = v.openPhoneSession('repo-1::/tmp/a/') + const second = v.openPhoneSession(WT) + expect(v.isViewing(WT, OTHER_PANE)).toBe(true) + first() + first() + expect(v.isViewing(WT, PANE)).toBe(true) + second() + expect(v.isViewing(WT, PANE)).toBe(false) + }) +}) diff --git a/src/main/runtime/sidebar-inputs/workspace-viewers.ts b/src/main/runtime/sidebar-inputs/workspace-viewers.ts new file mode 100644 index 00000000000..5cc7389355b --- /dev/null +++ b/src/main/runtime/sidebar-inputs/workspace-viewers.ts @@ -0,0 +1,71 @@ +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { worktreeIdentityKey } from '../../../shared/worktree/id' +import type { ClientWorkspaceView } from '../../../shared/rpc-contract/client-workspace-view-params' + +type ClientView = { workspaceKeys: Set; tabIds: Set; presentUntil: number } + +/** + * Who is looking at this host's workspaces: client windows by what they report on screen, and + * phones by the session they hold open. The host's unread flag skips what someone is looking at. + * Workspaces are keyed by identity, so path spellings of one checkout match. + */ +export class WorkspaceViewers { + private readonly clients = new Map() + private readonly phoneSessions = new Map() + + constructor(private readonly now: () => number) {} + + /** Replaces the client's view; true the first time, so the caller ties it to the connection. */ + reportClientView(clientKey: string, view: ClientWorkspaceView): boolean { + const known = this.clients.has(clientKey) + this.clients.set(clientKey, { + workspaceKeys: new Set(view.workspaceIds.map(worktreeIdentityKey)), + tabIds: new Set(view.tabIds), + presentUntil: + view.presentForMs === null ? Number.POSITIVE_INFINITY : this.now() + view.presentForMs + }) + return !known + } + + dropClient(clientKey: string): void { + this.clients.delete(clientKey) + } + + /** A phone's open session on the workspace, until the returned release. */ + openPhoneSession(worktreeId: string): () => void { + const key = worktreeIdentityKey(worktreeId) + this.phoneSessions.set(key, (this.phoneSessions.get(key) ?? 0) + 1) + let open = true + return () => { + if (!open) { + return + } + open = false + const remaining = (this.phoneSessions.get(key) ?? 1) - 1 + if (remaining > 0) { + this.phoneSessions.set(key, remaining) + } else { + this.phoneSessions.delete(key) + } + } + } + + /** As the window's own rule: a pane is seen when its tab is on screen, else the workspace. */ + isViewing(worktreeId: string, paneKey: string | null): boolean { + const key = worktreeIdentityKey(worktreeId) + if (this.phoneSessions.has(key)) { + return true + } + const tabId = paneKey === null ? null : (parsePaneKey(paneKey)?.tabId ?? null) + const now = this.now() + for (const view of this.clients.values()) { + if ( + now < view.presentUntil && + (tabId === null ? view.workspaceKeys.has(key) : view.tabIds.has(tabId)) + ) { + return true + } + } + return false + } +} diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx index 700bd5bbeef..4a94b3a59c0 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx @@ -71,7 +71,9 @@ beforeEach(() => { mocks.listSessions.mockReset() mocks.listSessions.mockReturnValue(new Promise(() => {})) Object.assign(window, { - api: { aiVault: { listSessions: mocks.listSessions, cancelListSessions: vi.fn(async () => {}) } } + api: { + aiVault: { listSessions: mocks.listSessions, cancelListSessions: vi.fn(async () => {}) } + } }) }) diff --git a/src/renderer/src/hooks/agent-auto-ack-presence.ts b/src/renderer/src/hooks/agent-auto-ack-presence.ts index 220afb462a6..a47ed4dcf27 100644 --- a/src/renderer/src/hooks/agent-auto-ack-presence.ts +++ b/src/renderer/src/hooks/agent-auto-ack-presence.ts @@ -22,6 +22,13 @@ export function isPresenceConfirmed(): boolean { return !presenceGated || Date.now() - presentAt < DESKTOP_AWAY_SECONDS * 1000 } +/** How long presence holds without new input; null where it is not gated (browsers). */ +export function presenceRemainingMs(): number | null { + return presenceGated + ? Math.max(0, Math.round(DESKTOP_AWAY_SECONDS * 1000 - (Date.now() - presentAt))) + : null +} + export function createAutoAckPresenceCheck( readAway: () => Promise, onPresent: () => void diff --git a/src/renderer/src/hooks/client-workspace-view-report.test.ts b/src/renderer/src/hooks/client-workspace-view-report.test.ts new file mode 100644 index 00000000000..799808ea67a --- /dev/null +++ b/src/renderer/src/hooks/client-workspace-view-report.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { ClientWorkspaceView } from '../../../shared/rpc-contract/client-workspace-view-params' + +const presence = vi.hoisted((): { remaining: number | null } => ({ remaining: 180_000 })) + +vi.mock('./agent-auto-ack-presence', () => ({ presenceRemainingMs: () => presence.remaining })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) +vi.mock('@/store/slices/workspace-attention/workspace-unread-flag', () => ({ + onScreenHostId: () => null +})) +vi.mock('@/store/slices/worktrees/listing/worktree-owner-target', () => ({ + // `server:` workspaces belong to a paired server; the rest to this app. + tryRuntimeTargetForWorktreeOwner: (_state: unknown, workspaceId: string) => + workspaceId.startsWith('server:') + ? { kind: 'environment', environmentId: 'env-1' } + : workspaceId === 'unowned' + ? null + : { kind: 'local' } +})) + +const { createClientWorkspaceViewReporter } = await import('./client-workspace-view-report') + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: owner lookup is mocked above, so no state is read. +const STATE = {} as AppState + +function reporter() { + const sent: { target: RuntimeClientTarget; view: ClientWorkspaceView }[] = [] + const report = createClientWorkspaceViewReporter(async (target, view) => { + sent.push({ target, view }) + }) + return { sent, report } +} + +beforeEach(() => { + vi.useFakeTimers({ now: 1_000_000, toFake: ['Date'] }) + presence.remaining = 180_000 +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createClientWorkspaceViewReporter', () => { + it('sends each owning host only its own workspaces and tabs, and only when they change', () => { + const { sent, report } = reporter() + const targets = [ + { tabId: 'tab-a', worktreeId: 'local-wt', surfaceKind: 'terminal' as const }, + { tabId: 'tab-s', worktreeId: 'server:wt', surfaceKind: 'structured' as const }, + { tabId: 'tab-x', worktreeId: null, surfaceKind: 'terminal' as const } + ] + report.update(STATE, ['local-wt', 'server:wt', 'unowned'], targets) + expect(sent).toEqual([ + { + target: { kind: 'local' }, + view: { workspaceIds: ['local-wt'], tabIds: ['tab-a'], presentForMs: 190_000 } + }, + { + target: { kind: 'environment', environmentId: 'env-1' }, + view: { workspaceIds: ['server:wt'], tabIds: ['tab-s'], presentForMs: 190_000 } + } + ]) + report.update(STATE, ['local-wt', 'server:wt', 'unowned'], targets) + expect(sent).toHaveLength(2) + + // The server's workspace left the screen: it hears an empty view, once. + report.update(STATE, ['local-wt'], targets.slice(0, 1)) + report.update(STATE, ['local-wt'], targets.slice(0, 1)) + expect(sent.slice(2)).toEqual([ + { + target: { kind: 'environment', environmentId: 'env-1' }, + view: { workspaceIds: [], tabIds: [], presentForMs: 190_000 } + } + ]) + }) + + it('refreshes the host’s copy at most every ten seconds of activity', () => { + const { sent, report } = reporter() + report.update(STATE, ['local-wt'], []) + vi.setSystemTime(Date.now() + 9_000) + report.renewPresence() + expect(sent).toHaveLength(1) + + vi.setSystemTime(Date.now() + 1_000) + report.renewPresence() + expect(sent).toHaveLength(2) + expect(sent[1].view.presentForMs).toBe(190_000) + }) + + it('reports an away user’s screen with no presence, and renews as soon as input returns', () => { + const { sent, report } = reporter() + presence.remaining = 0 + report.update(STATE, ['local-wt'], []) + expect(sent[0].view.presentForMs).toBe(0) + report.renewPresence() + expect(sent).toHaveLength(1) + presence.remaining = 180_000 + report.renewPresence() + expect(sent).toHaveLength(2) + }) + + it('refreshes where presence is not tracked too, so a reconnected host learns the view', () => { + const { sent, report } = reporter() + presence.remaining = null + report.update(STATE, ['local-wt'], []) + vi.setSystemTime(Date.now() + 10_000) + report.renewPresence() + expect(sent).toHaveLength(2) + expect(sent[1].view.presentForMs).toBeNull() + }) + + it('retries a failed send, empty or not, no sooner than a refresh', async () => { + const sent: ClientWorkspaceView[] = [] + let fail = true + const report = createClientWorkspaceViewReporter(async (_target, view) => { + sent.push(view) + if (fail) { + throw new Error('method_not_found') + } + }) + report.update(STATE, ['local-wt'], []) + await Promise.resolve() + report.renewPresence() + expect(sent).toHaveLength(1) + vi.setSystemTime(Date.now() + 10_000) + report.renewPresence() + expect(sent).toHaveLength(2) + + fail = false + vi.setSystemTime(Date.now() + 10_000) + report.renewPresence() + await Promise.resolve() + expect(sent).toHaveLength(3) + + // The workspace left the screen and the empty view failed: it is resent too. + fail = true + report.update(STATE, [], []) + await Promise.resolve() + fail = false + vi.setSystemTime(Date.now() + 10_000) + report.renewPresence() + expect(sent).toHaveLength(5) + expect(sent.at(-1)?.workspaceIds).toEqual([]) + }) +}) diff --git a/src/renderer/src/hooks/client-workspace-view-report.ts b/src/renderer/src/hooks/client-workspace-view-report.ts new file mode 100644 index 00000000000..1d70ceb5f89 --- /dev/null +++ b/src/renderer/src/hooks/client-workspace-view-report.ts @@ -0,0 +1,146 @@ +import type { AppState } from '@/store/types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { ClientWorkspaceView } from '../../../shared/rpc-contract/client-workspace-view-params' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { tryRuntimeTargetForWorktreeOwner } from '@/store/slices/worktrees/listing/worktree-owner-target' +import { onScreenHostId } from '@/store/slices/workspace-attention/workspace-unread-flag' +import { presenceRemainingMs, subscribeAutoAckPresenceSignals } from './agent-auto-ack-presence' +import type { AutoAckTabTarget } from './agent-auto-ack-targets' + +// While the user is active, a host's copy of the view is resent this often: its presence trails the +// window's by at most this, and a host that lost the connection (and so the view) learns it again. +const REFRESH_MS = 10_000 + +type HostView = { + target: RuntimeClientTarget + workspaceIds: string[] + tabIds: string[] + signature: string +} +/** `signature` null: the send failed, and is retried once REFRESH_MS has passed. */ +type SentView = { + target: RuntimeClientTarget + signature: string | null + sentAt: number + present: boolean +} + +export type ClientWorkspaceViewReporter = { + /** The scan's screen: its workspaces and tabs, empty while the window is hidden or unfocused. */ + update: ( + state: AppState, + workspaceIds: readonly string[], + targets: readonly AutoAckTabTarget[] + ) => void + /** Trusted input arrived. */ + renewPresence: () => void +} + +function targetKey(target: RuntimeClientTarget): string { + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} + +/** + * Tells each host that owns an on-screen workspace what this window shows of it, so the host + * raises no unread flag there. A host whose workspaces left the screen is sent an empty view. + */ +export function createClientWorkspaceViewReporter( + send: (target: RuntimeClientTarget, view: ClientWorkspaceView) => Promise = ( + target, + view + ) => callRuntimeRpc(target, 'sidebarInputs.reportView', view) +): ClientWorkspaceViewReporter { + let views = new Map() + const sent = new Map() + + const flush = (): void => { + const now = Date.now() + const presentForMs = presenceRemainingMs() + const present = presentForMs !== 0 + for (const key of new Set([...sent.keys(), ...views.keys()])) { + const view = views.get(key) + const previous = sent.get(key) + const target = view?.target ?? previous?.target + const failed = previous?.signature === null + if (!target || (failed && now - previous.sentAt < REFRESH_MS)) { + continue + } + const refreshes = + view !== undefined && + previous !== undefined && + present && + (!previous.present || now - previous.sentAt >= REFRESH_MS) + if (!failed && previous?.signature === view?.signature && !refreshes) { + continue + } + const entry = view && { target, signature: view.signature, sentAt: now, present } + if (entry) { + sent.set(key, entry) + } else { + sent.delete(key) + } + // An older host has no reportView; a failed send, empty or not, is retried no sooner than + // REFRESH_MS, so input never turns into a stream of doomed calls. + void send(target, { + workspaceIds: view?.workspaceIds ?? [], + tabIds: view?.tabIds ?? [], + // Held one refresh longer than the window's, so the host never drops a present user first. + presentForMs: presentForMs ? presentForMs + REFRESH_MS : presentForMs + }).catch(() => { + if (sent.get(key) === entry) { + sent.set(key, { target, signature: null, sentAt: now, present }) + } + }) + } + } + + return { + update: (state, workspaceIds, targets) => { + const next = new Map() + const hostView = (workspaceId: string): HostView | null => { + const target = tryRuntimeTargetForWorktreeOwner( + state, + workspaceId, + onScreenHostId(state, workspaceId) ?? undefined + ) + if (!target) { + return null + } + const key = targetKey(target) + let view = next.get(key) + if (!view) { + view = { target, workspaceIds: [], tabIds: [], signature: '' } + next.set(key, view) + } + return view + } + for (const workspaceId of workspaceIds) { + hostView(workspaceId)?.workspaceIds.push(workspaceId) + } + for (const target of targets) { + if (target.worktreeId !== null) { + hostView(target.worktreeId)?.tabIds.push(target.tabId) + } + } + for (const view of next.values()) { + view.signature = JSON.stringify([view.workspaceIds, view.tabIds]) + } + views = next + flush() + }, + renewPresence: flush + } +} + +/** The scan's presence signals plus blur: a window still visible but behind another app shows nothing. */ +export function subscribeViewReportPresenceSignals( + onRescan: () => void, + onInput: () => void +): () => void { + const stop = subscribeAutoAckPresenceSignals(onRescan, onInput) + window.addEventListener('blur', onRescan) + return () => { + stop() + window.removeEventListener('blur', onRescan) + } +} diff --git a/src/renderer/src/hooks/on-screen-attention-scan.ts b/src/renderer/src/hooks/on-screen-attention-scan.ts index 096c67cc2d0..72111579c9e 100644 --- a/src/renderer/src/hooks/on-screen-attention-scan.ts +++ b/src/renderer/src/hooks/on-screen-attention-scan.ts @@ -32,6 +32,7 @@ import { } from '@/store/slices/workspace-attention/workspace-unread-flag' import { paneKeyTabId } from '@/store/slices/workspace-attention/workspace-live-markers' import { isOrcaWindowForegroundFocused } from '@/components/terminal-pane/terminal-notification-pane-visibility' +import type { ClientWorkspaceViewReporter } from './client-workspace-view-report' type ScanStore = { getState: () => AppState @@ -48,6 +49,8 @@ export type OnScreenAttentionEnvironment = { subscribeStructuredViews: (onView: (view: StructuredSubjectRead) => void) => () => void /** `onRescan`: visibility/focus changed. `onInput`: trusted input proves presence. */ subscribePresenceSignals: (onRescan: () => void, onInput: () => void) => () => void + /** Where the owning hosts raise the unread flag, they learn what this window shows. */ + viewReporter?: ClientWorkspaceViewReporter } function hasMarkersOnScreen(s: AppState, targets: readonly AutoAckTabTarget[]): boolean { @@ -151,12 +154,13 @@ export function startOnScreenAttentionScan( // Why: tab-active only proxies "seen"; gate on window visible+focused so away-time transitions don't silently clear the bold signal. const workspaceIds = isOrcaWindowForegroundFocused() ? onScreenWorkspaceIds(s) : [] + // An editor, browser or empty workspace has no targets but still reads its flag on arrival. + const targets = workspaceIds.length === 0 ? [] : resolveAutoAckTabTargets(s) + env.viewReporter?.update(s, workspaceIds, targets) if (workspaceIds.length === 0) { lastReadScreenKey = null return } - // An editor, browser or empty workspace has no targets but still reads its flag on arrival. - const targets = resolveAutoAckTabTargets(s) const screenKey = [ s.activeWorkspaceExecutionHostId, ...workspaceIds, @@ -250,6 +254,7 @@ export function startOnScreenAttentionScan( () => maybeAck({ force: true }), () => { notePresence() + env.viewReporter?.renewPresence() // Why only while a presence check is parked: input is the hottest path in the app, and an // unparked rescan would only re-read the screen that store writes and views already read. if (pendingPresenceReads !== undefined) { diff --git a/src/renderer/src/hooks/useReadOnScreenAttention.ts b/src/renderer/src/hooks/useReadOnScreenAttention.ts index 15662f9157d..27cff0c942f 100644 --- a/src/renderer/src/hooks/useReadOnScreenAttention.ts +++ b/src/renderer/src/hooks/useReadOnScreenAttention.ts @@ -4,6 +4,11 @@ import { subscribeAutoAckPresenceSignals } from './agent-auto-ack-presence' import { useAppStore } from '@/store' import { isWebClientLocation } from '@/lib/web-client-location' import { startOnScreenAttentionScan } from './on-screen-attention-scan' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-switch' +import { + createClientWorkspaceViewReporter, + subscribeViewReportPresenceSignals +} from './client-workspace-view-report' /** Reads what is on screen: acknowledges the visible agents and reads the visible tabs' markers. */ export function useReadOnScreenAttention(): void { @@ -14,7 +19,13 @@ export function useReadOnScreenAttention(): void { ? null : async () => window.api?.notifications?.getDesktopAwayState?.(), subscribeStructuredViews: subscribeStructuredAttentionViews, - subscribePresenceSignals: subscribeAutoAckPresenceSignals + // Switch point: the owning hosts raise the unread flag, so they learn what is on screen. + ...(ORCA_SIDEBAR_INPUTS_IS_MAIN + ? { + subscribePresenceSignals: subscribeViewReportPresenceSignals, + viewReporter: createClientWorkspaceViewReporter() + } + : { subscribePresenceSignals: subscribeAutoAckPresenceSignals }) }), [] ) diff --git a/src/renderer/src/lib/sidebar-shadow/window-unread-decisions.ts b/src/renderer/src/lib/sidebar-shadow/window-unread-decisions.ts new file mode 100644 index 00000000000..d44da9ffe1e --- /dev/null +++ b/src/renderer/src/lib/sidebar-shadow/window-unread-decisions.ts @@ -0,0 +1,7 @@ +// What the window's own unread rule decided per workspace. With the switch on the window writes no +// flag; its decisions are kept here only for the workspace-unread shadow to compare. +export const windowUnreadDecisions = new Map() + +export function noteWindowUnreadDecision(workspaceId: string, isUnread: boolean): void { + windowUnreadDecisions.set(workspaceId, isUnread) +} diff --git a/src/renderer/src/lib/sidebar-shadow/workspace-unread-shadow.ts b/src/renderer/src/lib/sidebar-shadow/workspace-unread-shadow.ts new file mode 100644 index 00000000000..5e4b0f4036c --- /dev/null +++ b/src/renderer/src/lib/sidebar-shadow/workspace-unread-shadow.ts @@ -0,0 +1,62 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { defineSidebarShadowDomain } from './sidebar-shadow-comparison' +import { windowUnreadDecisions as windowDecisions } from './window-unread-decisions' + +// The workspace unread flag: what the window's own rule decided, next to the flag the owning host +// holds (the records this store lists). Only flags either side moved since the check first saw the +// workspace are compared, so flags restored at startup are no one's decision. A clear from any +// client (a phone opening it, a read on another window) reaches every window, so it resets this +// window's decision too; only a raise is the host's call. + +function storeFlags(state: AppState): Map { + const flags = new Map() + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + flags.set(worktree.id, worktree.isUnread) + } + } + for (const folder of state.folderWorkspaces) { + const key = folderWorkspaceKey(folder.id) + flags.set(key, (flags.get(key) ?? false) || folder.isUnread) + } + return flags +} + +const firstSeen = new Map() +const lastSeen = new Map() + +export const workspaceUnreadShadow = defineSidebarShadowDomain({ + name: 'workspace-unread', + readMain: async () => { + const current = storeFlags(useAppStore.getState()) + for (const [workspaceId, flag] of current) { + if (!firstSeen.has(workspaceId)) { + firstSeen.set(workspaceId, flag) + } + if (lastSeen.get(workspaceId) === true && !flag) { + windowDecisions.set(workspaceId, false) + } + lastSeen.set(workspaceId, flag) + } + const values = new Map() + for (const [workspaceId, flag] of current) { + if (windowDecisions.has(workspaceId) || firstSeen.get(workspaceId) !== flag) { + values.set(workspaceId, flag) + } + } + return { values } + }, + readWindow: (main) => { + const values = new Map() + for (const workspaceId of main.values.keys()) { + values.set( + workspaceId, + windowDecisions.get(workspaceId) ?? firstSeen.get(workspaceId) ?? false + ) + } + return values + }, + equals: (window, main) => (window ?? false) === (main ?? false) +}) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 68c316addbf..ee796988f57 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -37,6 +37,7 @@ import { workspaceReviewShadow } from './lib/sidebar-shadow/workspace-review-sha import { sidebarTemporalShadow } from './lib/sidebar-shadow/sidebar-temporal-shadow' import { workspaceCreationsShadow } from './lib/sidebar-shadow/workspace-creations-shadow' import { workspaceRowsShadow } from './lib/sidebar-shadow/workspace-rows-shadow' +import { workspaceUnreadShadow } from './lib/sidebar-shadow/workspace-unread-shadow' const disposeOsFileDropGuard = installOsFileDropCancellationGuard() import.meta.hot?.dispose(disposeOsFileDropGuard) @@ -54,7 +55,8 @@ if (ORCA_SIDEBAR_INPUTS_IS_MAIN) { workspaceReviewShadow, sidebarTemporalShadow, workspaceCreationsShadow, - workspaceRowsShadow + workspaceRowsShadow, + workspaceUnreadShadow ]) import.meta.hot?.dispose(disposeSidebarShadow) } diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts index 3ef999e41d1..e2bb95f83c1 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts @@ -16,6 +16,8 @@ import { import { workspaceMarkerTabIds } from './workspace-live-markers' import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets' import { isWorkspaceNotificationMutedBySidebarFilters } from '@/attention/hidden-workspace-notification-mute' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../shared/sidebar-inputs-switch' +import { noteWindowUnreadDecision } from '@/lib/sidebar-shadow/window-unread-decisions' export const createWorkspaceAttentionSlice: StateCreator< AppState, @@ -118,6 +120,9 @@ export const createWorkspaceAttentionSlice: StateCreator< } }, setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => { + if (ORCA_SIDEBAR_INPUTS_IS_MAIN) { + noteWindowUnreadDecision(worktreeId, isUnread) + } if (!isUnread) { removeAndDismiss(workspaceMarkerTabIds(get(), worktreeId)) } diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts index 14ab0850a8c..a2dcd715fa9 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts @@ -12,6 +12,7 @@ import { persistPassiveWorktreeMetaForOwner } from '../worktrees/listing/worktre import type { WorkspaceAttentionGet, WorkspaceAttentionSet } from './workspace-attention-contract' import { workspaceHasLiveMarker } from './workspace-live-markers' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../shared/sidebar-inputs-switch' +import { noteWindowUnreadDecision } from '@/lib/sidebar-shadow/window-unread-decisions' // The persisted workspace flag `isUnread`. Only the workspace-attention module writes it. @@ -20,8 +21,12 @@ export function markWorkspaceUnreadFlag( get: WorkspaceAttentionGet, worktreeId: string ): void { - // Switch point: the owning host stamps bells and finishes; the flag alone is the window's. - const stamp = ORCA_SIDEBAR_INPUTS_IS_MAIN ? {} : { lastActivityAt: Date.now() } + // Switch point: the owning host raises the flag from the same news; the window only records it. + if (ORCA_SIDEBAR_INPUTS_IS_MAIN) { + noteWindowUnreadDecision(worktreeId, true) + return + } + const stamp = { lastActivityAt: Date.now() } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderWorkspaceId = workspaceScope.folderWorkspaceId @@ -97,6 +102,9 @@ export function clearWorkspaceUnreadFlag( /** Folder rows share an id across hosts: the on-screen host's row, or null for every row. */ executionHostId?: ExecutionHostId | null ): void { + if (ORCA_SIDEBAR_INPUTS_IS_MAIN) { + noteWindowUnreadDecision(worktreeId, false) + } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderWorkspaceId = workspaceScope.folderWorkspaceId diff --git a/src/renderer/src/store/slices/worktrees-recent-stamp-switch-on.test.ts b/src/renderer/src/store/slices/worktrees-recent-stamp-switch-on.test.ts index 52689e5c45c..40a80251340 100644 --- a/src/renderer/src/store/slices/worktrees-recent-stamp-switch-on.test.ts +++ b/src/renderer/src/store/slices/worktrees-recent-stamp-switch-on.test.ts @@ -26,7 +26,8 @@ vi.mock('@/components/worktree-base-fallback-notice', () => ({ beforeEach(resetWorktreeSliceModuleMemory) -// With the switch on, the owning host writes every Recent stamp; the window writes none. +// With the switch on, the owning host writes every Recent stamp and the unread flag; the window +// writes neither. describe('window-side Recent stamps with the sidebar-inputs switch on', () => { const wt = makeWorktree({ id: 'repo1::/path/wt1', @@ -61,7 +62,12 @@ describe('window-side Recent stamps with the sidebar-inputs switch on', () => { expect(persistedStamp()).toBe(false) }) - it('a bell or finish raises the unread flag but writes no stamp', () => { + const persistedUnread = () => + mockApi.worktrees.updateMeta.mock.calls.some( + ([args]: [{ updates: Record }]) => 'isUnread' in args.updates + ) + + it('a bell or finish writes no stamp and leaves the unread flag to the host', () => { const store = storeWith() store.getState().markAgentAttentionUnread({ workspaceId: wt.id, @@ -69,7 +75,8 @@ describe('window-side Recent stamps with the sidebar-inputs switch on', () => { groupId: null, reason: 'agent-completion' }) - expect(store.getState().worktreesByRepo.repo1[0].isUnread).toBe(true) + expect(store.getState().worktreesByRepo.repo1[0].isUnread).toBe(false) + expect(persistedUnread()).toBe(false) expect(lastActivityAt(store)).toBe(5) expect(persistedStamp()).toBe(false) }) @@ -82,7 +89,7 @@ describe('window-side Recent stamps with the sidebar-inputs switch on', () => { expect(persistedStamp()).toBe(false) }) - it('a folder workspace’s terminal activity and unread mark write no stamp', async () => { + it('a folder workspace’s terminal activity and finish write no stamp and no flag', async () => { const store = createTestStore() const folder = makeFolderWorkspace({ id: 'folder-1', lastActivityAt: 5 }) store.setState({ folderWorkspaces: [folder], projectGroups: [] }) @@ -95,10 +102,12 @@ describe('window-side Recent stamps with the sidebar-inputs switch on', () => { }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(store.getState().folderWorkspaces[0]).toMatchObject({ - isUnread: true, + isUnread: false, lastActivityAt: 5 }) const folderWrites = vi.mocked(store.getState().updateFolderWorkspace).mock.calls - expect(folderWrites.some(([, updates]) => 'lastActivityAt' in updates)).toBe(false) + expect( + folderWrites.some(([, updates]) => 'lastActivityAt' in updates || 'isUnread' in updates) + ).toBe(false) }) }) diff --git a/src/shared/rpc-contract/client-workspace-view-params.ts b/src/shared/rpc-contract/client-workspace-view-params.ts new file mode 100644 index 00000000000..8ac508bbd5e --- /dev/null +++ b/src/shared/rpc-contract/client-workspace-view-params.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +/** + * What a client window shows its user, sent by `sidebarInputs.reportView` to each host that owns + * one of those workspaces: the workspaces on screen and their tabs on screen. Each report replaces + * the client's last one; an empty one means the window is hidden or unfocused. + */ +export const ClientWorkspaceViewParams = z.object({ + workspaceIds: z.array(z.string().min(1).max(1024)).max(64), + tabIds: z.array(z.string().min(1).max(1024)).max(256), + /** How long the user stays present without new input; null where presence is not tracked. */ + presentForMs: z.number().int().nonnegative().nullable() +}) + +export type ClientWorkspaceView = z.infer diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index fc72eb49418..37f75bc50f9 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -145,6 +145,7 @@ import { SettingsUpdate } from './client-settings-params' import { FeatureInteractionIdParam, UiUpdate } from './client-ui-params' +import { ClientWorkspaceViewParams } from './client-workspace-view-params' import { AbortImageUpload, AppendImageUploadChunk, @@ -1264,6 +1265,7 @@ export const RPC_PARAMS_BY_METHOD = { 'sidebarInputs.pairedServerRows': null, 'sidebarInputs.read': SidebarInputsReadParams, 'sidebarInputs.repoIcons': RepoIconDataUrlsParams, + 'sidebarInputs.reportView': ClientWorkspaceViewParams, 'sidebarInputs.reviewFacts': null, 'sidebarInputs.temporal': null, 'sidebarInputs.workspaceCreations': null, diff --git a/src/shared/sidebar-list/sort-stamps.ts b/src/shared/sidebar-list/sort-stamps.ts index 234de5a9b2c..a9fc8a6baa1 100644 --- a/src/shared/sidebar-list/sort-stamps.ts +++ b/src/shared/sidebar-list/sort-stamps.ts @@ -134,3 +134,13 @@ export function classifySortStampEvent( ...(smart.smartTier !== undefined ? { smartTier: smart.smartTier, smartAt: smart.smartAt } : {}) } } + +/** A finish, a request for input or a bell: news that flags the workspace for whoever isn't looking. */ +export function isUnreadNews(event: SortStampEvent): boolean { + return ( + event.kind === 'bell' || + (event.kind === 'agent' && + event.from !== event.to && + (event.to === 'done' || event.to === 'needs-you')) + ) +} diff --git a/tests/e2e/sidebar-host-unread.spec.ts b/tests/e2e/sidebar-host-unread.spec.ts new file mode 100644 index 00000000000..813c0a0a1cd --- /dev/null +++ b/tests/e2e/sidebar-host-unread.spec.ts @@ -0,0 +1,83 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { postClaudeHook, readHookEndpoint } from './helpers/agent-hook-endpoint' +import { SIDEBAR_INPUTS_BUILD, expectNoSidebarShadowMismatches } from './helpers/sidebar-shadow' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import { + WorkspaceRowsSnapshotSchema, + type WorkspaceRow +} from '../../src/shared/sidebar-list/workspace-rows' + +test.skip( + !SIDEBAR_INPUTS_BUILD, + 'the host raises the unread flag only in an ORCA_SIDEBAR_INPUTS=main build' +) + +async function readHostRow(page: Page, rowKey: string): Promise { + const response = await page.evaluate(() => + window.api.runtime.call({ method: 'sidebarInputs.workspaceRows' }) + ) + if (!response.ok) { + throw new Error('sidebarInputs.workspaceRows failed') + } + return WorkspaceRowsSnapshotSchema.parse(response.result).rows[rowKey] +} + +test('the host raises the unread flag where the window’s user is not looking, as the window would', async ({ + orcaPage, + electronApp +}) => { + test.setTimeout(90_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const endpoint = await readHookEndpoint(electronApp) + const pane = await waitForActivePaneHookDescriptor(orcaPage) + const rowKey = `local|${pane.worktreeId}` + const unread = async (): Promise => + (await readHostRow(orcaPage, rowKey))?.isUnread + await expect.poll(unread).toBe(false) + + // Input in the window confirms the user is present, and the agent's tab is on screen. + await orcaPage.mouse.move(40, 40) + await orcaPage.mouse.move(60, 60) + await postClaudeHook(endpoint, pane, { hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + 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) + + // Settings is up, so no workspace is on screen: the finish is news. + await orcaPage.evaluate(() => window.__store?.setState({ activeView: 'tasks' })) + 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's copy is the host's record. + await expect + .poll(() => + orcaPage.evaluate( + (id) => + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .find((worktree) => worktree.id === id)?.isUnread, + pane.worktreeId + ) + ) + .toBe(true) + await expectNoSidebarShadowMismatches(orcaPage) + + // Mark as read writes through to the host. + await orcaPage.evaluate((id) => { + window.__store?.getState().setWorkspaceUnreadByUser(id, false, 'local') + }, pane.worktreeId) + await expect.poll(unread).toBe(false) + + await postClaudeHook(endpoint, pane, { + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'ls' } + }) + await expect.poll(unread).toBe(true) + await expectNoSidebarShadowMismatches(orcaPage) +})