fix: fit the story graph to the window and scroll each column on its own

This commit is contained in:
mesalogo
2026-10-02 22:22:03 +08:00
parent 0fd2f7e3c8
commit 9d8477b728
5 changed files with 109 additions and 1 deletions
@@ -103,6 +103,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 420px。三栏时整页不再出现纵向滚动;左侧列表、中间画布、右侧详情各自在这一行内独立滚动,滚动只影响自身,不带动页面。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。
实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例常显,读图说明可展开。
画布每批最多显示 8 个事件、6 个实体;同时间事件分开排布。列表保留全部返回记录和完整名称,选择实体会定位到真实关联事件所在批次,选择关系会优先显示两端实体。滑块明确命名“事件浏览”,只切换当前事件,不还原实体历史状态,也不宣称完整历史回放。当前接口返回单条故事线,不复制 Demo 中的三条模拟故事。后续视图规划见[图谱设计](../story-graph/prd.md)。
+4 -1
View File
@@ -6,6 +6,7 @@ import type { SupervisionReviewExecution } from '../../shared/supervision-review
import { EmptyState, PageTabs } from './WorkspacePrimitives'
import { SupervisionDiscussion } from './SupervisionDiscussion'
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
import { useFillHeight } from './use-fill-height'
import { SupervisionEventStory, SupervisionExperienceDetail, SupervisionExperienceList, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
@@ -478,6 +479,8 @@ export function SupervisorWorkspace({
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
: undefined
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
const graphLayoutRef = useRef<HTMLDivElement>(null)
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0)
// The work review reads the selected result's own scope, so it matches the period shown.
const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id)
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
@@ -718,7 +721,7 @@ export function SupervisorWorkspace({
/>
)}
{graph.events.length > 0 && (
<div className="supervisor-workspace__graph-layout">
<div className="supervisor-workspace__graph-layout" ref={graphLayoutRef}>
<aside
className="supervisor-workspace__graph-list"
aria-label={t('supervisor.selection')}
+35
View File
@@ -388,6 +388,41 @@
.supervisor-workspace__graph-canvas {
border-inline: 1px solid var(--border-default);
}
/*
* Wide layouts: the graph row is as tall as the window allows (`--fill-height`, set by useFillHeight with a
* steady bottom margin), and each column in it scrolls on its own, so long lists never grow the page.
* With three columns that is the whole layout; with two, the detail wraps below at its natural height.
* Single-column layouts keep page scrolling.
*/
@container supervisor (min-width: 861px) {
.supervisor-workspace__graph-layout {
grid-template-rows: var(--fill-height, auto);
grid-auto-rows: auto;
}
.supervisor-workspace__graph-canvas,
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
min-height: 0;
max-height: var(--fill-height, none);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-gutter: stable;
}
.supervisor-workspace__graph-list {
contain: none;
height: 100%;
}
.supervisor-workspace__graph-list > .supervisor-workspace__list-panel {
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
}
@container supervisor (min-width: 861px) and (max-width: 1190px) {
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
max-height: none;
overflow-y: visible;
}
}
.supervisor-workspace__canvas-heading {
display: flex;
justify-content: space-between;
+36
View File
@@ -0,0 +1,36 @@
import { useLayoutEffect, type RefObject } from 'react'
/**
* Sets `--fill-height` on the element: the visible height of its scroll container from the element's
* top down, minus `bottomGap`, never below `minimum`. Lets a panel fill the window with a steady bottom
* margin while its columns scroll inside, instead of growing the page with their content.
* Measured with the container scrolled to the top, so scrolling does not change the value.
*/
export function useFillHeight(ref: RefObject<HTMLElement | null>, enabled: boolean, bottomGap = 24, minimum = 420): void {
useLayoutEffect(() => {
const element = ref.current
if (!element || !enabled) return
let container: HTMLElement | null = element.parentElement
while (container && !/(auto|scroll)/.test(getComputedStyle(container).overflowY)) container = container.parentElement
const scroller = container ?? document.documentElement
const measure = () => {
const top = element.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop
const padding = parseFloat(getComputedStyle(scroller).paddingBottom) || 0
// The layout's own border sits outside the row; subtract it so the outer box ends exactly at the gap.
const style = getComputedStyle(element)
const borders = (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0)
const height = Math.max(minimum, Math.floor(scroller.clientHeight - top - Math.max(bottomGap, padding) - borders))
element.style.setProperty('--fill-height', `${height}px`)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(scroller)
// Content above the element (notices, toolbars, headers) moves its top: observe earlier siblings up the tree.
// Parents are not observed, since the element's own height changes theirs.
for (let node: HTMLElement | null = element; node && node !== scroller; node = node.parentElement) {
for (let sibling = node.previousElementSibling; sibling; sibling = sibling.previousElementSibling) observer.observe(sibling)
}
window.addEventListener('resize', measure)
return () => { observer.disconnect(); window.removeEventListener('resize', measure); element.style.removeProperty('--fill-height') }
}, [ref, enabled, bottomGap, minimum])
}
@@ -824,6 +824,38 @@ app
await settle()
await writeFile(join(artifacts, `dense-bottom-${width}.png`), (await win.webContents.capturePage()).toPNG())
}
// The graph row fills the window down to a steady bottom margin; each column scrolls on its own.
for (const [width, height] of [[1440, 900], [1440, 700], [1024, 800]]) {
win.setContentSize(width, height)
await settle()
const fill = await js(`(() => {
const shell = document.querySelector('.page-shell');
shell.scrollTop = 0;
const layout = document.querySelector('.supervisor-workspace__graph-layout');
const shellBox = shell.getBoundingClientRect(), box = layout.getBoundingClientRect();
const columns = [...layout.children].map(column => {
const scroller = column.matches('.supervisor-workspace__graph-list') ? column.querySelector('[role=tabpanel]') : column;
const r = column.getBoundingClientRect();
return { name: column.className.split(' ')[0], top: r.top, bottom: r.bottom, scrolls: getComputedStyle(scroller).overflowY,
clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight };
});
// Bottom of the first grid row (the side-by-side columns), measured from the window bottom.
const rowBottom = Math.max(...columns.filter(column => Math.abs(column.top - columns[0].top) < 1).map(column => column.bottom));
return { bottomGap: shellBox.bottom - rowBottom, wrapped: columns.length - columns.filter(column => Math.abs(column.top - columns[0].top) < 1).length,
height: box.height, pageScroll: shell.scrollHeight - shell.clientHeight, columns };
})()`)
reports.push({ fill: { width, height, ...fill } })
await writeFile(join(artifacts, `fill-${width}x${height}.json`), JSON.stringify({ width, height, bottomGap: Math.round(fill.bottomGap), wrapped: fill.wrapped, pageScroll: fill.pageScroll, rowHeight: Math.round(fill.columns[0].bottom - fill.columns[0].top) }))
assert(fill.bottomGap >= 20 && fill.bottomGap <= 40, `Graph must stop above the window bottom: ${JSON.stringify(fill)}`)
// Three columns fit the window with no page scroll; at two columns only the wrapped detail extends the page.
if (!fill.wrapped) assert(fill.pageScroll <= 1, `Page must not scroll at three columns: ${JSON.stringify(fill)}`)
const sideBySide = fill.columns.filter(column => Math.abs(column.top - fill.columns[0].top) < 1)
for (const column of sideBySide) {
assert(Math.abs(column.bottom - sideBySide[0].bottom) < 1, `Columns must share the row height: ${JSON.stringify(fill)}`)
assert(['auto', 'scroll'].includes(column.scrolls), `Column must scroll on its own: ${JSON.stringify(column)}`)
}
await writeFile(join(artifacts, `fill-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG())
}
win.setContentSize(1440, 1100)
await js(
'document.querySelectorAll(".supervisor-workspace__graph-list [role=tab]")[2].click()'