fix: organize story graph lists into tabs

Replace the stacked event, entity and relation lists with shared tabs and an independently scrolling panel. Preserve graph selection when switching tabs and follow canvas selections in the sidebar. Update translations, UI documentation and focused interaction and Electron layout coverage.

Release note: Browse story graph events, entities and relations in separate sidebar tabs, with counts and synchronized canvas selection.
This commit is contained in:
mesalogo
2026-09-27 10:06:35 +08:00
parent 5177b47aeb
commit 40c108da1a
7 changed files with 77 additions and 44 deletions
@@ -352,9 +352,17 @@ describe('SupervisorWorkspace', () => {
render(<SupervisorWorkspace tab="graph" />)
const graph = await screen.findByRole('group', { name: '故事线图谱' })
const list = screen.getByRole('complementary', { name: '图谱选择' })
fireEvent.click(within(list).getByRole('tab', { name: /实体/ }))
fireEvent.click(within(list).getByRole('button', { name: '实体 10' }))
expect(within(graph).getByRole('button', { name: '实体 10' })).toHaveAttribute('aria-pressed', 'true')
expect(within(graph).getByRole('button', { name: '事件 20' })).toHaveClass('is-selected')
expect(graph.querySelector('[data-event-entity="event-20:entity-10"]')).toHaveClass('is-selected')
fireEvent.keyDown(within(list).getByRole('tab', { name: /实体/ }), { key: 'ArrowRight' })
expect(within(list).getByRole('tab', { name: /关系/ })).toHaveFocus()
expect(within(list).getByRole('tabpanel')).toHaveTextContent('本次回顾暂无此类记录。')
expect(within(graph).getByRole('button', { name: '实体 10' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(within(graph).getByRole('button', { name: '事件 20' }))
expect(within(list).getByRole('tab', { name: /事件/ })).toHaveAttribute('aria-selected', 'true')
expect(within(list).getAllByRole('button')).toHaveLength(24)
})
})
+30 -16
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BookOpen, Network, RefreshCw, X } from 'lucide-react'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState } from './WorkspacePrimitives'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
import {
@@ -61,6 +61,7 @@ export function SupervisorWorkspace({
const [error, setError] = useState<string>()
const [errorAction, setErrorAction] = useState<'run' | 'source' | 'action'>()
const [selection, setSelection] = useState<Selection>()
const [listTab, setListTab] = useState<Selection['kind']>('event')
const [source, setSource] = useState<{
id: string
title: string
@@ -388,6 +389,7 @@ export function SupervisorWorkspace({
const stageEvent = layout.events[stage]
const select = (next: Selection) => {
setSelection(next)
setListTab(next.kind)
setSource(undefined)
setConfirmRemoval(false)
setRevision(undefined)
@@ -672,11 +674,30 @@ export function SupervisorWorkspace({
className="supervisor-workspace__graph-list"
aria-label={t('supervisor.selection')}
>
<h3>
{t('supervisor.events')}
<span>{layout.events.length}</span>
</h3>
{layout.events.map((event, index) => (
<PageTabs
ariaLabel={t('supervisor.selection')}
idPrefix={`${graphId}-list`}
value={listTab}
onChange={setListTab}
tabs={[
{ id: 'event', label: `${t('supervisor.listTabs.event')} ${layout.events.length}` },
{ id: 'entity', label: `${t('supervisor.listTabs.entity')} ${layout.entities.length}` },
{ id: 'relation', label: `${t('supervisor.listTabs.relation')} ${graph.relations.length}` }
]}
/>
<div
key={listTab}
className="supervisor-workspace__list-panel"
role="tabpanel"
id={`${graphId}-list-panel-${listTab}`}
aria-labelledby={`${graphId}-list-tab-${listTab}`}
tabIndex={0}
>
{((listTab === 'entity' && !layout.entities.length) ||
(listTab === 'relation' && !graph.relations.length)) && (
<p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>
)}
{listTab === 'event' && layout.events.map((event, index) => (
<button
key={event.id}
aria-label={`${index + 1}. ${event.title} · ${date(event.occurred_at)}`}
@@ -694,11 +715,7 @@ export function SupervisorWorkspace({
</span>
</button>
))}
<h3>
{t('supervisor.entities')}
<span>{layout.entities.length}</span>
</h3>
{layout.entities.map((entity) => (
{listTab === 'entity' && layout.entities.map((entity) => (
<button
key={entity.id}
data-tone={entityTone(entity.id)}
@@ -716,11 +733,7 @@ export function SupervisorWorkspace({
</span>
</button>
))}
<h3>
{t('supervisor.relations')}
<span>{graph.relations.length}</span>
</h3>
{graph.relations.map((relation) => (
{listTab === 'relation' && graph.relations.map((relation) => (
<button
key={relation.id}
aria-pressed={selection?.id === relation.id}
@@ -744,6 +757,7 @@ export function SupervisorWorkspace({
)}
</button>
))}
</div>
<p className="supervisor-workspace__muted">
{t('supervisor.legend')}
</p>
@@ -85,6 +85,8 @@ export const heartbeat = {
newReview: 'New review', summary: 'Work summary', changes: 'Changes', runningHint: 'A new review is running. The existing result remains available to read.',
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an automatic supervision plan.', run: 'Review current progress', running: 'Reviewing…', retryRun: 'Retry review', dismiss: 'Dismiss',
graphScope: 'Graph scope', graphEmpty: 'No story events in this scope', legend: 'Solid lines show event impact on entities; dashed lines show entity relations. Time runs counter-clockwise with a visible gap.', start: 'Start', end: 'End',
listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations' },
listEmpty: 'No records of this type in this review.',
events: 'Time events', entities: 'Knowledge entities', relations: 'Entity relations', eventSources: 'Event sources', sources: 'Related sources', noSources: 'No related sources are available.', selectHint: 'Select an event, entity, or relation to inspect it.', sourceSnapshot: 'Source details', sourceMissing: 'Source not found',
confirm: 'Confirm', revise: 'Revise', remove: 'Remove relation', label: 'Entity name', save: 'Save revision', cancel: 'Cancel', removeHint: 'This changes graph organization only; the original source remains available.',
relationTypes: { supports: 'Supports', 'depends-on': 'Depends on', contrasts: 'Contrasts', related: 'Related' },
@@ -82,6 +82,8 @@ export const heartbeat = {
newReview: '新回顾', summary: '工作总结', changes: '本次变化', runningHint: '新回顾正在整理,已有结果仍可阅读。',
empty: '还没有成功回顾', emptyHint: '即使没有自动监督计划,也可以手动回顾当前进展。', run: '回顾当前进展', running: '回顾整理中…', retryRun: '重试回顾', dismiss: '关闭提示',
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
listTabs: { event: '事件', entity: '实体', relation: '关系' },
listEmpty: '本次回顾暂无此类记录。',
events: '时间事件', entities: '知识实体', relations: '实体关系', eventSources: '事件来源', sources: '关联来源', noSources: '没有可用的关联来源。', selectHint: '选择事件、实体或关系查看详情。', sourceSnapshot: '来源详情', sourceMissing: '来源不存在',
confirm: '确认', revise: '修订', remove: '移除关系', label: '实体名称', save: '保存修订', cancel: '取消', removeHint: '这只改变图谱中的关系组织,原始来源仍然保留。',
relationTypes: { supports: '支持', 'depends-on': '依赖', contrasts: '对比', related: '相关' },
+19 -19
View File
@@ -413,27 +413,27 @@
padding: var(--space-4);
background: var(--surface-subtle);
border-radius: var(--radius-card) 0 0 var(--radius-card);
overflow: hidden;
}
.supervisor-workspace__graph-list > .page-tabs,
.supervisor-workspace__graph-list > p {
flex: 0 0 auto;
}
.supervisor-workspace__graph-list > .supervisor-workspace__list-panel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: var(--space-1);
overflow: auto;
scrollbar-width: thin;
}
.supervisor-workspace__graph-list > * {
.supervisor-workspace__graph-list .page-tabs__tab {
gap: var(--space-1);
padding-inline: var(--space-1);
}
.supervisor-workspace__list-panel > button {
flex: 0 0 auto;
}
.supervisor-workspace__graph-list h3 {
display: flex;
justify-content: space-between;
margin: var(--space-4) 0 var(--space-2);
font-size: var(--font-body);
}
.supervisor-workspace__graph-list h3:first-child {
margin-top: var(--space-1);
}
.supervisor-workspace__graph-list h3 span {
color: var(--text-muted);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.supervisor-workspace__graph-list button {
display: flex;
align-items: baseline;
gap: var(--space-2);
@@ -468,10 +468,10 @@
background: currentColor;
border-radius: 50%;
}
.supervisor-workspace__graph-list button:hover {
.supervisor-workspace__list-panel > button:hover {
background: var(--surface-raised);
}
.supervisor-workspace__graph-list button[aria-pressed='true'] {
.supervisor-workspace__list-panel > button[aria-pressed='true'] {
border-color: var(--accent);
background: var(--accent-subtle);
}