feat(web): done dot on sessions that finished while unviewed

A session that stops running while it is not the selected session arms a
green 'done' reminder dot on its sidebar row, so the operator notices a
finished background session and returns to it; opening the session clears
the dot, and a re-run re-arms it on completion.

SessionManager owns the reminder set (a sibling of the waiting-approval
bit): a running->idle edge of a non-selected session arms it, select()
consumes it, removal prunes it, and it survives connection generations.
The bit rides SessionListEntry/SessionSummary into the workspace browser
rows, which render the existing StateDot done state (running keeps the
spinner) and label the hover card '已完成/Completed'.
This commit is contained in:
GeeeekExplorer
2026-08-05 16:42:51 +08:00
parent 17ff1e0d4a
commit ffdcafb45f
10 changed files with 297 additions and 15 deletions

View File

@@ -29,6 +29,8 @@ export interface SessionListEntry {
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** User interaction currently blocking this session, derived from live mux frames. */
pendingInteraction?: PendingInteractionStatus
/** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
completed: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -39,11 +41,13 @@ export interface SessionListEntry {
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param pendingInteractions - current manager-owned interaction status by session.
* @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(
summaries: readonly TitledSessionSummary[],
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
completed?: ReadonlySet<SessionId>,
): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -72,6 +76,7 @@ export function flattenLineage(
out.push({
...s,
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
completed: completed?.has(s.sessionId) ?? false,
depth,
})
const kids = children.get(s.sessionId)

View File

@@ -109,6 +109,14 @@ export class SessionManager {
* sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
* still-pending requests — and on session-removed. */
private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
/**
* Sessions that finished running while not selected — the sidebar's green
* "done" reminder (manager-owned, survives connection generations; cleared
* on select and session-removed, re-armed by the next completion).
*/
private readonly completedNotifications = new Set<SessionId>()
/** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */
private readonly prevRunning = new Map<SessionId, boolean>()
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -175,6 +183,8 @@ export class SessionManager {
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
)
this.selected = sessionId
// Looking at the session consumes its completion reminder (dot clears).
this.completedNotifications.delete(sessionId)
void this.refreshSubagents(sessionId)
this.notifier.notifyNow()
}
@@ -192,6 +202,7 @@ export class SessionManager {
this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
this.selected = address.childSessionId
this.completedNotifications.delete(address.childSessionId)
void this.refreshSubagents(address.childSessionId)
this.notifier.notifyNow()
}
@@ -414,13 +425,28 @@ export class SessionManager {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
let summaries = this.listPhase === 'pending'
const baseline = this.listPhase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
// Seed first observations from the pull-time baseline BEFORE replaying
// in-flight mutations, then reconcile the reminders after EVERY
// replayed mutation: an edge that happens entirely between mutations
// (baseline idle → running → idle) must still arm, which a single
// sync on the folded result would collapse away.
for (const s of baseline) {
if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running)
}
let summaries = baseline
for (const mutation of mutations) {
summaries = applyMutation(summaries, mutation)
this.summaries = summaries
this.syncCompletedNotifications()
}
this.summaries = summaries
this.listState = 'idle'
this.listPhase = 'ready'
// Covers the empty-mutations pull (a plain baseline carries no edge).
this.syncCompletedNotifications()
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
@@ -566,6 +592,8 @@ export class SessionManager {
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, mutation)
// Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
this.syncCompletedNotifications()
this.notifier.markDirty()
}
@@ -893,6 +921,38 @@ export class SessionManager {
})
}
/**
* Reconcile completion reminders against the latest summaries, eagerly after
* every mutation and pull (a snapshot-build-time pass would collapse
* consecutive status frames into one observation). A running→idle edge of a
* non-selected session arms its reminder; running disarms it; removal drops
* it. First observation only records the running bit — sessions already
* idle at load get no reminder.
*/
private syncCompletedNotifications(): void {
const seen = new Set<SessionId>()
for (const s of this.summaries) {
seen.add(s.sessionId)
const prev = this.prevRunning.get(s.sessionId)
if (prev === undefined) {
this.prevRunning.set(s.sessionId, s.running)
continue
}
if (prev && !s.running) {
if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId)
} else if (s.running) {
this.completedNotifications.delete(s.sessionId)
}
this.prevRunning.set(s.sessionId, s.running)
}
for (const id of this.prevRunning.keys()) {
if (!seen.has(id)) this.prevRunning.delete(id)
}
for (const id of this.completedNotifications) {
if (!seen.has(id)) this.completedNotifications.delete(id)
}
}
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit
@@ -914,7 +974,7 @@ export class SessionManager {
const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
if (status !== undefined) pendingInteractions.set(sessionId, status)
}
const fresh = flattenLineage(merged, pendingInteractions)
const fresh = flattenLineage(merged, pendingInteractions, this.completedNotifications)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
@@ -924,6 +984,7 @@ export class SessionManager {
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.pendingInteraction === entry.pendingInteraction
&& prev.projectionValues === entry.projectionValues
&& prev.completed === entry.completed
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -51,6 +51,8 @@ export interface SessionSummary {
running: boolean
/** User interaction currently blocking this session (sidebar amber-dot state). */
pendingInteraction?: PendingInteractionStatus
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
completed?: boolean
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
@@ -614,6 +616,7 @@ export class SessionsService implements ISessions {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
...(entry.completed ? { completed: true } : {}),
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.pendingInteraction === undefined

View File

@@ -52,4 +52,11 @@ describe('flattenLineage', () => {
warnSpy.mockRestore()
}
})
it('projects the completion-reminder set into rows (absent = false)', () => {
const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId]))
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
})
})

View File

@@ -985,3 +985,128 @@ describe('pending-interaction list status', () => {
expect(session.getSnapshot().pending).toEqual([])
})
})
describe('completed reminder', () => {
const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({
rpcId: rpcId as never,
payload: { type: 'host/session-status' as const, sessionId, running },
})
const added = (rpcId: string, sessionId: SessionId) => ({
rpcId: rpcId as never,
payload: { type: 'host/session-added' as const, sessionId, blank: false },
})
const entry = (manager: SessionManager, sessionId: SessionId) =>
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
// Opening the session consumes the reminder.
manager.select(S2)
expect(entry(manager, S2)?.completed).toBe(false)
})
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S2)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
// Switch away; a fresh run completing again arms the reminder.
manager.select(S1)
manager.handleHostEnvelope(status('s3', S2, true))
manager.handleHostEnvelope(status('s4', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
})
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
// The user starts a new run without opening the session: running wins.
manager.handleHostEnvelope(status('s3', S2, true))
expect(entry(manager, S2)?.completed).toBe(false)
manager.handleHostEnvelope(status('s4', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
})
it('session-removed drops the reminder and a re-add starts clean', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
manager.handleHostEnvelope(added('h3', S2))
expect(entry(manager, S2)?.completed).toBe(false)
})
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(true)
})
it('never arms for sessions already idle at first observation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(false)
})
it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const refresh = manager.refreshList()
// The session finishes while the first pull is still in flight; the pull
// response recorded it as running at pull time.
manager.handleHostEnvelope(status('s-mid', S2, false))
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const refresh = manager.refreshList()
// The unknown session starts and finishes while the first pull is in
// flight; the pull-time baseline recorded it idle, so the running→idle
// edge lives entirely inside the replayed mutations.
manager.handleHostEnvelope(status('s-start', S2, true))
manager.handleHostEnvelope(status('s-finish', S2, false))
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
})