Просмотр исходного кода

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'.
GeeeekExplorer 1 месяц назад
Родитель
Сommit
ffdcafb45f

+ 5 - 0
packages/client/runtime/src/client/sessions/lineage.ts

@@ -29,6 +29,8 @@ export interface SessionListEntry {
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
   /** User interaction currently blocking this session, derived from live mux frames. */
   /** User interaction currently blocking this session, derived from live mux frames. */
   pendingInteraction?: PendingInteractionStatus
   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. */
   /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
   depth: number
   depth: number
 }
 }
@@ -39,11 +41,13 @@ export interface SessionListEntry {
  * hydrated list from mutable timestamps.
  * hydrated list from mutable timestamps.
  * @param summaries - the host's session.list items.
  * @param summaries - the host's session.list items.
  * @param pendingInteractions - current manager-owned interaction status by session.
  * @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.
  * @returns display rows in render order.
  */
  */
 export function flattenLineage(
 export function flattenLineage(
   summaries: readonly TitledSessionSummary[],
   summaries: readonly TitledSessionSummary[],
   pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
   pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
+  completed?: ReadonlySet<SessionId>,
 ): SessionListEntry[] {
 ): SessionListEntry[] {
   const byId = new Map<SessionId, TitledSessionSummary>()
   const byId = new Map<SessionId, TitledSessionSummary>()
   for (const s of summaries) byId.set(s.sessionId, s)
   for (const s of summaries) byId.set(s.sessionId, s)
@@ -72,6 +76,7 @@ export function flattenLineage(
     out.push({
     out.push({
       ...s,
       ...s,
       ...(pendingInteraction === undefined ? {} : { pendingInteraction }),
       ...(pendingInteraction === undefined ? {} : { pendingInteraction }),
+      completed: completed?.has(s.sessionId) ?? false,
       depth,
       depth,
     })
     })
     const kids = children.get(s.sessionId)
     const kids = children.get(s.sessionId)

+ 64 - 3
packages/client/runtime/src/client/sessions/manager.ts

@@ -109,6 +109,14 @@ export class SessionManager {
    *  sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
    *  sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
    *  still-pending requests — and on session-removed. */
    *  still-pending requests — and on session-removed. */
   private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
   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
   /** Per-session projection value stores, retained independently of instance arrival (the
    *  title-snapshot precedent, generalized): push frames land here whether or not the Session
    *  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
    *  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.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
     )
     )
     this.selected = sessionId
     this.selected = sessionId
+    // Looking at the session consumes its completion reminder (dot clears).
+    this.completedNotifications.delete(sessionId)
     void this.refreshSubagents(sessionId)
     void this.refreshSubagents(sessionId)
     this.notifier.notifyNow()
     this.notifier.notifyNow()
   }
   }
@@ -192,6 +202,7 @@ export class SessionManager {
     this.addresses.set(address.childSessionId, address)
     this.addresses.set(address.childSessionId, address)
     this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
     this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
     this.selected = address.childSessionId
     this.selected = address.childSessionId
+    this.completedNotifications.delete(address.childSessionId)
     void this.refreshSubagents(address.childSessionId)
     void this.refreshSubagents(address.childSessionId)
     this.notifier.notifyNow()
     this.notifier.notifyNow()
   }
   }
@@ -414,13 +425,28 @@ export class SessionManager {
       try {
       try {
         const { result } = await this.api.sessions.list({})
         const { result } = await this.api.sessions.list({})
         if (result.ok) {
         if (result.ok) {
-          let summaries = this.listPhase === 'pending'
+          const baseline = this.listPhase === 'pending'
             ? result.value.items
             ? result.value.items
             : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
             : 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.summaries = summaries
           this.listState = 'idle'
           this.listState = 'idle'
           this.listPhase = 'ready'
           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).
           // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
           for (const s of this.summaries) {
           for (const s of this.summaries) {
             const session = this.sessions.get(s.sessionId)
             const session = this.sessions.get(s.sessionId)
@@ -566,6 +592,8 @@ export class SessionManager {
   private recordMutation(mutation: SessionListMutation): void {
   private recordMutation(mutation: SessionListMutation): void {
     this.listMutations?.push(mutation)
     this.listMutations?.push(mutation)
     this.summaries = applyMutation(this.summaries, 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()
     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 {
   private buildListSnapshot(): SessionListSnapshot {
     const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
     const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
       // List rows read the generic 'title' projection key (host-computed unit
       // 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]
       const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
       if (status !== undefined) pendingInteractions.set(sessionId, status)
       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 items = fresh.map((entry) => {
       const prev = this.entryCache.get(entry.sessionId)
       const prev = this.entryCache.get(entry.sessionId)
       if (
       if (
@@ -924,6 +984,7 @@ export class SessionManager {
         && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
         && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
         && prev.pendingInteraction === entry.pendingInteraction
         && prev.pendingInteraction === entry.pendingInteraction
         && prev.projectionValues === entry.projectionValues
         && prev.projectionValues === entry.projectionValues
+        && prev.completed === entry.completed
       ) return prev
       ) return prev
       this.entryCache.set(entry.sessionId, entry)
       this.entryCache.set(entry.sessionId, entry)
       return entry
       return entry

+ 3 - 0
packages/client/runtime/src/client/sessions/service.ts

@@ -51,6 +51,8 @@ export interface SessionSummary {
   running: boolean
   running: boolean
   /** User interaction currently blocking this session (sidebar amber-dot state). */
   /** User interaction currently blocking this session (sidebar amber-dot state). */
   pendingInteraction?: PendingInteractionStatus
   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
    * Empty-log bit (host summary derivation mirror). New Session reuses a blank
    * one targeting the same workspace. Filtering stays with the consumer: the
    * one targeting the same workspace. Filtering stays with the consumer: the
@@ -614,6 +616,7 @@ export class SessionsService implements ISessions {
         id: entry.sessionId,
         id: entry.sessionId,
         displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
         displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
         running: entry.running,
         running: entry.running,
+        ...(entry.completed ? { completed: true } : {}),
         blank: entry.blank,
         blank: entry.blank,
         updatedAt: entry.updatedAt,
         updatedAt: entry.updatedAt,
         ...(entry.pendingInteraction === undefined
         ...(entry.pendingInteraction === undefined

+ 7 - 0
packages/client/runtime/tests/lineage.spec.ts

@@ -52,4 +52,11 @@ describe('flattenLineage', () => {
       warnSpy.mockRestore()
       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)
+  })
 })
 })

+ 125 - 0
packages/client/runtime/tests/manager.spec.ts

@@ -985,3 +985,128 @@ describe('pending-interaction list status', () => {
     expect(session.getSnapshot().pending).toEqual([])
     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)
+  })
+})

+ 2 - 0
packages/client/ui-workspace/src/client/locales.ts

@@ -49,6 +49,7 @@ export const zh = {
   'status.waitingApproval': '等待审批',
   'status.waitingApproval': '等待审批',
   'status.planReview': '计划待审',
   'status.planReview': '计划待审',
   'status.waitingAnswer': '等待回答',
   'status.waitingAnswer': '等待回答',
+  'status.completed': '已完成',
   'hover.created': '创建于 {time}',
   'hover.created': '创建于 {time}',
   'hover.copied': '已复制',
   'hover.copied': '已复制',
   'date.ymd': '{y}年{m}月{d}日',
   'date.ymd': '{y}年{m}月{d}日',
@@ -109,6 +110,7 @@ export const en = {
   'status.waitingApproval': 'Waiting for approval',
   'status.waitingApproval': 'Waiting for approval',
   'status.planReview': 'Plan awaiting review',
   'status.planReview': 'Plan awaiting review',
   'status.waitingAnswer': 'Waiting for answer',
   'status.waitingAnswer': 'Waiting for answer',
+  'status.completed': 'Completed',
   'hover.created': 'Created {time}',
   'hover.created': 'Created {time}',
   'hover.copied': 'Copied',
   'hover.copied': 'Copied',
   'date.ymd': '{y}-{m}-{d}',
   'date.ymd': '{y}-{m}-{d}',

+ 8 - 4
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -173,7 +173,7 @@ function assertNever(value: never): never {
 
 
 /** Session status presentation; pending user interaction outranks the running state. */
 /** Session status presentation; pending user interaction outranks the running state. */
 function sessionStatus(
 function sessionStatus(
-  node: Pick<SessionNode, 'pendingInteraction' | 'running'>,
+  node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'completed'>,
   t: RowTranslate,
   t: RowTranslate,
 ): { state: StateDotState; label: string } {
 ): { state: StateDotState; label: string } {
   switch (node.pendingInteraction) {
   switch (node.pendingInteraction) {
@@ -185,10 +185,11 @@ function sessionStatus(
     default: return assertNever(node.pendingInteraction)
     default: return assertNever(node.pendingInteraction)
   }
   }
   if (node.running) return { state: 'ongoing', label: t('status.running') }
   if (node.running) return { state: 'ongoing', label: t('status.running') }
+  if (node.completed) return { state: 'done', label: t('status.completed') }
   return { state: 'done', label: t('status.idle') }
   return { state: 'done', label: t('status.idle') }
 }
 }
 
 
-/** Hover-card body: full title, relative time, and interaction/running/idle status. */
+/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */
 function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
 function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
   const status = sessionStatus(node, t)
   const status = sessionStatus(node, t)
   return (
   return (
@@ -251,7 +252,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
     >
     >
       <span className={css.searchResultHeading}>
       <span className={css.searchResultHeading}>
         <span className={css.slot}>
         <span className={css.slot}>
-          {status.state !== 'done' && (
+          {(status.state !== 'done' || result.completed) && (
             <>
             <>
               <StateDot state={status.state} />
               <StateDot state={status.state} />
               <span className={css.visuallyHidden}>{status.label}</span>
               <span className={css.visuallyHidden}>{status.label}</span>
@@ -351,8 +352,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
           drag.drop(rowHalf(e))
           drag.drop(rowHalf(e))
         }}
         }}
     >
     >
+      {/* Pending interactions and running outrank the idle state; a
+          finished-but-unviewed session shows the green done reminder dot
+          (cleared by opening the session). */}
       <span className={css.slot}>
       <span className={css.slot}>
-        {status.state !== 'done' && (
+        {(status.state !== 'done' || row.completed) && (
           <>
           <>
             <StateDot state={status.state} />
             <StateDot state={status.state} />
             <span className={css.visuallyHidden}>{status.label}</span>
             <span className={css.visuallyHidden}>{status.label}</span>

+ 6 - 0
packages/client/ui-workspace/src/client/tree.ts

@@ -24,6 +24,8 @@ export interface SessionNode {
   /** The runtime Session list reports an interaction awaiting this user. */
   /** The runtime Session list reports an interaction awaiting this user. */
   pendingInteraction?: PendingInteractionStatus
   pendingInteraction?: PendingInteractionStatus
   running: boolean
   running: boolean
+  /** Finished running while not selected and not yet opened (the green "done" reminder dot). */
+  completed: boolean
   updatedAt: number
   updatedAt: number
 }
 }
 
 
@@ -54,6 +56,8 @@ export interface SearchResultNode {
   /** The runtime Session list reports an interaction awaiting this user. */
   /** The runtime Session list reports an interaction awaiting this user. */
   pendingInteraction?: PendingInteractionStatus
   pendingInteraction?: PendingInteractionStatus
   running: boolean
   running: boolean
+  /** Finished running while not selected and not yet opened (the green "done" reminder dot). */
+  completed: boolean
   snippet?: string
   snippet?: string
 }
 }
 
 
@@ -175,6 +179,7 @@ function sessionNode(s: SessionSummary): SessionNode {
     title: sessionTitle(s),
     title: sessionTitle(s),
     blank: s.blank,
     blank: s.blank,
     running: s.running,
     running: s.running,
+    completed: s.completed === true,
     updatedAt: s.updatedAt,
     updatedAt: s.updatedAt,
     ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
     ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
   }
   }
@@ -330,6 +335,7 @@ export function deriveSearchResults(
         ...(summary.pendingInteraction === undefined
         ...(summary.pendingInteraction === undefined
           ? {}
           ? {}
           : { pendingInteraction: summary.pendingInteraction }),
           : { pendingInteraction: summary.pendingInteraction }),
+        completed: summary.completed === true,
         ...match === undefined ? {} : { snippet: match.snippet },
         ...match === undefined ? {} : { snippet: match.snippet },
       }
       }
     }),
     }),

+ 58 - 8
packages/client/ui-workspace/tests/rows.spec.tsx

@@ -64,6 +64,7 @@ describe('workspace browser rows', () => {
       title: 'Result title',
       title: 'Result title',
       workspace: 'Workspace context',
       workspace: 'Workspace context',
       running: true,
       running: true,
+      completed: false,
       snippet: 'matching message excerpt',
       snippet: 'matching message excerpt',
     }
     }
     render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />)
     render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />)
@@ -85,7 +86,7 @@ describe('workspace browser rows', () => {
   ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
   ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
     const result: SearchResultNode = {
     const result: SearchResultNode = {
       id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
       id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
-      pendingInteraction, running: true,
+      pendingInteraction, running: true, completed: false,
     }
     }
     render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
     render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
     const row = screen.getByRole('treeitem')
     const row = screen.getByRole('treeitem')
@@ -114,7 +115,7 @@ describe('workspace browser rows', () => {
 
 
   it('renders and opens a selected running Session row', () => {
   it('renders and opens a selected running Session row', () => {
     const node: SessionNode = {
     const node: SessionNode = {
-      id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
+      id: sid('session'), title: 'Session', blank: false, running: true, completed: false, updatedAt: 0,
     }
     }
     const onOpen = vi.fn()
     const onOpen = vi.fn()
     render(
     render(
@@ -130,6 +131,38 @@ describe('workspace browser rows', () => {
     expect(onOpen).toHaveBeenCalledWith(node.id)
     expect(onOpen).toHaveBeenCalledWith(node.id)
   })
   })
 
 
+  it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => {
+    const renderRow = (over: Partial<SessionNode>) => render(
+      <SessionNodeItem
+        node={{ id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0, ...over }}
+        currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
+      />,
+    )
+    const stateDot = (view: ReturnType<typeof renderRow>) =>
+      view.container.querySelector('[data-state]')
+    // No completion reminder, not running: no state dot at all.
+    const plain = renderRow({})
+    expect(stateDot(plain)).toBeNull()
+    plain.unmount()
+    // Completed while unviewed: the green done dot.
+    const done = renderRow({ completed: true })
+    expect(done.container.querySelector('[data-state="done"]')).not.toBeNull()
+    done.unmount()
+    // Running wins the slot: the animated ongoing dot, no done dot.
+    const running = renderRow({ completed: true, running: true })
+    expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
+    expect(running.container.querySelector('[data-state="done"]')).toBeNull()
+  })
+
+  it('shows the green done dot on a finished search result row', () => {
+    render(<SearchResultItem
+      result={{ id: sid('result'), title: 'Done', workspace: 'Workspace', running: false, completed: true }}
+      currentId={undefined} onOpen={vi.fn()} t={t}
+    />)
+    expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
+  })
+
   it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
   it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
     const onRename = vi.fn()
     const onRename = vi.fn()
     const onDelete = vi.fn()
     const onDelete = vi.fn()
@@ -198,7 +231,7 @@ describe('workspace browser rows', () => {
     vi.useFakeTimers()
     vi.useFakeTimers()
     try {
     try {
       const node: SessionNode = {
       const node: SessionNode = {
-        id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0,
+        id: sid('s-blank'), title: 'ignored', blank: true, running: false, completed: false, updatedAt: 0,
       }
       }
       render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
       render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -224,7 +257,7 @@ describe('workspace browser rows', () => {
     const onFork = vi.fn()
     const onFork = vi.fn()
     const onArchive = vi.fn()
     const onArchive = vi.fn()
     const node: SessionNode = {
     const node: SessionNode = {
-      id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
+      id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0,
     }
     }
     render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
     render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
       onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
       onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -257,7 +290,7 @@ describe('workspace browser rows', () => {
     vi.useFakeTimers()
     vi.useFakeTimers()
     try {
     try {
       const node: SessionNode = {
       const node: SessionNode = {
-        id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
+        id: sid('s1'), title: 'Hovered', blank: false, running: true, completed: false, updatedAt: 0,
       }
       }
       render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
       render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -288,7 +321,7 @@ describe('workspace browser rows', () => {
     try {
     try {
       const node: SessionNode = {
       const node: SessionNode = {
         id: sid(pendingInteraction), title: 'Needs input', blank: false,
         id: sid(pendingInteraction), title: 'Needs input', blank: false,
-        pendingInteraction, running: true, updatedAt: 0,
+        pendingInteraction, running: true, completed: false, updatedAt: 0,
       }
       }
       const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
       const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -314,7 +347,7 @@ describe('workspace browser rows', () => {
     vi.useFakeTimers()
     vi.useFakeTimers()
     try {
     try {
       const node: SessionNode = {
       const node: SessionNode = {
-        id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
+        id: sid('s1'), title: 'Quiet', blank: false, running: false, completed: false, updatedAt: 0,
       }
       }
       render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
       render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
         onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -327,9 +360,26 @@ describe('workspace browser rows', () => {
     }
     }
   })
   })
 
 
+  it('completed hover card shows the Completed status line', () => {
+    vi.useFakeTimers()
+    try {
+      const node: SessionNode = {
+        id: sid('s1'), title: 'Done', blank: false, running: false, completed: true, updatedAt: 0,
+      }
+      render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
+      fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
+      act(() => { vi.advanceTimersByTime(500) })
+      // Row's visually-hidden reminder label plus the hover card's status line.
+      expect(screen.getAllByText('已完成')).toHaveLength(2)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
   it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
     const node: SessionNode = {
     const node: SessionNode = {
-      id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
+      id: sid('s1'), title: 'Drag me', blank: false, running: false, completed: false, updatedAt: 0,
     }
     }
     const inactive = dragProps()
     const inactive = dragProps()
     const { rerender } = render(
     const { rerender } = render(

+ 19 - 0
packages/client/ui-workspace/tests/tree.spec.ts

@@ -77,6 +77,22 @@ describe('deriveGroups', () => {
     expect(strayGroups.map(group => group.key)).toEqual(['first'])
     expect(strayGroups.map(group => group.key)).toEqual(['first'])
   })
   })
 
 
+  it('projects the completion reminder into session and search rows (absent = false)', () => {
+    const done = { ...summary('done', 3), completed: true }
+    const plain = summary('plain', 2)
+    const sessions = list(done, plain)
+    const groups = deriveGroups(
+      sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']),
+    )
+    const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
+    const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
+    expect(doneNode.completed).toBe(true)
+    expect(plainNode.completed).toBe(false)
+    expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true)
+    const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10)
+    expect(search.items[0]?.completed).toBe(true)
+  })
+
   it('hides subagent-origin sessions without hiding ordinary forks', () => {
   it('hides subagent-origin sessions without hiding ordinary forks', () => {
     const parent = summary('parent', 1)
     const parent = summary('parent', 1)
     const fork = { ...summary('fork', 2), parentId: parent.id }
     const fork = { ...summary('fork', 2), parentId: parent.id }
@@ -259,6 +275,7 @@ describe('deriveSearchResults', () => {
           workspace: 'Alpha',
           workspace: 'Alpha',
           running: false,
           running: false,
           pendingInteraction: 'plan-review',
           pendingInteraction: 'plan-review',
+          completed: false,
           snippet: 'title session body excerpt',
           snippet: 'title session body excerpt',
         },
         },
         {
         {
@@ -266,12 +283,14 @@ describe('deriveSearchResults', () => {
           title: 'Ordinary title',
           title: 'Ordinary title',
           workspace: 'Needle Workspace',
           workspace: 'Needle Workspace',
           running: false,
           running: false,
+          completed: false,
         },
         },
         {
         {
           id: contentHit.id,
           id: contentHit.id,
           title: 'content-hit',
           title: 'content-hit',
           workspace: 'c',
           workspace: 'c',
           running: false,
           running: false,
+          completed: false,
           snippet: 'body needle excerpt',
           snippet: 'body needle excerpt',
         },
         },
       ],
       ],