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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user