feat(client): surface subagent activity in sidebar

This commit is contained in:
Yichen Jiang
2026-08-08 14:42:07 +08:00
parent 915a56208d
commit 04e6a98064
20 changed files with 320 additions and 93 deletions
@@ -45,6 +45,8 @@ export const zh = {
'actions.session.aria': '会话“{name}”的操作',
'actions.newSession.aria': '在“{name}”中新建会话',
'status.running': '进行中',
'status.subagentsRunning.one': '{n} 个子代理运行中',
'status.subagentsRunning.other': '{n} 个子代理运行中',
'status.idle': '空闲',
'status.waitingApproval': '等待审批',
'status.planReview': '计划待审',
@@ -106,6 +108,8 @@ export const en = {
'actions.session.aria': 'Session actions for {name}',
'actions.newSession.aria': 'New session in {name}',
'status.running': 'Running',
'status.subagentsRunning.one': '{n} subagent running',
'status.subagentsRunning.other': '{n} subagents running',
'status.idle': 'Idle',
'status.waitingApproval': 'Waiting for approval',
'status.planReview': 'Plan awaiting review',
@@ -171,37 +171,67 @@ function assertNever(value: never): never {
throw new Error(`unknown pending interaction: ${String(value)}`)
}
/** Session status presentation; pending user interaction outranks the running state. */
function sessionStatus(
node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'completed'>,
interface SessionStatus {
state: StateDotState
label: string
}
/** Session status presentation; pending user interaction remains primary. */
function sessionStatuses(
node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'runningSubagentCount' | 'completed'>,
t: RowTranslate,
): { state: StateDotState; label: string } {
): readonly [SessionStatus, ...SessionStatus[]] {
const subagents: SessionStatus | undefined = node.runningSubagentCount === 0
? undefined
: {
state: 'ongoing',
label: t(
node.runningSubagentCount === 1
? 'status.subagentsRunning.one'
: 'status.subagentsRunning.other',
{ n: node.runningSubagentCount },
),
}
let pending: SessionStatus | undefined
switch (node.pendingInteraction) {
case 'approval': return { state: 'warning', label: t('status.waitingApproval') }
case 'plan-review': return { state: 'warning', label: t('status.planReview') }
case 'question': return { state: 'warning', label: t('status.waitingAnswer') }
case 'approval':
pending = { state: 'warning', label: t('status.waitingApproval') }
break
case 'plan-review':
pending = { state: 'warning', label: t('status.planReview') }
break
case 'question':
pending = { state: 'warning', label: t('status.waitingAnswer') }
break
case undefined: break
/* v8 ignore next -- closed PendingInteractionStatus union */
default: return assertNever(node.pendingInteraction)
}
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') }
if (pending !== undefined) return subagents === undefined ? [pending] : [pending, subagents]
if (node.running) {
const primary: SessionStatus = { state: 'ongoing', label: t('status.running') }
return subagents === undefined ? [primary] : [primary, subagents]
}
if (subagents !== undefined) return [subagents]
if (node.completed) return [{ state: 'done', label: t('status.completed') }]
return [{ state: 'done', label: t('status.idle') }]
}
/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */
/** Hover-card body: full title, relative time, and every relevant live status. */
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t)
const statuses = sessionStatuses(node, t)
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
{/* Same placeholder rule as the row's trailing cell: no timestamp
before the first prompt. */}
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
<div className={css.hoverStatus}>
<StateDot state={status.state} />
<span>{status.label}</span>
</div>
{statuses.map(status => (
<div className={css.hoverStatus} key={status.label}>
<StateDot state={status.state} />
<span>{status.label}</span>
</div>
))}
</div>
)
}
@@ -241,7 +271,8 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
t: RowTranslate
}) {
const selected = result.id === currentId
const status = sessionStatus(result, t)
const statuses = sessionStatuses(result, t)
const primaryStatus = statuses[0]
return (
<button
type="button"
@@ -252,10 +283,12 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
>
<span className={css.searchResultHeading}>
<span className={css.slot}>
{(status.state !== 'done' || result.completed) && (
{(primaryStatus.state !== 'done' || result.completed) && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)}
</span>
@@ -277,7 +310,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
/**
* One top-level 34px session row: status dot (pending user interaction outranks
* running), title, relative time, and the row actions menu.
* own or descendant activity), title, relative time, and the row actions menu.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
@@ -307,7 +340,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
const row = node
const title = displayTitle(node, t)
const selected = node.id === currentId
const status = sessionStatus(node, t)
const statuses = sessionStatuses(node, t)
const primaryStatus = statuses[0]
const [menuOpen, setMenuOpen] = useState(false)
// Archive replaces the former Delete placeholder: it hides the row through
// the registry-global archive set and never touches the session log, so it
@@ -356,10 +390,12 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
finished-but-unviewed session shows the green done reminder dot
(cleared by opening the session). */}
<span className={css.slot}>
{(status.state !== 'done' || row.completed) && (
{(primaryStatus.state !== 'done' || row.completed) && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)}
</span>
@@ -3,9 +3,10 @@
* Unassigned Sessions trail under Ungrouped; only the selected blank Session
* remains visible.
*/
import type {
PendingInteractionStatus, SessionId, SessionListState, SessionSearchResultItem, SessionSummary,
WorkspaceId, WorkspaceView,
import {
indexSubagentDescendants, type PendingInteractionStatus, type SessionId, type SessionListState,
type SessionSearchResultItem, type SessionSummary, type SubagentDescendantSummary,
type WorkspaceId, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for Sessions outside every Workspace. */
@@ -24,6 +25,8 @@ export interface SessionNode {
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
runningSubagentCount: number
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
updatedAt: number
@@ -56,6 +59,8 @@ export interface SearchResultNode {
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
runningSubagentCount: number
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
snippet?: string
@@ -173,12 +178,16 @@ function groupByWorkspace(
return groups
}
function sessionNode(s: SessionSummary): SessionNode {
function sessionNode(
s: SessionSummary,
descendants: ReadonlyMap<SessionId, SubagentDescendantSummary>,
): SessionNode {
return {
id: s.id,
title: sessionTitle(s),
blank: s.blank,
running: s.running,
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
completed: s.completed === true,
updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
@@ -207,6 +216,7 @@ export function deriveGroups(
): GroupNode[] {
const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
const descendants = indexSubagentDescendants(list.byId)
const currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
@@ -223,7 +233,7 @@ export function deriveGroups(
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? g.sessions.map(sessionNode) : [],
sessions: expanded ? g.sessions.map(session => sessionNode(session, descendants)) : [],
})
}
return groups
@@ -240,6 +250,7 @@ export function deriveGroups(
*/
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
@@ -247,7 +258,7 @@ export function deriveFlat(list: SessionListState, archivedSessionIds: readonly
rows.push(s)
}
rows.sort(byRecency)
return rows.map(sessionNode)
return rows.map(session => sessionNode(session, descendants))
}
/** Relative-time bucket of a session row's trailing label. */
@@ -282,6 +293,7 @@ export function deriveSearchResults(
const q = query.trim().toLowerCase()
if (q === '') return { items: [], hasMore: false }
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
const workspaceBySession = new Map<SessionId, string>()
for (const workspace of workspaces) {
@@ -332,6 +344,7 @@ export function deriveSearchResults(
title: sessionTitle(summary),
workspace: labelOf(summary),
running: summary.running,
runningSubagentCount: descendants.get(summary.id)?.runningCount ?? 0,
...(summary.pendingInteraction === undefined
? {}
: { pendingInteraction: summary.pendingInteraction }),