From 84b68e9216818f6d15c5081281c62f8692a974d6 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 27 Sep 2026 21:46:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E4=BB=BB=E5=8A=A1=E7=AA=97?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E5=A1=AB=E6=BB=A1=E9=AB=98=E5=BA=A6=E5=8F=AF?= =?UTF-8?q?=E8=87=AA=E7=84=B6=E6=8B=89=E4=BC=B8=EF=BC=9B=E8=81=9A=E5=90=88?= =?UTF-8?q?=E5=8D=A1=E5=9C=A8=E6=88=90=E5=91=98=E5=A4=9A=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E5=87=BA=20+N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户两条优化: 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) --- src/client/FloatingWindow.module.css | 14 +++++++++++ src/client/FloatingWindow.tsx | 19 +++++++++++++-- src/client/TaskWindow.tsx | 7 +++++- src/client/TasksGraph.tsx | 2 +- src/client/TasksTree.tsx | 4 ++-- src/client/tasks-graph.module.css | 36 ++++++++++++++++++++++++++-- src/client/tasks-shared.tsx | 19 ++++++++++++--- tests/tasks-model.spec.ts | 19 ++++++++++++++- tests/tasks-page.spec.tsx | 29 ++++++++++++++++++++++ 9 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/client/FloatingWindow.module.css b/src/client/FloatingWindow.module.css index bbb7757..5800860 100644 --- a/src/client/FloatingWindow.module.css +++ b/src/client/FloatingWindow.module.css @@ -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; diff --git a/src/client/FloatingWindow.tsx b/src/client/FloatingWindow.tsx index 0e51899..c11ecae 100644 --- a/src/client/FloatingWindow.tsx +++ b/src/client/FloatingWindow.tsx @@ -83,6 +83,14 @@ export interface FloatingWindowProps { * caller scrolls it; the window owns its size and its overflow. */ bodyRef?: RefObject + /** + * 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 { /> -
+
{children}
{footer !== undefined &&
{footer}
} diff --git a/src/client/TaskWindow.tsx b/src/client/TaskWindow.tsx index f53b2aa..8a06c70 100644 --- a/src/client/TaskWindow.tsx +++ b/src/client/TaskWindow.tsx @@ -121,7 +121,7 @@ function TaskViewBody(props: { )}
-
+
{body === '' ?
{t('teamTaskNoDescription')}
: ( @@ -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 {
)} > +
{task !== undefined && } @@ -466,6 +470,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode { )} {note !== undefined &&
{note}
} +
) } diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx index 964d823..29df249 100644 --- a/src/client/TasksGraph.tsx +++ b/src/client/TasksGraph.tsx @@ -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} /> diff --git a/src/client/TasksTree.tsx b/src/client/TasksTree.tsx index b074b62..a877888 100644 --- a/src/client/TasksTree.tsx +++ b/src/client/TasksTree.tsx @@ -117,8 +117,8 @@ export function TasksTree(props: TasksTreeProps): ReactNode { {t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')} - - {foldPreviews(node.previews)} + + {foldPreviews(node.previews, node.count)}
diff --git a/src/client/tasks-graph.module.css b/src/client/tasks-graph.module.css index fe6ce2d..0ce41e2 100644 --- a/src/client/tasks-graph.module.css +++ b/src/client/tasks-graph.module.css @@ -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; diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx index 078a567..6b52cf9 100644 --- a/src/client/tasks-shared.tsx +++ b/src/client/tasks-shared.tsx @@ -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}` } /** diff --git a/tests/tasks-model.spec.ts b/tests/tasks-model.spec.ts index 9b0befc..e66f35f 100644 --- a/tests/tasks-model.spec.ts +++ b/tests/tasks-model.spec.ts @@ -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({ diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx index 10c581d..090427f 100644 --- a/tests/tasks-page.spec.tsx +++ b/tests/tasks-page.spec.tsx @@ -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))