feat(sidebar): the owning host raises the workspace unread flag (dark) (#30450)

* feat(sidebar): the owning host raises the workspace unread flag (dark)

* fix(sidebar): phone session open clears and spares the flag; refresh views; blur rescan

* fix(sidebar): throttle failed view retries, resend failed empty views, hold presence one refresh longer

* test(sidebar): switch-on window writes neither the unread flag nor a stamp
This commit is contained in:
Jinwoo Hong
2026-10-18 20:01:28 -04:00
committed by GitHub
parent f8a5d3e284
commit 062e06d982
26 changed files with 1045 additions and 38 deletions
@@ -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) {
@@ -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',
@@ -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<string>()
@@ -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') {
@@ -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()
}
@@ -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<string, Flag> = { [WT]: { lastActivityAt: 0, isUnread: false } }
const sshMeta: Record<string, Flag> = { [SSH_WT]: { lastActivityAt: 0, isUnread: false } }
const folders: Record<string, Flag & { projectGroupId: string }> = {
[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<Flag>) => Object.assign(meta[id], updates),
getWorktreeMetaForHost: (id: string, hostId: string) =>
hostId === 'ssh:build-box' ? sshMeta[id] : undefined,
setWorktreeMetaForHost: (id: string, _hostId: string, updates: Partial<Flag>) =>
Object.assign(sshMeta[id], updates),
getFolderWorkspace: (id: string) => folders[id],
updateFolderWorkspace: (id: string, updates: Partial<Flag>) =>
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()
}
})
})
@@ -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<TRecord, TUpdates> = {
updateFolderWorkspace?: (folderWorkspaceId: string, updates: TUpdates) => unknown
}
/** The record reads and writes stamping needs. */
export type SortStampsStore = WorkspaceRecordStore<StampedRecord, StampUpdates> & {
type UnreadUpdate = { isUnread: boolean }
/** The record reads and writes stamping and the unread flag need. */
export type SortStampsStore = WorkspaceRecordStore<StampedRecord, StampUpdates | UnreadUpdate> & {
getAllWorktreeMeta?: () => Record<string, StampedRecord>
getFolderWorkspaces?: () => readonly (StampedRecord & { id: string })[]
}
@@ -64,6 +66,21 @@ export function writeWorkspaceRecord<TUpdates>(
}
}
/** 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<UnreadUpdate>(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,
@@ -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<string>()
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')
})
})
@@ -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<string, TrackedPane>()
@@ -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)
}
}
@@ -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)
})
})
@@ -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<string>; tabIds: Set<string>; 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<string, ClientView>()
private readonly phoneSessions = new Map<string, number>()
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
}
}
@@ -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 () => {}) }
}
})
})
@@ -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<boolean | undefined>,
onPresent: () => void
@@ -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([])
})
})
@@ -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<unknown> = (
target,
view
) => callRuntimeRpc(target, 'sidebarInputs.reportView', view)
): ClientWorkspaceViewReporter {
let views = new Map<string, HostView>()
const sent = new Map<string, SentView>()
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<string, HostView>()
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)
}
}
@@ -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) {
@@ -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 })
}),
[]
)
@@ -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<string, boolean>()
export function noteWindowUnreadDecision(workspaceId: string, isUnread: boolean): void {
windowUnreadDecisions.set(workspaceId, isUnread)
}
@@ -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<string, boolean> {
const flags = new Map<string, boolean>()
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<string, boolean>()
const lastSeen = new Map<string, boolean>()
export const workspaceUnreadShadow = defineSidebarShadowDomain<boolean>({
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<string, boolean>()
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<string, boolean>()
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)
})
+3 -1
View File
@@ -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)
}
@@ -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))
}
@@ -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
@@ -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<string, unknown> }]) => '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)
})
})
@@ -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<typeof ClientWorkspaceViewParams>
+2
View File
@@ -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,
+10
View File
@@ -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'))
)
}
+83
View File
@@ -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<WorkspaceRow | undefined> {
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<boolean | undefined> =>
(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)
})