diff --git a/docs/plans/2026-09-27-tasks-graph-polish.md b/docs/plans/2026-09-27-tasks-graph-polish.md index d747f4a..e8c5217 100644 --- a/docs/plans/2026-09-27-tasks-graph-polish.md +++ b/docs/plans/2026-09-27-tasks-graph-polish.md @@ -122,6 +122,40 @@ client 半(src/client/) 当前会话改用加粗 accent 描边;**圆角 4px → 8px**。任务输出的锚定浮窗也换成了常驻浮动窗。 以新文档为准。 +## 后续修正(2026-09-27,用户反馈) + +用户反馈两条,都在真机上定位并修掉: + +### 1. 扫光只有「部分」→ 改成整条完整扫过 + +第一版用 `background-size: 45%` 的窄带 + `background-position: 0% → 100%`:亮带确实在移动, +但它始终只覆盖小条的一部分(峰值从 22.5% 走到 77.5%,两端永远扫不到),看起来像中间游走的 +一小块光斑。现改为**与小条等宽的渐变带 + `transform: translateX(-100%) → translateX(100%)`**: +光带从左边外侧进入、中途覆盖整条、再从右边外侧离开,行程就是用条宽本身表达的(任意条宽都成立), +末 1/3 周期停在右侧外侧(两轮之间留白)。`tests/theme.spec.ts` 新增一条守护钉住这个形状。 + +真机证据(242px 宽的小条,每 90ms 采样一次 `::after` 的 `matrix` 平移量): +`[-23, 32, 85, 131, 163, -139, -94, -46, 9, 64, 114, 152, 179, 188]` —— 光带在小条左侧 +(-139)到右侧(+188)之间被连续观察到,单调推进、跨度超过一个条宽;并截到一张行程中的帧。 + +### 2. 折叠按钮与展开按钮长得一样 → 两者分向 + +三处控件原来共用向下箭头(`IconChevronDownOutlineRegular`):卡片的逐节点「收进已完成聚合」按钮、 +聚合卡片的「展开」、以及控制条的折叠开关(后者更糟:无论将要折叠还是展开都画同一个清单图标)。 +现在按**动作方向**区分: + +| 控件 | 折叠方向(chevron up) | 展开方向(chevron down) | +|---|---|---| +| 卡片小条的逐节点按钮 | ✅ `收进已完成聚合` | — | +| 聚合卡片的小条 | — | ✅ `展开已完成的节点` | +| 控制条折叠开关 | ✅(当前已展开时) | ✅(当前已折叠时) | + +真机证据(比对三处 `` 内容):`expandVsCollapse: true`(两个方向确实不同)、 +`clusterMatchesExpand: true`(控制条在折叠态画的就是聚合卡的展开箭头)、 +`clusterFlips: true`(状态切换时图标跟着换)、`clusterCollapseMatchesCard: true` +(控制条的折叠箭头与卡片逐节点按钮一致)。组件级守护见 `tests/tasks-page.spec.tsx` +的「draws COLLAPSE and EXPAND with different glyphs」。 + ## 实施偏差记录(写给复审) 1. **`process-activity.ts` 是裁剪版而不是原样搬运**:本轮「阶段」= 徽标 + 颜色,逐阶段列表没有消费者,留着就是死代码。裁剪后 44 例 → 30 例(阶段相关用例随机制一起删除)。 diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx index aeaba7e..43a9357 100644 --- a/src/client/TasksGraph.tsx +++ b/src/client/TasksGraph.tsx @@ -30,7 +30,8 @@ import { } from 'react' import clsx from 'clsx' import { - Button, IconChecklistOutlineRegular, IconChevronDownOutlineRegular, IconFullscreenOutlineRegular, + Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, + IconFullscreenOutlineRegular, IconLoadingOutlineRegular, IconTreeCornerRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts' @@ -164,6 +165,14 @@ export function ViewModeToggle(props: { } /** The fold toggle (expand / re-collapse the settled aggregates). */ +/** + * The cluster's fold switch. Its glyph names the action it will take, not the + * state it is in: while the settled nodes are folded the button offers to + * EXPAND (chevron down), and while they are open it offers to FOLD (chevron + * up, the same collapse direction the cards' own bar buttons wear). The old + * single checklist glyph read identically in both states, which is what the + * reader reported as "折叠按钮和展开一样". + */ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode { const { folded, onToggleFold } = props return ( @@ -171,7 +180,9 @@ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void variant="ghost" size="sm" className={clsx(css.controlBtn, folded && css.controlBtnActive)} - icon={} + icon={folded + ? + : } aria-pressed={folded} aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')} title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')} diff --git a/src/client/tasks-card.tsx b/src/client/tasks-card.tsx index 5d88921..6ced5ca 100644 --- a/src/client/tasks-card.tsx +++ b/src/client/tasks-card.tsx @@ -19,7 +19,7 @@ */ import type { ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronUpOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { TasksNodeState } from './tasks-model.ts' import { nodeDotState, stateLabel } from './tasks-shared.tsx' import { t } from './locales.ts' @@ -150,7 +150,13 @@ export function CardBar(props: { props.onFold?.(event) }} > - + {/* + The COLLAPSE direction (chevron up = gather into the group): the + aggregate card at the other end of the same motion offers the + EXPAND one (chevron down). Two opposite actions must not share + a glyph — the first cut drew both as a down chevron. + */} + )} diff --git a/src/client/tasks-graph.module.css b/src/client/tasks-graph.module.css index 6b69300..0ba8c3e 100644 --- a/src/client/tasks-graph.module.css +++ b/src/client/tasks-graph.module.css @@ -317,30 +317,38 @@ overflow: hidden; } -/* Executing: the bar's background SWEEPS left → right (the same motion the - main conversation's shimmer uses, applied to a strip instead of text). The - band is 45% of the bar and travels the full width; the last third of the - cycle rests, so a fast glance still catches it at rest. */ +/* Executing: a light band SWEEPS the bar left → right (the same motion the + main conversation's shimmer uses, applied to a strip instead of text). + The band is as WIDE as the bar and travels a full width either side of it: + it enters at the left edge, covers the whole bar mid-flight, and leaves past + the right edge — a narrower band with `background-position` (the first cut) + only ever lit a 45% slice, which read as a blob wandering in the middle + instead of a sweep. `transform` is used rather than background-position + because the travel is then literal (`translateX(-100%) → translateX(100%)` + of the bar's own width), so it stays correct at every bar width. The last + third of the cycle holds the band off the right edge: a clean pause between + sweeps, and a fast glance still catches the bar at rest. */ .cardBar[data-running='true']::after { content: ''; position: absolute; - inset: 0; + top: 0; + bottom: 0; + left: 0; + width: 100%; background-image: linear-gradient( 90deg, transparent 0%, - color-mix(in srgb, var(--dsw-alias-state-business-primary) 14%, transparent) 50%, + color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent) 50%, transparent 100% ); - background-size: 45% 100%; - background-repeat: no-repeat; - background-position: 0% center; + transform: translateX(-100%); animation: dsh-tasks-bar-sweep 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite; pointer-events: none; } @keyframes dsh-tasks-bar-sweep { - 0% { background-position: 0% center; } - 66.6667%, 100% { background-position: 100% center; } + 0% { transform: translateX(-100%); } + 66.6667%, 100% { transform: translateX(100%); } } /* Motion is decoration here: the state word and the activity text carry the diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx index fc847b4..b6cc925 100644 --- a/tests/tasks-page.spec.tsx +++ b/tests/tasks-page.spec.tsx @@ -718,6 +718,35 @@ describe('Tasks page: the shared task window', () => { unmount() }) + it('draws COLLAPSE and EXPAND with different glyphs (cluster, card, aggregate)', async () => { + const store = makeStore(snapshotWithChildren(2)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + await flushJobs() + /** The icon markup of one control (the glyph is what the reader compares). */ + const glyph = (selector: string): string => + container.querySelector(selector)?.querySelector('svg')?.innerHTML ?? '' + + // Folded by default: the cluster's switch offers EXPAND, and it wears the + // same expand glyph the aggregate card's bar does. + const expandGlyph = glyph('button[aria-label="展开已完成的节点"]') + expect(expandGlyph).not.toBe('') + expect(glyph('[data-graph-node^="fold:"] [data-card-bar]')).toBe(expandGlyph) + + // Unfold: the cards' own control COLLAPSES one node, and it must not share + // the expand glyph (the reader's report was that both looked identical). + await act(async () => { + (container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement).click() + }) + const collapseGlyph = glyph('[data-graph-node] button[aria-label="收进已完成聚合"]') + expect(collapseGlyph).not.toBe('') + expect(collapseGlyph).not.toBe(expandGlyph) + // The cluster switch flips to the collapse direction with the same glyph. + expect(glyph('button[aria-label="折叠已完成的节点"]')).toBe(collapseGlyph) + unmount() + }) + it('offers no fold chevron on a running card or on the current session', async () => { livePayload = { 'child-0': { running: true, summary: { counts: [], runningDetail: '' } } } const store = makeStore(snapshotWithChildren(2)) diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts index 683f83d..b2ac98d 100644 --- a/tests/theme.spec.ts +++ b/tests/theme.spec.ts @@ -161,6 +161,26 @@ describe('skin contract: the plugin owns no color of its own', () => { } }) + it('the running bar sweeps the FULL width (a band either side, not a partial blob)', () => { + // Reader report: the first cut moved a 45%-wide band with + // `background-position`, so the bar was only ever partially lit. The + // corrected rule is a bar-wide band whose TRANSFORM travels one full width + // either side of the bar — entered at the left edge, covering the bar + // mid-flight, gone past the right edge. + const styles = readFileSync(resolve(ROOT, 'src/client/tasks-graph.module.css'), 'utf8') + const block = /\.cardBar\[data-running='true'\]::after \{([\s\S]*?)\n\}/.exec(styles)?.[1] + if (block === undefined) throw new Error('the running bar sweep rule must exist') + expect(block).toContain('width: 100%') + expect(block).toContain('transform: translateX(-100%)') + // No residual background-position travel (the partial-sweep mechanism). + expect(block).not.toContain('background-position') + expect(block).not.toContain('background-size') + const frames = /@keyframes dsh-tasks-bar-sweep \{([\s\S]*?)\n\}/.exec(styles)?.[1] + if (frames === undefined) throw new Error('the sweep keyframes must exist') + expect(frames).toContain('transform: translateX(-100%)') + expect(frames).toContain('transform: translateX(100%)') + }) + it('the empty-pane welcome capsule follows the host guide recipe', () => { // The card is the same surface as DSH's own guide capsule ("pick what // opens here"), so this pins the shared geometry and the token-only