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:
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user