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)
This commit is contained in:
Menghuan1918
2026-09-27 22:19:05 +08:00
parent 11c2a16986
commit 84b68e9216
9 changed files with 137 additions and 12 deletions
+14
View File
@@ -77,6 +77,20 @@
padding: 10px 12px 12px;
}
/* A body whose content should FILL the window instead of stacking from the top
(a form with one editable region, a detail view with prose): the caller's
children become a column and decide for themselves what takes the slack, so
dragging the window bigger grows the content rather than the empty space. */
.bodyFill {
display: flex;
flex-direction: column;
}
.bodyFill > * {
flex: 1;
min-height: 0;
}
.footer {
display: flex;
align-items: center;
+17 -2
View File
@@ -83,6 +83,14 @@ export interface FloatingWindowProps {
* caller scrolls it; the window owns its size and its overflow.
*/
bodyRef?: RefObject<HTMLDivElement>
/**
* How the body stacks its children. `scroll` (default) flows them from the
* top inside the scrolling box — right for a log or a table. `fill` makes
* the body a column whose children stretch to the window's height — right
* for a detail view or a form, where the point of enlarging the window is
* more room for the content, not more empty space under it.
*/
bodyLayout?: 'scroll' | 'fill'
children: ReactNode
}
@@ -143,7 +151,9 @@ function initialBox(
* two drag gestures and the dismissal contract (close button + Escape only).
*/
export function FloatingWindow(props: FloatingWindowProps): ReactNode {
const { title, onClose, initialSize, minSize, anchor, actions, footer, bodyRef, children } = props
const {
title, onClose, initialSize, minSize, anchor, actions, footer, bodyRef, bodyLayout, children,
} = props
const min = useMemo(() => ({
width: minSize?.width ?? DEFAULT_MIN_WIDTH,
height: minSize?.height ?? DEFAULT_MIN_HEIGHT,
@@ -304,7 +314,12 @@ export function FloatingWindow(props: FloatingWindowProps): ReactNode {
/>
</span>
</div>
<div ref={bodyRef} className={css.body} data-window-body>
<div
ref={bodyRef}
className={bodyLayout === 'fill' ? `${css.body} ${css.bodyFill}` : css.body}
data-window-body
data-window-body-layout={bodyLayout ?? 'scroll'}
>
{children}
</div>
{footer !== undefined && <div className={css.footer} data-window-footer>{footer}</div>}
+6 -1
View File
@@ -121,7 +121,7 @@ function TaskViewBody(props: {
</>
)}
</div>
<div className={css.taskMarkdown}>
<div className={css.taskMarkdown} data-task-description>
{body === ''
? <div className={css.popHint}>{t('teamTaskNoDescription')}</div>
: (
@@ -316,6 +316,9 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
initialSize={{ width: 460, height: 360 }}
minSize={{ width: 320, height: 240 }}
anchor={props.anchor}
// The description (or the editor) owns the window's slack: enlarging it
// gives the CONTENT more room instead of leaving a gap under it.
bodyLayout="fill"
footer={(
// The action row is the shell's FOOTER, so it never scrolls away from
// the body it acts on. One state owner: `busy` and `armedDelete` are
@@ -436,6 +439,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
</div>
)}
>
<div className={css.taskBody}>
<div className={css.taskHeadRow}>
{task !== undefined && <StateDot size={6} state={taskDotState(task)} />}
<span className={css.popTitle} title={task?.subject ?? subject}>
@@ -466,6 +470,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
)}
{note !== undefined && <div className={css.teamNote}>{note}</div>}
</div>
</FloatingWindow>
)
}
+1 -1
View File
@@ -678,7 +678,7 @@ function renderFoldNode(
count={node.count}
foldKind={node.foldKind}
depth={node.depth}
name={foldPreviews(node.previews)}
name={foldPreviews(node.previews, node.count)}
extraClass={css.cardNamePlain}
/>
<CardBar state={node.foldKind === 'idle' ? 'idle' : 'done'} stateWord={false}>
+2 -2
View File
@@ -117,8 +117,8 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
{t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
</span>
</span>
<span className={css.treeMeta} title={foldPreviews(node.previews)}>
{foldPreviews(node.previews)}
<span className={css.treeMeta} title={foldPreviews(node.previews, node.count)}>
{foldPreviews(node.previews, node.count)}
</span>
</span>
</div>
+34 -2
View File
@@ -809,6 +809,16 @@
min-width: 0;
}
/* The task window's body: the window hands it the slack (FloatingWindow's
`fill` layout), and inside it the description / editor is the region that
grows. Everything else keeps its natural height so the action row and the
meta line never drift. */
.taskBody {
display: flex;
flex-direction: column;
min-height: 0;
}
/* The task window's own head line (inside the floating window's scrolling
body): state dot + subject + status Tag. Sentence case, unlike the node
detail's letter-spaced uppercase `.popHead` band — this one labels a real
@@ -1035,8 +1045,12 @@
* task / output wells beside it, and the body is the card's VALUE rung
* (secondary) — a description is content, not a title, so the title keeps the
* card's only primary ink. */
/* The description: it FILLS the window (no 160px cap — that was the box that
left the rest of a resized window empty) and scrolls inside itself only when
the prose really is longer than the room. */
.taskMarkdown {
max-height: 160px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 2px 0;
font: var(--dsw-font-xxs-12);
@@ -1130,6 +1144,7 @@
.taskTextarea {
width: 100%;
flex: 1;
min-height: 120px;
padding: 6px 8px;
border: 1px solid var(--dsw-alias-border-l2);
@@ -1152,11 +1167,19 @@
/* The form's two field groups keep the card's OWN beat: 8px between groups
against the 4px between a label and its control, so view and edit stack on
the same rhythm (the view's owner field carries the same 8px above it). */
the same rhythm (the view's owner field carries the same 8px above it).
The description group is the one that grows with the window. */
.taskForm {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
}
.taskForm > label:last-child {
flex: 1;
min-height: 0;
}
.taskField {
@@ -1171,6 +1194,15 @@
margin-top: 4px;
}
/* The owner field is a fixed band under the description: it must not absorb
the window's slack (the prose is what grows). */
.taskBody > .taskField,
.taskBody > .jobPopMeta,
.taskBody > .taskHeadRow,
.taskBody > .teamNote {
flex: none;
}
.taskLabel {
font-family: var(--ds-font-family-code, monospace);
font-size: 10px;
+16 -3
View File
@@ -282,9 +282,22 @@ export function LiveLine(props: { live: SidebarChildLiveView | undefined }): Rea
)
}
/** The fold aggregate's subtitle: up to two label previews joined by `/`. */
export function foldPreviews(previews: readonly string[]): string {
return previews.join(' / ')
/**
* 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}`
}
/**
+18 -1
View File
@@ -16,7 +16,9 @@ import type {
SidebarSubagentCatalogEntry,
} from '../src/context-types.ts'
import type { TeamMemberRow } from '../src/client/team-projection.ts'
import { taskBlocked, taskDotState, taskStatusLabel, taskTone } from '../src/client/tasks-shared.tsx'
import {
foldPreviews, taskBlocked, taskDotState, taskStatusLabel, taskTone,
} from '../src/client/tasks-shared.tsx'
import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
import type { SidebarChildLiveView } from '../src/context-types.ts'
import type { WorkflowRunView } from '../src/workflow-runs.ts'
@@ -247,6 +249,21 @@ describe('buildTasksModel', () => {
})
})
it('names two members and counts the rest on the aggregate card', () => {
const mates = ['m1', 'm2', 'm3', 'm4', 'm5']
const model = buildTasksModel(input({
catalogs: {
root: catalog(mates.map(id => child(id))),
...Object.fromEntries(mates.map(id => [id, catalog([])])),
},
byId: Object.fromEntries([['root', summary('root')], ...mates.map(id => [id, summary(id)])]),
teamMembers: mates.map(id => member({ id, name: id, status: 'idle' })),
}))
const fold = model.find(node => node.kind === 'fold')
expect(fold?.previews).toEqual(['m1', 'm2'])
expect(foldPreviews(fold?.previews ?? [], fold?.count ?? 0)).toBe('m1 / m2 +3')
})
it('keeps two idle team members visible (below the threshold)', () => {
const mates = ['m1', 'm2']
const model = buildTasksModel(input({
+29
View File
@@ -723,6 +723,35 @@ describe('Tasks page: the shared task window', () => {
await act(async () => { unmount() })
})
it('hands the window slack to the description instead of leaving it empty', async () => {
withTeam({ tasks: [teamTask({ subject: '长描述', description: '第一行\n\n第二行' })] })
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
await act(async () => { row.click() })
const body = document.querySelector('[data-window-body]') as HTMLElement
// The body declares the FILL layout, so its child column (and the prose in
// it) grows with the window instead of stacking under a 160px box.
expect(body.getAttribute('data-window-body-layout')).toBe('fill')
const described = body.querySelector('[data-task-description]') as HTMLElement
expect(described).not.toBeNull()
expect(described.textContent).toContain('第一行')
expect(body.querySelector('textarea')).toBeNull()
// Editing swaps in the multi-line field INSIDE the same growing body (the
// action that switches modes lives in the shell's FOOTER, not the body).
const frame = document.querySelector('[data-floating-window]') as HTMLElement
const edit = [...frame.querySelectorAll('button')].find(button => button.textContent?.includes('编辑'))
await act(async () => { edit?.click() })
const area = body.querySelector('textarea') as HTMLTextAreaElement
expect(area).not.toBeNull()
expect(area.value).toBe('第一行\n\n第二行')
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
await act(async () => { unmount() })
})
it('closes the task window on Escape, never on an outside click', async () => {
withTeam({ tasks: [teamTask()] })
const store = makeStore(snapshotWithChildren(1))