Files
apes-sidebar/src/client/tasks-shared.tsx
T
Menghuan1918 84b68e9216 feat(tasks): 任务窗内容填满高度可自然拉伸;聚合卡在成员多时报出 +N
用户两条优化:
  1. 任务详细占满上下部分,不要留空(这样能自然拉伸)
  2. 思考如何优化内容很多时的待命和完成的样式

第 1 条:描述区原本是 `max-height: 160px` 的滚动盒,窗口拉大只长空白。
FloatingWindow 新增 `bodyLayout: 'scroll' | 'fill'`(默认 scroll 不变,后台
任务输出窗仍按原样流动),fill 把 body 变成列容器、让调用方的子节点自己决定
谁吃余量;任务窗据此把余量全给描述区(编辑态给多行输入框,`flex: 1` 跟着窗
口长),头部行 / 元信息 / 负责人 / 提示行都 `flex: none`,不会跟着漂。

第 2 条:一队队友很多时,聚合卡只有一行名字却塞了十几个人,卡上看不出还有
多少。名称行现在写「前两个名字 + N」—— 用的是团队任务行已有的同一个 `+N`
约定,不是第二套;分组总数另有徽标和小条两处,所以 `+N` 只是尾巴而不是唯一
计数。图卡片与树行共用同一个 foldPreviews。

测试:新增 2 例(窗口声明 fill 布局、描述区在 body 内且编辑态换成多行框;
五个待命成员的聚合卡名称是 `m1 / m2 +3`);floating-window 既有 9 例回归通过。
pnpm test 1288 passed / 9 skipped,typecheck / lint 通过。

(cherry picked from commit 62651e731d6e5fd0378e2ecc504792cb2e3eb461)
2026-09-27 22:19:05 +08:00

319 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared presentation bits of the Tasks page's two modes (graph + tree):
* the state dot, the agent/workflow/task GLYPHS (host primitive icons — the
* page draws no glyphs of its own), the short mono meta line, the node task
* line, and the iconified live activity row ("tool icon + tool + args").
*
* Card content contract (the page's answer to "everything is ellipsized"):
* line 1 state dot + agent icon + name (11px semibold, one line)
* line 2 mode/model · activity (9px mono, one line)
* line 3 live tool line (running nodes only)
* line 4 owned shared task (team members only)
* Everything else lives in the popovers.
*/
import type { ReactNode } from 'react'
import {
IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconUserOutlineRegular,
type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarChildLiveView } from '../context-types.ts'
import { liveActivityLabel } from './process-labels.ts'
import type {
TasksAgentNode, TasksFoldNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode,
} from './tasks-model.ts'
import { toolGlyph } from './tool-icons.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 48
/** Preview cap of one text line. */
const TEXT_PREVIEW = 72
/** The host StateDot semantic of a node display state. */
export function nodeDotState(state: TasksNodeState): StateDotState {
switch (state) {
case 'running': return 'ongoing'
case 'idle': return 'idle'
case 'done': return 'done'
case 'error': return 'error'
}
}
/** The short state word of a node (the meta line's second token). */
export function stateLabel(state: TasksNodeState): string {
const key: CopyKey = state === 'running'
? 'tasksStateRunning'
: state === 'idle'
? 'subagentInactive'
: state === 'error' ? 'tasksStateError' : 'tasksStateDone'
return t(key)
}
/**
* The two facts every task display rule reads. Deliberately NARROWER than
* either carrier (`TasksNodeTask` on the graph, `SidebarTeamTaskView` in the
* board and the task window) so one rule serves all of them — the window sees
* a `deleted` status the graph never draws, and only the shared rule needs to
* know that a deleted task is not "blocked".
*/
export interface TaskStatusFacts {
status: 'pending' | 'in_progress' | 'completed' | 'deleted'
ready: boolean
}
/** The task status label key. */
export function taskStatusKey(status: TaskStatusFacts['status']): CopyKey {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
case 'deleted': return 'teamTaskDeleted'
}
}
/**
* Whether a shared task is HELD UP by its blockers.
*
* The service's `ready` flag means exactly one thing: "a pending task whose
* every blocker completed, so it can be CLAIMED". It is therefore false for
* every task that is not queued — which makes `!ready` the wrong test for
* "blocked": a claimed task (in_progress) and a finished one both report
* `ready: false`, and reading the flag alone labelled a task that is actively
* being worked on as 阻塞 (reproduced on the real board: claiming a task made
* its Tag flip from 待办 to 阻塞).
*/
export function taskBlocked(task: TaskStatusFacts): boolean {
return task.status === 'pending' && !task.ready
}
/** The status word of a task row: its own status, or 阻塞 when held up. */
export function taskStatusLabel(task: TaskStatusFacts): string {
return t(taskBlocked(task) ? 'teamTaskBlocked' : taskStatusKey(task.status))
}
/** The tone of a task's status Tag (or dot) — the same three-way rule. */
export function taskTone(task: TaskStatusFacts): 'success' | 'info' | 'warning' {
if (task.status === 'completed') return 'success'
return taskBlocked(task) ? 'warning' : 'info'
}
/** The host StateDot semantic of a task row/line. */
export function taskDotState(task: TaskStatusFacts): StateDotState {
if (task.status === 'completed') return 'done'
return taskBlocked(task) ? 'warning' : 'ongoing'
}
/** First `limit` characters with an ellipsis when truncated. */
export function preview(text: string, limit: number = ARGS_PREVIEW): string {
return text.length > limit ? `${text.slice(0, limit)}…` : text
}
/** Collapse whitespace for single-line previews. */
export function flatten(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
/** The host icon of one agent node (the lead and plain children share one). */
export function AgentGlyph(props: { node: TasksAgentNode; size?: number }): ReactNode {
const size = props.size ?? 11
if (props.node.team?.role === 'teammate') return <IconUserOutlineRegular size={size} />
return <IconAgentPresetOutlineRegular size={size} />
}
/** The workflow-run glyph. */
export function WorkflowGlyph(props: { size?: number }): ReactNode {
return <IconBranchOutlineRegular size={props.size ?? 11} />
}
/** The fold aggregate glyph. */
export function FoldGlyph(props: { size?: number }): ReactNode {
return <IconChecklistOutlineRegular size={props.size ?? 11} />
}
/**
* The mode word of one catalog row, or undefined when it names no mode. DSH
* 0.1.7 added `unknown` (a child the host's catalog fold kept without a
* readable descriptor): it claims NEITHER mode, so the segment is omitted
* rather than mislabelled.
*/
export function modeLabel(mode: TasksAgentNode['mode']): string | undefined {
switch (mode) {
case 'one-shot': return t('subagentModeOneShot')
case 'continuable': return t('subagentModeContinuable')
case 'unknown':
case undefined: return undefined
}
}
/**
* The mono meta line of an agent card — at most two short tokens:
* - the topology root: 主代理 · 状态
* - any other agent: 模式 · 状态 (a teammate without a catalog mode falls back
* to its team role, the one durable identity the roster still carries — the
* `agentTeam` projection has no model field to print, see team-projection.ts)
*/
export function agentMeta(node: TasksAgentNode): string {
const state = stateLabel(node.state)
if (node.parentId === undefined) return `${t('subagentMainAgent')} · ${state}`
const mode = modeLabel(node.mode)
if (mode !== undefined) return `${mode} · ${state}`
const fallback = node.team === undefined ? undefined : teamRoleLabel(node.team)
return fallback === undefined ? state : `${fallback} · ${state}`
}
/**
* The CARD's mono meta line: WHO the node is, never its state.
*
* The state belongs to the card's bottom bar (the state word sits next to the
* state dot there), so repeating it here would print "已完成" twice inside one
* 190px card — the densest complaint the readability pass had to fix. The
* accessible name keeps {@link agentMeta} (state included), because a screen
* reader never sees the bar's dot.
*/
export function agentIdentity(node: TasksAgentNode): string {
// The root's badge already reads 主代理: an identity line would repeat it.
if (node.parentId === undefined) return ''
const mode = modeLabel(node.mode)
if (mode !== undefined) return mode
return node.team === undefined ? '' : teamRoleLabel(node.team) ?? ''
}
/** The roster row's own identity word (the fallback for a modelless teammate). */
function teamRoleLabel(team: NonNullable<TasksAgentNode['team']>): string | undefined {
if (team.role === 'lead') return undefined
if (team.phase === 'provisioning') return t('tasksStateProvisioning')
if (team.phase === 'failed') return t('tasksStateError')
return t('tasksKindTeammate')
}
/** The mono meta line of a workflow run card: status · member tally. */
export function workflowMeta(node: TasksWorkflowNode): string {
const { run } = node
let done = 0
let total = 0
for (const phase of run.phases) {
for (const member of phase.members) {
total += 1
if (member.outcome !== undefined) done += 1
}
}
return `${t(workflowStatusKey(run.status))} · ${t('workflowMembers', { done, total })}`
}
/** The workflow run status label key. */
export function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey {
switch (status) {
case 'running': return 'workflowRunning'
case 'completed': return 'workflowCompleted'
case 'cancelled': return 'workflowCancelled'
case 'failed': return 'workflowFailed'
}
}
/** The task a node surfaces first: in-progress, else pending, else the last. */
export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | undefined {
return tasks.find(task => task.status === 'in_progress')
?? tasks.find(task => task.status === 'pending')
?? tasks[tasks.length - 1]
}
/**
* The node's shared-task line: an icon, the primary task's subject, its
* status word, and a `+N` tail when the agent owns more. Renders nothing
* without tasks, so non-team nodes keep the three-line shape.
*/
export function TaskLine(props: {
tasks: readonly TasksNodeTask[] | undefined
onOpenTask(taskId: string, anchor: HTMLElement): void
}): ReactNode {
const tasks = props.tasks ?? []
const primary = primaryTask(tasks)
if (primary === undefined) return null
return (
<span
role="button"
tabIndex={0}
className={css.taskLine}
title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
onClick={(event) => {
event.stopPropagation()
props.onOpenTask(primary.id, event.currentTarget)
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
event.stopPropagation()
props.onOpenTask(primary.id, event.currentTarget)
}}
>
<span className={css.taskGlyph} aria-hidden="true"><IconChecklistOutlineRegular size={9} /></span>
<span className={css.taskSubject}>{primary.subject}</span>
<span className={css.taskState}>{t(taskStatusKey(primary.status))}</span>
{tasks.length > 1 && <span className={css.taskMore}>{`+${tasks.length - 1}`}</span>}
</span>
)
}
/**
* The live activity row of a RUNNING agent: the merged process summary the
* main conversation would show for the child's newest range (category wording
* + the running call's detail), plus the flattened last text line underneath.
* A running node with neither reads as thinking.
*/
export function LiveLine(props: { live: SidebarChildLiveView | undefined }): ReactNode {
const { live } = props
const activity = liveActivityLabel(live?.summary)
if (activity === undefined && live?.text === undefined) {
return <span className={css.nodeMeta}>{t('subagentThinking')}</span>
}
return (
<>
{activity !== undefined && (
<span className={css.live}>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph('bash')(9)}</span>
<span className={css.liveTool} title={activity}>{activity}</span>
</span>
)}
{live?.text !== undefined && (
<span className={css.liveText}>{preview(flatten(live.text), TEXT_PREVIEW)}</span>
)}
</>
)
}
/**
* The fold aggregate's name line: two label previews, plus `+N` when the group
* holds more.
*
* A roster aggregate can swallow dozens of members, and the card has one line
* for them. Naming two and saying how many are left over is what keeps that
* line honest at any size — the exact `+N` the team task line already uses,
* not a second convention. The group's total is also on the badge and the bar,
* so `+N` is a tail, never the only count.
*
* @param previews - member labels, in page order (see {@link TasksFoldNode.previews}).
* @param count - how many members the group holds in total.
*/
export function foldPreviews(previews: readonly string[], count: number): string {
const tail = count > previews.length ? ` +${count - previews.length}` : ''
return `${previews.join(' / ')}${tail}`
}
/**
* The fold aggregate's bar text.
*
* The two groups read differently on purpose. The `done` row counts its
* finished members and calls out failures beside them (`✓ 4 已完成 · 出错 1`
* — a row of nothing but failures is `出错 N`), because "✓ N 已完成" alone
* would quietly include them. The `idle` row says what the members are doing
* now: nothing (`N 待命`), which is why they were swept away.
*/
export function foldTally(node: TasksFoldNode): string {
if (node.foldKind === 'idle') return t('tasksFoldIdle', { count: node.count })
const parts: string[] = []
if (node.doneCount > 0) parts.push(t('tasksFoldCompleted', { count: node.doneCount }))
if (node.errorCount > 0) parts.push(`${t('tasksStateError')} ${node.errorCount}`)
return parts.join(' · ')
}