mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user