mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Sidebar: land the switch-on fixes from the flip branch on main, switch still off (#31466)
* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip) The fixes found while the sidebar ran from main's feed, without turning that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's stop-gaps come along. - Main stops counting a closed tab's terminal once it was asked to stop and no pane shows it; a failed or refused stop takes the request back, and a host out of contact stays unverifiable. - Main fetches a paired server's rows with the transport's normal bound, so a server slower than 5 s still lists. - A deferred unread raise flips the folder row it was decided for, and a read while the owner check is out cancels it. - Phone: no worktree.ps flash before the feed; a host's last installed feed stands in while a fresh read is out; it highlights its own last-opened workspace and a fold never scrolls; projects file under their project group with the desktop's indent; the collapse spinner shows only after 500 ms; "Reopen to update" keeps the rows on screen. - e2e helpers and journeys for the switch-on suite. * fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises - A stop-and-wait that throws takes its stop request back, like one that fails. - A final stop the legacy relay still owes stays marked, since the relay retries it. - Pending unread raises are tracked only while one waits, so the map no longer grows. - The paired-server fallback poll test gives its third fetch a reply and asserts live.
This commit is contained in:
@@ -75,6 +75,10 @@ async function stopRendererOwnedPtyAs(
|
||||
stopped = true
|
||||
} finally {
|
||||
settleStop?.(stopped)
|
||||
if (!stopped) {
|
||||
// The failed shutdown left the process running.
|
||||
deps.runtime?.clearPtyStopRequest?.(args.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('a tab close of a terminal an older relay still runs', () => {
|
||||
})
|
||||
|
||||
describe('a stop-and-wait of a terminal an older relay still runs', () => {
|
||||
function install(recordStop = vi.fn()) {
|
||||
function install(recordStop = vi.fn(), runtime: unknown = null) {
|
||||
installLegacyRelayBridge({
|
||||
leases: () => [lease],
|
||||
retireLease: vi.fn(),
|
||||
@@ -146,7 +146,7 @@ describe('a stop-and-wait of a terminal an older relay still runs', () => {
|
||||
report: () => {}
|
||||
})
|
||||
const deps = {
|
||||
runtime: null,
|
||||
runtime,
|
||||
store: null,
|
||||
getLocalPtyProviderStartupPromise: () => null,
|
||||
shutdownProviderAndDetectExit: vi.fn(),
|
||||
@@ -185,4 +185,87 @@ describe('a stop-and-wait of a terminal an older relay still runs', () => {
|
||||
expect(recordStop).not.toHaveBeenCalled()
|
||||
installLegacyRelayBridge(null)
|
||||
})
|
||||
|
||||
it('keeps the stop request of a final stop the bridge still owes, so the pane reads closing', async () => {
|
||||
const runtime = { markPtyStopRequested: vi.fn(), clearPtyStopRequest: vi.fn() }
|
||||
const recordStop = vi.fn()
|
||||
const deps = install(recordStop, runtime)
|
||||
const ptyId = toAppSshPtyId(TARGET, lease.ptyId)
|
||||
await expect(
|
||||
stopAndWaitPtyFromRuntimeController(deps, ptyId, { deadlineMs: Date.now() + 1_000 })
|
||||
).resolves.toBe(false)
|
||||
expect(recordStop).toHaveBeenCalled()
|
||||
expect(runtime.clearPtyStopRequest).not.toHaveBeenCalled()
|
||||
installLegacyRelayBridge(null)
|
||||
})
|
||||
|
||||
it('takes back the stop request of a refused reversible stop, so the pane reads running', async () => {
|
||||
const runtime = { markPtyStopRequested: vi.fn(), clearPtyStopRequest: vi.fn() }
|
||||
const deps = install(vi.fn(), runtime)
|
||||
const ptyId = toAppSshPtyId(TARGET, lease.ptyId)
|
||||
await expect(
|
||||
stopAndWaitPtyFromRuntimeController(deps, ptyId, { keepHistory: true })
|
||||
).resolves.toBe(false)
|
||||
expect(runtime.markPtyStopRequested).toHaveBeenCalledWith(ptyId)
|
||||
expect(runtime.clearPtyStopRequest).toHaveBeenCalledWith(ptyId)
|
||||
installLegacyRelayBridge(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a stop-and-wait whose provider startup fails', () => {
|
||||
it('takes back its stop request, so the terminal still reads running', async () => {
|
||||
const runtime = { markPtyStopRequested: vi.fn(), clearPtyStopRequest: vi.fn() }
|
||||
const deps = {
|
||||
runtime,
|
||||
store: null,
|
||||
getLocalPtyProviderStartupPromise: () => Promise.reject(new Error('daemon failed')),
|
||||
shutdownProviderAndDetectExit: vi.fn(),
|
||||
finishPtyShutdown: vi.fn()
|
||||
}
|
||||
await expect(
|
||||
stopAndWaitPtyFromRuntimeController(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup await throws before any other dep is read.
|
||||
deps as unknown as PtyRuntimeControllerDeps,
|
||||
'local-pty-1'
|
||||
)
|
||||
).rejects.toThrow('daemon failed')
|
||||
expect(runtime.markPtyStopRequested).toHaveBeenCalledWith('local-pty-1')
|
||||
expect(runtime.clearPtyStopRequest).toHaveBeenCalledWith('local-pty-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a failed tab-close stop', () => {
|
||||
it('takes back its stop request, so the terminal still reads running', async () => {
|
||||
installLegacyRelayBridge({
|
||||
leases: () => [lease],
|
||||
retireLease: vi.fn(),
|
||||
recordStop: vi.fn(),
|
||||
connection: () => null,
|
||||
onPanesChanged: () => {},
|
||||
onData: () => {},
|
||||
onReplay: () => {},
|
||||
onExit: () => {},
|
||||
report: () => {}
|
||||
})
|
||||
const runtime = {
|
||||
markPtyStopRequested: vi.fn(),
|
||||
clearPtyStopRequest: vi.fn(),
|
||||
intentionalPtyStops: { mark: () => () => {} }
|
||||
}
|
||||
const deps = {
|
||||
runtime,
|
||||
store: { markSshRemotePtyLease: vi.fn() },
|
||||
getLocalPtyProviderStartupPromise: () => undefined,
|
||||
shutdownProviderAndDetectExit: vi.fn(),
|
||||
rememberSyntheticKillExit: vi.fn(),
|
||||
sendPtyExitToRenderer: vi.fn()
|
||||
}
|
||||
const ptyId = toAppSshPtyId(TARGET, lease.ptyId)
|
||||
await expect(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch reads only these deps.
|
||||
stopRendererOwnedPty(deps as unknown as PtyKillIpcDeps, { id: ptyId, keepHistory: true })
|
||||
).rejects.toThrow('legacy_relay_bridged_pty_cannot_sleep')
|
||||
expect(runtime.clearPtyStopRequest).toHaveBeenCalledWith(ptyId)
|
||||
installLegacyRelayBridge(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,6 +196,26 @@ export async function stopAndWaitPtyFromRuntimeController(
|
||||
deps: PtyRuntimeControllerDeps,
|
||||
ptyId: string,
|
||||
opts?: { keepHistory?: boolean; deadlineMs?: number }
|
||||
): Promise<boolean> {
|
||||
// A final relay stop is owed until the relay proves it, so an unproven one still reads as closing.
|
||||
const relayOwesStop =
|
||||
!opts?.keepHistory && Boolean(getPtySshConnectionId(ptyId)) && isLegacyRelayBridgedPty(ptyId)
|
||||
let stopped = false
|
||||
try {
|
||||
stopped = await stopAndWaitPty(deps, ptyId, opts)
|
||||
return stopped
|
||||
} finally {
|
||||
if (!stopped && !relayOwesStop) {
|
||||
// Refused, failed or thrown: the process may run on, so it must not read as closing.
|
||||
deps.runtime?.clearPtyStopRequest?.(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAndWaitPty(
|
||||
deps: PtyRuntimeControllerDeps,
|
||||
ptyId: string,
|
||||
opts?: { keepHistory?: boolean; deadlineMs?: number }
|
||||
): Promise<boolean> {
|
||||
const {
|
||||
runtime,
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('mobile relay host catalog', () => {
|
||||
'worktree.ps',
|
||||
// The phone's full-catalog limit: the server's default page would silently truncate.
|
||||
{ limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true },
|
||||
{ timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: 'runtime-a' } }
|
||||
{ expected: { pairingRevision: 1, runtimeId: 'runtime-a' } }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -330,7 +330,6 @@ describe('mobile relay host catalog', () => {
|
||||
const next = hostCatalog.worktrees('runtime:env')
|
||||
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(2))
|
||||
expect(call).toHaveBeenLastCalledWith(expect.anything(), 'worktree.ps', expect.anything(), {
|
||||
timeoutMs: 5_000,
|
||||
expected: { pairingRevision: 2, runtimeId: 'runtime-a' }
|
||||
})
|
||||
await expect(next).resolves.toMatchObject({ worktrees: [{ worktreeId: 'new-server-row' }] })
|
||||
@@ -395,7 +394,6 @@ describe('mobile relay host catalog', () => {
|
||||
worktrees: [{ worktreeId: 'w' }]
|
||||
})
|
||||
expect(call).toHaveBeenCalledWith(expect.anything(), 'worktree.ps', expect.anything(), {
|
||||
timeoutMs: 5_000,
|
||||
expected: { pairingRevision: 1, runtimeId: null }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Runs on plain Node, as orcad does: main's "Hide sleeping" retention reads the runtime's own facts.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RuntimeStore } from '../runtime-store-contract'
|
||||
import type { PersistedUIState } from '../../../shared/persisted-ui-state-types'
|
||||
import type { ResolvedWorktreeSnapshot } from '../runtime-resolved-worktree-cache'
|
||||
import type { ResolvedWorktree } from '../runtime-worktree-path-identity'
|
||||
|
||||
vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
|
||||
const repo = { id: 'repo-1', path: '/r', displayName: 'r', badgeColor: 'blue', addedAt: 1 }
|
||||
const WT = 'repo-1::/r/a'
|
||||
const LEAF = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function worktree(id: string): ResolvedWorktree {
|
||||
return {
|
||||
id,
|
||||
repoId: repo.id,
|
||||
path: id,
|
||||
branch: 'a',
|
||||
head: '',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: id,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git: { path: id, head: '', branch: 'a', isBare: false, isMainWorktree: false }
|
||||
}
|
||||
}
|
||||
|
||||
class Probe extends OrcaRuntimeService {
|
||||
protected override async listResolvedWorktreeSnapshot(): Promise<ResolvedWorktreeSnapshot> {
|
||||
return { worktrees: [worktree(WT)], platformByRepoId: new Map() }
|
||||
}
|
||||
}
|
||||
|
||||
type Host = NonNullable<OrcaRuntimeService['sidebarFacts']>
|
||||
|
||||
/** The window's graph: one terminal tab in WT, running pty-1. */
|
||||
function publishTerminalTab(runtime: Host): void {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [{ tabId: 'tab-1', worktreeId: WT, title: 'Terminal', activeLeafId: LEAF, layout: null }],
|
||||
leaves: [{ tabId: 'tab-1', worktreeId: WT, leafId: LEAF, paneRuntimeId: 1, ptyId: 'pty-1' }],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: WT,
|
||||
publicationEpoch: 'renderer:test',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `tab-1::${LEAF}`,
|
||||
parentTabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
title: 'Terminal',
|
||||
ptyId: 'pty-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/** A window with one terminal open in WT, its list mounted, "Hide sleeping" on. */
|
||||
async function createHost(): Promise<Host> {
|
||||
const store: Pick<RuntimeStore, 'getRepos' | 'getUI'> = {
|
||||
getRepos: () => [repo],
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: retention reads only hideSleepingWorkspaces.
|
||||
getUI: () => ({ hideSleepingWorkspaces: true }) as PersistedUIState
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these paths touch only repos and the UI flag.
|
||||
const runtime = new Probe(store as RuntimeStore).sidebarFacts
|
||||
if (!runtime) {
|
||||
throw new Error('built without the sidebar facts layer')
|
||||
}
|
||||
publishTerminalTab(runtime)
|
||||
runtime.beginSidebarListSession('session-1')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live')
|
||||
return runtime
|
||||
}
|
||||
|
||||
const retained = (runtime: Host): readonly string[] =>
|
||||
runtime.getSidebarTemporalSnapshot().retention?.retainedIds ?? []
|
||||
|
||||
/** The window's graph once its terminal tab in WT is closed. */
|
||||
function publishNoTabs(runtime: Host): void {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: WT,
|
||||
publicationEpoch: 'renderer:test',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// pty-1 is recorded as the spawn records it, so it reads live until its exit arrives.
|
||||
async function createHostWithSpawnedPty(): Promise<Host> {
|
||||
const runtime = await createHost()
|
||||
runtime.registerPty('pty-1', WT)
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('main sleeping retention and a closed last tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops the row as the window closes the tab, before the terminal exits', async () => {
|
||||
const runtime = await createHostWithSpawnedPty()
|
||||
// As closeTab does: pty:kill first, then the graph without the tab.
|
||||
runtime.markPtyStopRequested('pty-1')
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live')
|
||||
publishNoTabs(runtime)
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]).toBeUndefined()
|
||||
expect(retained(runtime)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the row when the stop request lands after the graph', async () => {
|
||||
const runtime = await createHostWithSpawnedPty()
|
||||
publishNoTabs(runtime)
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live')
|
||||
runtime.markPtyStopRequested('pty-1')
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]).toBeUndefined()
|
||||
expect(retained(runtime)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a terminal live while a pane still shows it, though asked to stop', async () => {
|
||||
const runtime = await createHostWithSpawnedPty()
|
||||
runtime.markPtyStopRequested('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live')
|
||||
})
|
||||
|
||||
it('keeps a closed tab’s terminal unverifiable when its host stops answering', async () => {
|
||||
const runtime = await createHostWithSpawnedPty()
|
||||
runtime.markPtyStopRequested('pty-1')
|
||||
publishNoTabs(runtime)
|
||||
runtime.markPtyLivenessUnverifiable('pty-1', 'ssh contact lost')
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('counts the terminal live again when its stop fails', async () => {
|
||||
const runtime = await createHostWithSpawnedPty()
|
||||
runtime.markPtyStopRequested('pty-1')
|
||||
publishNoTabs(runtime)
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]).toBeUndefined()
|
||||
runtime.clearPtyStopRequest('pty-1')
|
||||
expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live')
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,7 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW
|
||||
|
||||
override syncWindowGraph(...args: Parameters<Runtime['syncWindowGraph']>) {
|
||||
const previousTabs = this.tabs
|
||||
const previousLeaves = this.leaves
|
||||
const result = super.syncWindowGraph(...args)
|
||||
// Only a tab moving workspace can change where an agent row is attributed.
|
||||
const moved = [...this.tabs.values()].some(
|
||||
@@ -41,9 +42,22 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW
|
||||
if (moved || [...previousTabs.keys()].some((tabId) => !this.tabs.has(tabId))) {
|
||||
this.workspaceActivityFacts.resyncAgentPanes()
|
||||
}
|
||||
// Now, not on the session publication: a closed pane's stopping PTY stops counting here.
|
||||
// syncWindowGraph replaces this.leaves (session-window-graph.ts), so previousLeaves stays intact.
|
||||
this.refreshWorkspaces(leafPtyChangedWorktrees(previousLeaves, this.leaves))
|
||||
return result
|
||||
}
|
||||
|
||||
override markPtyStopRequested(ptyId: string): void {
|
||||
super.markPtyStopRequested(ptyId)
|
||||
this.refreshWorkspaces(this.ptyWorktreeIds(ptyId))
|
||||
}
|
||||
|
||||
override clearPtyStopRequest(ptyId: string): void {
|
||||
super.clearPtyStopRequest(ptyId)
|
||||
this.refreshWorkspaces(this.ptyWorktreeIds(ptyId))
|
||||
}
|
||||
|
||||
protected override notifyMobileSessionTabsChangedNow(
|
||||
worktreeId: string,
|
||||
changeSequence: number
|
||||
@@ -102,6 +116,11 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW
|
||||
}
|
||||
}
|
||||
|
||||
// Asked to stop with no pane left: the window that closed it already shows it gone.
|
||||
private isClosing(ptyId: string): boolean {
|
||||
return this.isPtyStopRequested(ptyId) && this.getLeavesForPty(ptyId).length === 0
|
||||
}
|
||||
|
||||
private ptyWorktreeIds(ptyId: string): Set<string> {
|
||||
const worktreeIds = new Set(this.getLeavesForPty(ptyId).map((leaf) => leaf.worktreeId))
|
||||
const recorded = this.ptysById.get(ptyId)?.worktreeId
|
||||
@@ -123,8 +142,8 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW
|
||||
}
|
||||
}
|
||||
|
||||
// getLivePtyIdsForWorktree's identity rule in one pass. A host that only lost contact keeps its
|
||||
// terminals unverifiable, never gone, even while the record still reads connected.
|
||||
// Matches every host form of the worktree's id. A host that only lost contact keeps its terminals
|
||||
// unverifiable, never gone, even while the record still reads connected or is being closed.
|
||||
private readWorkspaceTerminalLiveness(worktreeId: string): WorkspaceTerminalLiveness | null {
|
||||
const key = worktreeIdentityKey(worktreeId)
|
||||
const owns = (id: string): boolean => id === worktreeId || worktreeIdentityKey(id) === key
|
||||
@@ -136,7 +155,7 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW
|
||||
}
|
||||
if (this.getPtyLivenessVerdict(surface.ptyId)?.status === 'unverifiable') {
|
||||
unverifiable = true
|
||||
} else if (surface.connected) {
|
||||
} else if (surface.connected && !this.isClosing(surface.ptyId)) {
|
||||
return 'live'
|
||||
}
|
||||
}
|
||||
@@ -144,3 +163,23 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW
|
||||
return unverifiable ? 'unverifiable' : null
|
||||
}
|
||||
}
|
||||
|
||||
function leafPtyChangedWorktrees(
|
||||
before: ReadonlyMap<string, { worktreeId: string; ptyId: string | null }>,
|
||||
after: ReadonlyMap<string, { worktreeId: string; ptyId: string | null }>
|
||||
): Set<string> {
|
||||
const changed = new Set<string>()
|
||||
for (const [leafId, leaf] of before) {
|
||||
const next = after.get(leafId)
|
||||
if (next?.ptyId !== leaf.ptyId || next.worktreeId !== leaf.worktreeId) {
|
||||
changed.add(leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [leafId, leaf] of after) {
|
||||
const previous = before.get(leafId)
|
||||
if (previous?.ptyId !== leaf.ptyId || previous.worktreeId !== leaf.worktreeId) {
|
||||
changed.add(leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ function fakeHosts(servers: { id: string; live: boolean }[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// callRuntimeEnvironment's bound for a call that names none.
|
||||
const DEFAULT_TRANSPORT_TIMEOUT_MS = 15_000
|
||||
|
||||
const GROUP = {
|
||||
name: 'school',
|
||||
parentPath: null,
|
||||
@@ -172,7 +175,7 @@ describe('PairedServerRowsProducer', () => {
|
||||
{ environmentId: 'box' },
|
||||
'worktree.ps',
|
||||
{ limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true },
|
||||
{ timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: 'runtime-a' } }
|
||||
{ expected: { pairingRevision: 1, runtimeId: 'runtime-a' } }
|
||||
)
|
||||
expect(rowsOf(producer, 'runtime:box')).toEqual({
|
||||
hostId: 'runtime:box',
|
||||
@@ -239,6 +242,90 @@ describe('PairedServerRowsProducer', () => {
|
||||
expect(rowsOf(producer, 'runtime:box')?.rows.map((entry) => entry.worktreeId)).toEqual(['new'])
|
||||
})
|
||||
|
||||
// A server answering after `delayMs`, or failing as the transport would once its bound passes.
|
||||
function slowServer(call: ReturnType<typeof fakeHosts>['call'], delayMs: number, rows: string[]) {
|
||||
call.mockImplementationOnce(
|
||||
(_host, _method, _params, options) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const boundMs = options?.timeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS
|
||||
if (delayMs > boundMs) {
|
||||
setTimeout(() => reject(new Error('timed out')), boundMs)
|
||||
} else {
|
||||
setTimeout(() => resolve(psReply(rows.map((id) => row(id)))), delayMs)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
it('lists a server that takes 8 s to answer', async () => {
|
||||
const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }])
|
||||
slowServer(call, 8_000, ['a'])
|
||||
const producer = new PairedServerRowsProducer({
|
||||
hasDemand: () => true,
|
||||
hostLabelOverrides: () => new Map()
|
||||
})
|
||||
producer.attach(hosts)
|
||||
watches.get('box')?.onChange()
|
||||
await vi.advanceTimersByTimeAsync(7_999)
|
||||
expect(rowsOf(producer, 'runtime:box')?.rows).toEqual([])
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(rowsOf(producer, 'runtime:box')).toMatchObject({
|
||||
freshness: 'live',
|
||||
rows: [{ worktreeId: 'a' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('holds one fetch per server, and notices during it coalesce into one follow-up', async () => {
|
||||
const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }])
|
||||
slowServer(call, 8_000, ['old'])
|
||||
slowServer(call, 8_000, ['new'])
|
||||
const producer = new PairedServerRowsProducer({
|
||||
hasDemand: () => true,
|
||||
hostLabelOverrides: () => new Map()
|
||||
})
|
||||
producer.attach(hosts)
|
||||
watches.get('box')?.onChange()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
// Change notices and a reader's refresh arrive while it runs.
|
||||
watches.get('box')?.onChange()
|
||||
watches.get('box')?.onChange()
|
||||
void producer.refresh('box')
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
// The fallback poll answers too, so the rows stay live.
|
||||
call.mockResolvedValueOnce(psReply([row('new')]))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(call).toHaveBeenCalledTimes(3)
|
||||
expect(rowsOf(producer, 'runtime:box')).toMatchObject({ freshness: 'live' })
|
||||
expect(rowsOf(producer, 'runtime:box')?.rows.map((entry) => entry.worktreeId)).toEqual(['new'])
|
||||
})
|
||||
|
||||
it('keeps the last rows when a fetch times out', async () => {
|
||||
const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }])
|
||||
call.mockResolvedValueOnce(psReply([row('a')]))
|
||||
const producer = new PairedServerRowsProducer({
|
||||
hasDemand: () => true,
|
||||
hostLabelOverrides: () => new Map()
|
||||
})
|
||||
producer.attach(hosts)
|
||||
watches.get('box')?.onChange()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
slowServer(call, 60_000, ['never'])
|
||||
watches.get('box')?.onChange()
|
||||
// The transport's normal bound, not the phone's 5 s, ends the wait.
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TRANSPORT_TIMEOUT_MS - 1)
|
||||
expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('live')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(rowsOf(producer, 'runtime:box')).toMatchObject({
|
||||
freshness: 'unverifiable',
|
||||
rows: [{ worktreeId: 'a' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last rows as unverifiable when the server loses contact or stops answering', async () => {
|
||||
const { hosts, call, watches, state, publishStatus } = fakeHosts([{ id: 'box', live: true }])
|
||||
call.mockResolvedValueOnce(psReply([row('a')]))
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '../mobile-desktop-relay/mobile-relay-host-descriptions'
|
||||
import type { ServerWorktreeRows } from '../mobile-desktop-relay/mobile-relay-host-catalog'
|
||||
import {
|
||||
SERVER_FETCH_TIMEOUT_MS,
|
||||
SERVER_WORKTREE_LIMIT,
|
||||
WorktreePsReplySchema,
|
||||
stampServerWorktreeRows
|
||||
@@ -244,8 +243,8 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
|
||||
const { contactGeneration } = entry
|
||||
const call = async (method: string, params?: unknown) =>
|
||||
hosts.call({ environmentId: entry.environmentId }, method, params, {
|
||||
// Why the transport bound too: it ends the hung call, so the next request fetches afresh.
|
||||
timeoutMs: SERVER_FETCH_TIMEOUT_MS,
|
||||
// Why the transport's normal bound: nothing waits on this fetch (readers take the latest
|
||||
// rows), so a slow server's answer must still land; the bound still ends a hung call.
|
||||
expected: entry.identity
|
||||
})
|
||||
// Only on contact, ready, a replay or reposChanged, which every catalog write announces.
|
||||
|
||||
@@ -53,6 +53,11 @@ export abstract class OrcaRuntimeWithMarkPtyLivenessUnverifiable extends OrcaRun
|
||||
this.stopRequestedPtyIds.add(ptyId)
|
||||
}
|
||||
|
||||
/** A stop that failed or was refused: the PTY runs on, so nothing may read it as closing. */
|
||||
clearPtyStopRequest(ptyId: string): void {
|
||||
this.stopRequestedPtyIds.delete(ptyId)
|
||||
}
|
||||
|
||||
isPtyStopRequested(ptyId: string): boolean {
|
||||
return this.stopRequestedPtyIds.has(ptyId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import { group, mainWt, repo, wt } from './sidebar-list-golden-factories.test-fixture'
|
||||
|
||||
// A group ahead of every other holding a git project and a folder project, a child group of it holding
|
||||
// only a folder project, and an empty group: the phone must file each project under its group, in order.
|
||||
const lake = repo('lake', 'Lake', { projectGroupId: 'pg-school', projectGroupOrder: 1 })
|
||||
const capstone = repo('capstone', 'capstone', {
|
||||
kind: 'folder',
|
||||
projectGroupId: 'pg-school',
|
||||
projectGroupOrder: 0
|
||||
})
|
||||
const notes = repo('notes', 'notes', { kind: 'folder', projectGroupId: 'pg-folder-only' })
|
||||
export const GROUPED_REPOS = [lake, capstone, notes]
|
||||
export const GROUPED_WORKTREES: AppState['worktreesByRepo'] = {
|
||||
lake: [mainWt(lake), wt(lake, 'essay', { sortOrder: 5 })],
|
||||
capstone: [mainWt(capstone, { branch: '' })],
|
||||
notes: [mainWt(notes, { branch: '' })]
|
||||
}
|
||||
export const GROUPS_OF_GROUPED_REPOS = [
|
||||
group('pg-school', 'school', { tabOrder: -1, color: '#22c55e' }),
|
||||
group('pg-folder-only', 'Folder only', { tabOrder: 5, parentGroupId: 'pg-school' }),
|
||||
group('pg-empty', 'Empty group', { tabOrder: 6 })
|
||||
]
|
||||
@@ -22,6 +22,11 @@ import type {
|
||||
import type { SmartStampFields } from '../../../../shared/sidebar-list/sort-stamps'
|
||||
import { buildPairedServerActivityFacts } from '../../../../shared/sidebar-list/paired-server-activity-facts'
|
||||
import { GOLDEN_NOW, folder, lineage } from './sidebar-list-golden-factories.test-fixture'
|
||||
import {
|
||||
GROUPED_REPOS,
|
||||
GROUPED_WORKTREES,
|
||||
GROUPS_OF_GROUPED_REPOS
|
||||
} from './sidebar-list-parity-catalog.test-fixture'
|
||||
import { sidebarWorkspaceRowFilters } from './sidebar-filter-actions'
|
||||
import {
|
||||
readWindowWorkspaceActivityFacts,
|
||||
@@ -76,9 +81,13 @@ const STAMPS: Record<string, SmartStampFields & { lastActivityAt?: number; isUnr
|
||||
* this host runs.
|
||||
*/
|
||||
export function parityWorldOverrides(base: Partial<AppState>): Partial<AppState> {
|
||||
const repoById = new Map((base.repos ?? []).map((repo) => [repo.id, repo]))
|
||||
const repos = [...(base.repos ?? []), ...GROUPED_REPOS]
|
||||
const repoById = new Map(repos.map((entry) => [entry.id, entry]))
|
||||
const worktreesByRepo: AppState['worktreesByRepo'] = {}
|
||||
for (const [repoId, worktrees] of Object.entries(base.worktreesByRepo ?? {})) {
|
||||
for (const [repoId, worktrees] of Object.entries({
|
||||
...base.worktreesByRepo,
|
||||
...GROUPED_WORKTREES
|
||||
})) {
|
||||
const repo = repoById.get(repoId)
|
||||
worktreesByRepo[repoId] = worktrees.map((worktree) => {
|
||||
const branch = worktree.branch.replace('refs/heads/', '')
|
||||
@@ -96,7 +105,8 @@ export function parityWorldOverrides(base: Partial<AppState>): Partial<AppState>
|
||||
const pending = Object.values(base.pendingWorktreeCreations ?? {})[0]
|
||||
const [serverMain, serverChild] = worktreesByRepo.api ?? []
|
||||
// The repos, projects and server groups as the window's all-host fetch folds them.
|
||||
const catalog = windowCatalogFromServerAnswers({ ...base, worktreesByRepo })
|
||||
const projectGroups = [...(base.projectGroups ?? []), ...GROUPS_OF_GROUPED_REPOS]
|
||||
const catalog = windowCatalogFromServerAnswers({ ...base, repos, worktreesByRepo, projectGroups })
|
||||
return {
|
||||
...catalog,
|
||||
// A server workspace nested under the server's main, and a server folder workspace.
|
||||
@@ -179,9 +189,12 @@ export function parityServerBrowserTabState(): Partial<AppState> {
|
||||
* The feed domains main would publish for this store, read through the window-side projections
|
||||
* the shadow comparison held equal to main's producers before the window read main's feed.
|
||||
*/
|
||||
export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDomain[] {
|
||||
export function feedDomainsFromWindowState(
|
||||
state: AppState,
|
||||
serverHostIds: ReadonlySet<string> = new Set([SERVER_HOST_ID])
|
||||
): SidebarInputsFeedDomain[] {
|
||||
const worktrees = getAllWorktreesFromState(state)
|
||||
const servers = readWindowPairedServerHosts(state, new Set([SERVER_HOST_ID]))
|
||||
const servers = readWindowPairedServerHosts(state, serverHostIds)
|
||||
const windowActivity = readWindowWorkspaceActivityFacts(state, GOLDEN_NOW)
|
||||
const serverRows = readWindowPairedServerRows(state, new Set(servers.keys()))
|
||||
// The server reports the terminals and agents the desktop runs in its workspaces.
|
||||
@@ -189,6 +202,9 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo
|
||||
const facts = windowActivity.get(row.worktreeId)
|
||||
return { ...row, activity: { terminal: facts?.terminal ?? null, agent: facts?.agent ?? null } }
|
||||
})
|
||||
// Each server reports only its own rows, keyed `hostId|worktreeId`.
|
||||
const rowsOf = <Row extends { key: string }>(rows: readonly Row[], hostId: string): Row[] =>
|
||||
rows.filter((row) => row.key.startsWith(`${hostId}|`))
|
||||
const domain = (
|
||||
name: string,
|
||||
read: SidebarInputsFeedDomain['read']
|
||||
@@ -211,7 +227,11 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo
|
||||
domain('activity', () => ({
|
||||
// The window's browser and chat tabs, as its saved session for the server holds them.
|
||||
...buildPairedServerActivityFacts(
|
||||
[{ hostId: SERVER_HOST_ID, freshness: 'live', rows: reportedRows }],
|
||||
[...servers.keys()].map((hostId) => ({
|
||||
hostId,
|
||||
freshness: 'live' as const,
|
||||
rows: rowsOf(reportedRows, hostId)
|
||||
})),
|
||||
() => ({
|
||||
browserTabsByWorktree: state.browserTabsByWorktree,
|
||||
unifiedTabs: state.unifiedTabsByWorktree
|
||||
@@ -221,7 +241,10 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo
|
||||
})),
|
||||
domain('review', () => ({
|
||||
...buildPairedServerReviewFacts(
|
||||
[{ hostId: SERVER_HOST_ID, rows: [...serverRows.values()] }],
|
||||
[...servers.keys()].map((hostId) => ({
|
||||
hostId,
|
||||
rows: rowsOf([...serverRows.values()], hostId)
|
||||
})),
|
||||
state.prCache
|
||||
),
|
||||
...Object.fromEntries(
|
||||
@@ -296,7 +319,7 @@ export function projectParityRows(
|
||||
]
|
||||
case 'header':
|
||||
return [
|
||||
`HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'}`
|
||||
`HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'} groupDepth=${row.projectGroupDepth ?? '-'}`
|
||||
]
|
||||
case 'item':
|
||||
return [
|
||||
|
||||
@@ -11,7 +11,8 @@ import { removeTabsAttention } from './subject-attention-transitions'
|
||||
import {
|
||||
markWorkspaceUnreadFlag,
|
||||
onScreenHostId,
|
||||
settleWorkspaceUnreadFlag
|
||||
settleWorkspaceUnreadFlag,
|
||||
noteWorkspaceRead
|
||||
} from './workspace-unread-flag'
|
||||
import { workspaceMarkerTabIds } from './workspace-live-markers'
|
||||
import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets'
|
||||
@@ -119,6 +120,7 @@ export const createWorkspaceAttentionSlice: StateCreator<
|
||||
},
|
||||
setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => {
|
||||
if (!isUnread) {
|
||||
noteWorkspaceRead(worktreeId)
|
||||
removeAndDismiss(workspaceMarkerTabIds(get(), worktreeId))
|
||||
}
|
||||
// Why: the toggle writes only the clicked host's row; the markers it removes are
|
||||
|
||||
+30
@@ -138,6 +138,17 @@ describe('the window raises the unread flag only where its host does not', () =>
|
||||
expect(isUnread(UNREACHABLE_SERVER_WT)).toBe(true)
|
||||
})
|
||||
|
||||
it('drops a raise still waiting on a server’s first status once the user marks it read', async () => {
|
||||
const { store, mark, isUnread } = createStore()
|
||||
let answer: (capabilities: readonly string[]) => void = () => {}
|
||||
hostCapabilities.set('slow', new Promise((resolve) => (answer = resolve)))
|
||||
await mark(SLOW_SERVER_WT)
|
||||
store.getState().setWorkspaceUnreadByUser(SLOW_SERVER_WT, false, 'runtime:slow')
|
||||
answer([])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(isUnread(SLOW_SERVER_WT)).toBe(false)
|
||||
})
|
||||
|
||||
it('decides a folder workspace by the host of the row it raises', async () => {
|
||||
const { store, mark, updateFolderWorkspace } = createStore()
|
||||
const folder = folderWorkspaceKey(FOLDER_ID)
|
||||
@@ -154,6 +165,25 @@ describe('the window raises the unread flag only where its host does not', () =>
|
||||
)
|
||||
})
|
||||
|
||||
it('raises the folder row it decided for, even if the screen moves to the other row meanwhile', async () => {
|
||||
const { store, mark, updateFolderWorkspace } = createStore()
|
||||
const folder = folderWorkspaceKey(FOLDER_ID)
|
||||
let answer: (capabilities: readonly string[]) => void = () => {}
|
||||
hostCapabilities.set('old', new Promise((resolve) => (answer = resolve)))
|
||||
store.setState({ activeWorktreeId: folder, activeWorkspaceExecutionHostId: 'runtime:old' })
|
||||
await mark(folder)
|
||||
store.setState({ activeWorkspaceExecutionHostId: 'runtime:new' })
|
||||
answer([])
|
||||
await vi.waitFor(() =>
|
||||
expect(updateFolderWorkspace).toHaveBeenCalledWith(
|
||||
FOLDER_ID,
|
||||
expect.objectContaining({ isUnread: true }),
|
||||
{ executionHostId: 'runtime:old' }
|
||||
)
|
||||
)
|
||||
expect(updateFolderWorkspace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears a flag on every owner, whichever side raised it', () => {
|
||||
const { store, isUnread } = createStore()
|
||||
for (const id of [LOCAL_WT, NEW_SERVER_WT, OLD_SERVER_WT]) {
|
||||
|
||||
@@ -12,12 +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 { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../shared/workspace-unread-raise-capability'
|
||||
import {
|
||||
readCachedRuntimeEnvironmentCapability,
|
||||
runtimeEnvironmentSupportsCapability
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import { tryRuntimeTargetForWorktreeOwner } from '../worktrees/listing/worktree-owner-target'
|
||||
import { ownerHostOwnsSidebarInputs } from '../worktrees/listing/owner-host-sidebar-inputs'
|
||||
|
||||
// The persisted workspace flag `isUnread`. Only the workspace-attention module writes it.
|
||||
|
||||
@@ -31,47 +26,47 @@ export function markWorkspaceUnreadFlag(
|
||||
return
|
||||
}
|
||||
// Switch point: the owning host raises the flag from the same news, unless it is older.
|
||||
void ownerRaisesUnreadFlag(get(), worktreeId).then((ownerRaises) => {
|
||||
if (!ownerRaises) {
|
||||
raiseWorkspaceUnreadFlag(set, get, worktreeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the owning host raises the flag itself. A local owner runs this build (main, its SSH
|
||||
* hosts, or the host that served the browser client). A paired server answers by capability; an
|
||||
* unreadable status raises here, so no flag is lost.
|
||||
*/
|
||||
async function ownerRaisesUnreadFlag(state: AppState, worktreeId: string): Promise<boolean> {
|
||||
// Why the row's host now: same-id folder rows differ by host, and the screen can move to the
|
||||
// other row while the owner is asked.
|
||||
const state = get()
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
const folderRow =
|
||||
workspaceScope?.type === 'folder'
|
||||
? folderRowToMark(state, worktreeId, workspaceScope.folderWorkspaceId)
|
||||
: undefined
|
||||
const target = tryRuntimeTargetForWorktreeOwner(
|
||||
state,
|
||||
worktreeId,
|
||||
folderRow ? getFolderWorkspaceHostId(folderRow, state.projectGroups) : undefined
|
||||
)
|
||||
if (!target) {
|
||||
return false
|
||||
const folderHostId = folderRow
|
||||
? getFolderWorkspaceHostId(folderRow, state.projectGroups)
|
||||
: undefined
|
||||
const pending = pendingRaises.get(worktreeId) ?? { raises: 0, reads: 0 }
|
||||
pendingRaises.set(worktreeId, pending)
|
||||
pending.raises += 1
|
||||
const readsBefore = pending.reads
|
||||
void ownerHostOwnsSidebarInputs(state, worktreeId, folderHostId)
|
||||
.then((ownerRaises) => {
|
||||
// A read while the owner was being asked already covers this news.
|
||||
if (!ownerRaises && pending.reads === readsBefore) {
|
||||
raiseWorkspaceUnreadFlag(set, get, worktreeId, folderHostId)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
pending.raises -= 1
|
||||
if (pending.raises === 0) {
|
||||
pendingRaises.delete(worktreeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Raises still waiting on their owner check, per workspace, with the reads seen meanwhile. Why
|
||||
* not per host: a read names its row's host differently from the row's own host lookup (a
|
||||
* group-hosted folder reads as 'local'), so a per-host count could miss the read it must see. */
|
||||
const pendingRaises = new Map<string, { raises: number; reads: number }>()
|
||||
|
||||
/** A read of the workspace by any path; a raise still waiting on its owner check drops. */
|
||||
export function noteWorkspaceRead(worktreeId: string): void {
|
||||
const pending = pendingRaises.get(worktreeId)
|
||||
if (pending) {
|
||||
pending.reads += 1
|
||||
}
|
||||
if (target.kind === 'local') {
|
||||
return true
|
||||
}
|
||||
// Why the cached answer first: a per-raise probe would delay the flag past a screen arrival.
|
||||
const cached = readCachedRuntimeEnvironmentCapability(
|
||||
target.environmentId,
|
||||
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
|
||||
)
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
}
|
||||
return runtimeEnvironmentSupportsCapability(
|
||||
target.environmentId,
|
||||
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
|
||||
).catch(() => false)
|
||||
}
|
||||
|
||||
/** Same-id folder rows: the on-screen host's, else the first. */
|
||||
@@ -80,7 +75,15 @@ function folderRowToMark(
|
||||
worktreeId: string,
|
||||
folderWorkspaceId: string
|
||||
): FolderWorkspace | undefined {
|
||||
const host = onScreenHostId(state, worktreeId)
|
||||
return folderRowOnHost(state, folderWorkspaceId, onScreenHostId(state, worktreeId))
|
||||
}
|
||||
|
||||
/** The folder row on `host`, or the first same-id row when no host is named. */
|
||||
function folderRowOnHost(
|
||||
state: AppState,
|
||||
folderWorkspaceId: string,
|
||||
host: ExecutionHostId | null
|
||||
): FolderWorkspace | undefined {
|
||||
return state.folderWorkspaces.find(
|
||||
(workspace) =>
|
||||
workspace.id === folderWorkspaceId &&
|
||||
@@ -91,14 +94,18 @@ function folderRowToMark(
|
||||
function raiseWorkspaceUnreadFlag(
|
||||
set: WorkspaceAttentionSet,
|
||||
get: WorkspaceAttentionGet,
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
/** The folder row's host when the news arrived; unset picks the on-screen row now. */
|
||||
folderHostId?: ExecutionHostId
|
||||
): void {
|
||||
const stamp = { lastActivityAt: Date.now() }
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
const folderWorkspaceId = workspaceScope.folderWorkspaceId
|
||||
const state = get()
|
||||
const marked = folderRowToMark(state, worktreeId, folderWorkspaceId)
|
||||
const marked = folderHostId
|
||||
? folderRowOnHost(state, folderWorkspaceId, folderHostId)
|
||||
: folderRowToMark(state, worktreeId, folderWorkspaceId)
|
||||
if (!marked || marked.isUnread) {
|
||||
return
|
||||
}
|
||||
@@ -112,7 +119,9 @@ function raiseWorkspaceUnreadFlag(
|
||||
void get().updateFolderWorkspace(
|
||||
folderWorkspaceId,
|
||||
{ isUnread: true, ...stamp },
|
||||
{ executionHostId: getFolderWorkspaceHostId(marked, state.projectGroups) }
|
||||
{
|
||||
executionHostId: getFolderWorkspaceHostId(marked, state.projectGroups)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -164,6 +173,7 @@ 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 {
|
||||
noteWorkspaceRead(worktreeId)
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
const folderWorkspaceId = workspaceScope.folderWorkspaceId
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../../shared/workspace-unread-raise-capability'
|
||||
import {
|
||||
readCachedRuntimeEnvironmentCapability,
|
||||
runtimeEnvironmentSupportsCapability
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import type { AppState } from '../../../types'
|
||||
import { tryRuntimeTargetForWorktreeOwner } from './worktree-owner-target'
|
||||
|
||||
/**
|
||||
* Whether the workspace's owning host runs main's sidebar inputs, so it raises the unread flag
|
||||
* itself. A local owner runs this build (main, its SSH hosts, or the host that served the browser
|
||||
* client). A paired server answers by capability; an unresolved owner or an unreadable status
|
||||
* counts as older, so this window raises the flag.
|
||||
*/
|
||||
export async function ownerHostOwnsSidebarInputs(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<boolean> {
|
||||
const target = tryRuntimeTargetForWorktreeOwner(state, worktreeId, executionHostId)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
if (target.kind === 'local') {
|
||||
return true
|
||||
}
|
||||
// Why the cached answer first: a per-write probe would delay the flag past a screen arrival.
|
||||
const cached = readCachedRuntimeEnvironmentCapability(
|
||||
target.environmentId,
|
||||
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
|
||||
)
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
}
|
||||
return runtimeEnvironmentSupportsCapability(
|
||||
target.environmentId,
|
||||
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
|
||||
).catch(() => false)
|
||||
}
|
||||
Reference in New Issue
Block a user