diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index 879309ed..6fdc1d2b 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -4,9 +4,24 @@ 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 +## 2026-10-02 故事线模型第 5 步:工作回顾按故事组织 + +对应[故事线模型改造设计](./storyline-model-design.md)第 5 步“工作回顾页”。 + +实现: + +- 工作回顾在所选回顾结果的摘要之上新增“这段时间的故事”,按该结果的范围和时间区间读取已保存的故事和经验,不调用模型(`supervision-story-digest.ts`、`SupervisionStoryDigest.tsx`)。 +- 分组:推进的故事(区间内有事件、区间前也有)、新出现的故事(第一个事件在区间内)、已结束的故事(结束依据事件在区间内)、形成或用上的经验(最早形成依据在区间内,或区间内有应用)、这段时间没有新进展(进行中、区间前有事件、区间内没有;跨项目故事和已结束的故事不算)。功能把子线索的事件计入自己,子线索不重复列出。 +- 每组按事件数排序,默认显示 6 条,可展开其余;每条显示项目、事件数和最近一个事件。点击任一故事或经验打开图谱,并选中该对象。原有的工作总结、本次变化、未解决事项保留在下方,作为阅读入口。 + +验证: + +- `supervision-story-digest.test.ts` 2 项(分组规则、子线索计入功能、跨项目与已结束故事不算无进展、经验形成与应用区分);`SupervisorWorkspace.test.tsx` 新增 1 项(按所选结果范围读取故事、各分组内容、点击进入图谱)。相关组件测试 4 个文件 73 项通过,Web 类型检查、相关 ESLint 通过。 +- Electron 实测(生产组件,第 5 步数据副本导出的 46 个故事、38 条经验和一条 7 天的全局回顾结果;1440 浅色、1024 深色、390 深色):无报错、无横向溢出、条目无文本溢出;宽屏 3 列、窄屏 1 列;展开后从 13 条到 26 条;点击条目切到图谱。该副本的数据从 09-19 开始,这条结果的区间正好覆盖全部数据,因此只出现“新出现的故事 19”“已结束的故事 1”“形成或用上的经验 33”,“推进的故事”和“没有新进展”两组没有在真实数据中出现,只由单元测试覆盖。 + ## 2026-10-02 故事线模型第 5 步:心跳故事建议与 MCP 故事、经验查询 -对应[故事线模型改造设计](./storyline-model-design.md)第 5 步。工作回顾页按故事组织尚未实施。 +对应[故事线模型改造设计](./storyline-model-design.md)第 5 步。工作回顾页按故事组织见上一节。 实现: @@ -33,7 +48,7 @@ - 再次生成时没有重复任何待处理建议;只补上了上一次被 20 条上限挤掉的未决事项(1 次调用)。 - MCP:全局范围搜到 6 个相关故事、9 条相关经验;一个功能的上下文返回 96 个事件(含子线索),一条经验返回 5 个依据事件。 -边界:工作回顾页仍按回顾结果组织,未改为按故事组织;时间螺旋视图尚未显示经验节点;是否在普通对话中主动查询故事与经验,取决于 Agent 是否调用工具,未做额外注入。 +边界:时间螺旋视图尚未显示经验节点;是否在普通对话中主动查询故事与经验,取决于 Agent 是否调用工具,未做额外注入。 ## 2026-10-02 故事线模型第 4 步:经验抽取 diff --git a/docs/features/conversation-supervision/storyline-model-design.md b/docs/features/conversation-supervision/storyline-model-design.md index 114d5281..d19a2ad1 100644 --- a/docs/features/conversation-supervision/storyline-model-design.md +++ b/docs/features/conversation-supervision/storyline-model-design.md @@ -1,6 +1,6 @@ # 故事线模型改造设计 -状态:2026-10-02 已确认的目标设计。第 1 步(共享时间线、事件起止与密度、跨项目开关)、第 2 步(故事归属与用户调整)、第 3 步(时间螺旋视图)、第 4 步(经验抽取)和第 5 步的心跳故事建议与 MCP 故事、经验查询已实现,见[实施进度](./progress.md);第 5 步中工作回顾页按故事组织尚未实现。本文定义监督回顾如何产出下一版故事线(时间螺旋、分层故事木片、经验 W),是 [3D 故事线图谱概念](../story-graph/3d-concept.md) 的数据来源合同。现有回顾流程见[回顾分块调度设计](./review-scheduling-design.md),现有图谱工具见 [Story Graph MCP 设计](./story-graph-mcp-design.md)。 +状态:2026-10-02 已确认的目标设计。第 1 步(共享时间线、事件起止与密度、跨项目开关)、第 2 步(故事归属与用户调整)、第 3 步(时间螺旋视图)、第 4 步(经验抽取)和第 5 步(心跳故事建议、MCP 故事与经验查询、工作回顾按故事组织)已实现,见[实施进度](./progress.md)。本文定义监督回顾如何产出下一版故事线(时间螺旋、分层故事木片、经验 W),是 [3D 故事线图谱概念](../story-graph/3d-concept.md) 的数据来源合同。现有回顾流程见[回顾分块调度设计](./review-scheduling-design.md),现有图谱工具见 [Story Graph MCP 设计](./story-graph-mcp-design.md)。 ## 1. 结构总览 diff --git a/src/renderer/src/SupervisionStoryDigest.tsx b/src/renderer/src/SupervisionStoryDigest.tsx new file mode 100644 index 00000000..78d8a755 --- /dev/null +++ b/src/renderer/src/SupervisionStoryDigest.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { SupervisionStoryView } from '../../shared/supervision-story-contracts' +import { storyDigest, type StoryDigestEntry } from './supervision-story-digest' + +type Focus = { kind: 'story' | 'experience'; id: string } +const shown = 6 + +/** + * The work review, organized by story for the selected result's period: which stories moved, + * started, finished or stayed quiet, and which experiences formed or were used. Reads stored + * stories only; each item opens the story or experience in the graph. + */ +export function SupervisionStoryDigest({ view, range, date, onOpen }: { + view: SupervisionStoryView + range: { from: string; to: string } + date: (value: string) => string + onOpen: (focus: Focus) => void +}): React.JSX.Element { + const { t } = useTranslation('heartbeat') + const [expanded, setExpanded] = useState>({}) + const digest = storyDigest(view, range) + const total = digest.advanced.length + digest.started.length + digest.concluded.length + digest.experiences.length + const limit = (key: string, items: T[]) => expanded[key] ? items : items.slice(0, shown) + const more = (key: string, count: number) => count > shown && !expanded[key] + ? + : null + const storyItem = (entry: StoryDigestEntry) =>
  • + +
  • + const section = (key: 'advanced' | 'started' | 'concluded', items: StoryDigestEntry[]) => items.length > 0 &&
    +

    {t(`supervisor.digest.${key}`)} {items.length}

    +
      {limit(key, items).map(storyItem)}
    + {more(key, items.length)} +
    + return
    +

    {t('supervisor.digest.title')}

    + {total === 0 &&

    {t('supervisor.digest.empty')}

    } +
    + {section('advanced', digest.advanced)} + {section('started', digest.started)} + {section('concluded', digest.concluded)} + {digest.experiences.length > 0 &&
    +

    {t('supervisor.digest.experiences')} {digest.experiences.length}

    +
      {limit('experiences', digest.experiences).map(entry =>
    • + +
    • )}
    + {more('experiences', digest.experiences.length)} +
    } +
    + {digest.quiet.length > 0 &&
    +

    {t('supervisor.digest.quiet')} {digest.quiet.length}

    +
      {limit('quiet', digest.quiet).map(entry =>
    • + + {t('supervisor.digest.lastAt', { date: date(entry.latest.startedAt) })} +
    • )}
    + {more('quiet', digest.quiet.length)} +
    } + {digest.unassigned > 0 && total > 0 &&

    {t('supervisor.digest.unassigned', { count: digest.unassigned })}

    } +
    +} diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx index f8675812..84561939 100644 --- a/src/renderer/src/SupervisorWorkspace.test.tsx +++ b/src/renderer/src/SupervisorWorkspace.test.tsx @@ -359,6 +359,39 @@ describe('SupervisorWorkspace', () => { await waitFor(() => expect(storyAction).toHaveBeenCalledWith({ action: 'move', eventId: 'event-1', storyId: null })) }) + it('organizes the work review by story for the selected period and opens a story in the graph', async () => { + const story = (id: string, name: string, at: string[]) => ({ + id, projectId: 'p', projectName: 'goodbuddy', parentId: null, level: 'feature', name, description: '', state: 'active', stateEventId: null, + userEdited: false, startedAt: at[0], endedAt: at.at(-1), + events: at.map((value, index) => ({ id: `${id}-${index}`, title: `${name} 第 ${index + 1} 步`, projectId: 'p', startedAt: value, endedAt: value, primary: true, userSet: false })) + }) + const view = { unassigned: 1, canUndo: false, stories: [ + story('11111111-1111-4111-8111-111111111111', '监督者', ['2026-09-10T00:00:00.000Z', '2026-09-21T00:00:00.000Z']), + story('22222222-2222-4222-8222-222222222222', '时间螺旋', ['2026-09-21T06:00:00.000Z']), + story('33333333-3333-4333-8333-333333333333', '笔记', ['2026-09-05T00:00:00.000Z']) + ], experiences: [{ id: '55555555-5555-4555-8555-555555555555', statement: '先验证关键假设', conditions: '', boundaries: '', userEdited: false, + events: [{ id: 'e', role: 'formed', note: '', title: '决定', projectId: 'p', at: '2026-09-21T00:00:00.000Z', storyId: 's', storyName: '监督者' }] }] } + const stories = vi.fn(async () => view) + const onTabChange = vi.fn() + window.goodbuddy = { supervision: { + overview: vi.fn(async () => [result]), stories, storyAction: vi.fn(), + graph: vi.fn(async () => ({ storyLine: { id: 'story', scope_json: '{"kind":"global"}' }, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })) + } } as never + render() + const digest = await screen.findByRole('article', { name: '按故事查看本次回顾' }) + await waitFor(() => expect(within(digest).getByRole('region', { name: /推进的故事/ })).toBeInTheDocument()) + expect(stories).toHaveBeenCalledWith({ scope: { kind: 'global' } }) + expect(within(within(digest).getByRole('region', { name: /推进的故事/ })).getByText('最近:监督者 第 2 步')).toBeInTheDocument() + expect(within(within(digest).getByRole('region', { name: /新出现的故事/ })).getByText('时间螺旋')).toBeInTheDocument() + expect(within(within(digest).getByRole('region', { name: /没有新进展/ })).getByRole('button', { name: '在图谱中查看 笔记' })).toBeInTheDocument() + expect(within(within(digest).getByRole('region', { name: /经验/ })).getByText('先验证关键假设')).toBeInTheDocument() + expect(within(digest).getByText('另有 1 个事件暂不归入任何故事。')).toBeInTheDocument() + // The original summary stays as the reading entry below. + expect(screen.getByText('Recap')).toBeInTheDocument() + fireEvent.click(within(digest).getByRole('button', { name: '在图谱中查看 监督者' })) + expect(onTabChange).toHaveBeenCalledWith('graph') + }) + it('lists experiences with evidence and applications and lets users edit or delete them', async () => { const experience = { id: '55555555-5555-4555-8555-555555555555', statement: '先验证关键假设', conditions: '新方向', boundaries: '紧急修复', userEdited: false, events: [{ id: 'event-1', role: 'formed', note: '', title: '决定', projectId: 'p', at: '2026-09-21T00:00:00.000Z', storyId: 's', storyName: '监督者' }, diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index f0c35f08..f9c79286 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -5,6 +5,7 @@ import { AnchoredMenu } from './AnchoredMenu' import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts' import { EmptyState, PageTabs } from './WorkspacePrimitives' import { SupervisionDiscussion } from './SupervisionDiscussion' +import { SupervisionStoryDigest } from './SupervisionStoryDigest' import { SupervisionEventStory, SupervisionExperienceDetail, SupervisionExperienceList, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories' import type { AssistantProject } from '../../shared/assistant-contracts' import { heartbeatScopeSchema } from '../../shared/assistant-contracts' @@ -477,6 +478,8 @@ export function SupervisorWorkspace({ ? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope']) : undefined const storyState = useSupervisionStories(graphScope, tab === 'graph', graph) + // 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 const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined const busy = !!api && (loading || pending !== undefined) @@ -630,6 +633,8 @@ export function SupervisorWorkspace({ {t('supervisor.graph')} } } + {latest && recapStories.available && { select(focus); onTabChange?.('graph') }} />} {latest ? (
    diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts index fa7b7435..fde3dd62 100644 --- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts +++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts @@ -133,6 +133,14 @@ export const heartbeat = { mergeInto: 'Merge into', chooseStory: 'Choose a story', merge: 'Merge', events: 'Events ({{count}})', primary: 'Story', none: 'Not in a story', missing: 'This story was changed. Select it again.' }, + digest: { + title: 'Stories in this period', label: 'This review by story', + advanced: 'Stories that moved forward', started: 'New stories', concluded: 'Finished stories', quiet: 'No new progress in this period', + experiences: 'Experiences formed or used', formed: 'New', applied: 'Used', + events: '{{count}} events', latest: 'Latest: {{title}}', lastAt: 'Last progress {{date}}', + empty: 'No events in this period belong to a story yet. After stories are organized, progress appears here by story.', + unassigned: '{{count}} more events are not in any story.', more: '{{count}} more', open: 'View {{name}} in the graph' + }, listEmpty: 'No records of this type in this review.', openConversation: 'Open conversation', discussion: { diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts index fbb90a4c..3c5f5f27 100644 --- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts +++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts @@ -130,6 +130,14 @@ export const heartbeat = { mergeInto: '合并到', chooseStory: '选择故事', merge: '合并', events: '事件({{count}})', primary: '所属故事', none: '暂不归类', missing: '这个故事已被调整,请重新选择。' }, + digest: { + title: '这段时间的故事', label: '按故事查看本次回顾', + advanced: '推进的故事', started: '新出现的故事', concluded: '已结束的故事', quiet: '这段时间没有新进展', + experiences: '形成或用上的经验', formed: '新形成', applied: '用上了', + events: '{{count}} 个事件', latest: '最近:{{title}}', lastAt: '最后进展 {{date}}', + empty: '这段时间还没有归入故事的事件。故事整理完成后,这里会按故事列出进展。', + unassigned: '另有 {{count}} 个事件暂不归入任何故事。', more: '另有 {{count}} 个', open: '在图谱中查看 {{name}}' + }, listEmpty: '本次回顾暂无此类记录。', openConversation: '打开会话', discussion: { diff --git a/src/renderer/src/supervision-story-digest.test.ts b/src/renderer/src/supervision-story-digest.test.ts new file mode 100644 index 00000000..928631e8 --- /dev/null +++ b/src/renderer/src/supervision-story-digest.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from 'vitest' +import type { SupervisionStory, SupervisionStoryView } from '../../shared/supervision-story-contracts' +import { storyDigest } from './supervision-story-digest' + +const event = (id: string, at: string, primary = true) => ({ id, title: id, projectId: 'p', startedAt: at, endedAt: at, primary, userSet: false }) +const story = (id: string, events: ReturnType[], extra: Partial = {}): SupervisionStory => ({ + id, projectId: 'p', projectName: 'P', parentId: null, level: 'feature', name: id, description: '', state: 'active', stateEventId: null, + userEdited: false, events, startedAt: events[0]?.startedAt ?? null, endedAt: events.at(-1)?.endedAt ?? null, ...extra }) +const range = { from: '2026-09-20T00:00:00Z', to: '2026-09-27T00:00:00Z' } + +it('sorts stories into advanced, started, concluded and quiet for the period, counting threads under their feature', () => { + const view: SupervisionStoryView = { unassigned: 2, canUndo: false, experiences: [], stories: [ + story('Review', [event('r1', '2026-09-10T00:00:00Z'), event('r2', '2026-09-21T00:00:00Z')]), + story('Review thread', [event('t1', '2026-09-22T00:00:00Z'), event('t2', '2026-09-23T00:00:00Z')], { level: 'thread', parentId: 'Review' }), + story('Graph 3D', [event('g1', '2026-09-24T00:00:00Z')]), + story('Release', [event('x1', '2026-09-05T00:00:00Z'), event('x2', '2026-09-25T00:00:00Z')], { state: 'concluded', stateEventId: 'x2' }), + story('Notes', [event('n1', '2026-09-01T00:00:00Z'), event('n2', '2026-09-12T00:00:00Z')]), + story('Done earlier', [event('d1', '2026-09-02T00:00:00Z')], { state: 'concluded', stateEventId: 'd1' }), + story('Cross', [event('c1', '2026-09-01T00:00:00Z', false)], { level: 'cross', projectId: null }), + story('Later', [event('l1', '2026-09-29T00:00:00Z')]) + ] } + const digest = storyDigest(view, range) + expect(digest.advanced.map(entry => [entry.story.name, entry.events.map(item => item.id), entry.latest.id])).toEqual([['Review', ['r2', 't1', 't2'], 't2']]) + expect(digest.started.map(entry => entry.story.name)).toEqual(['Graph 3D']) + expect(digest.concluded.map(entry => entry.story.name)).toEqual(['Release']) + // Quiet is active only; a concluded story and a cross story are not quiet; future-only stories are ignored. + expect(digest.quiet.map(entry => [entry.story.name, entry.latest.id])).toEqual([['Notes', 'n2']]) + expect(digest.unassigned).toBe(2) +}) + +it('lists experiences formed in the period, or applied in it when formed earlier', () => { + const formedEvent = (id: string, at: string, role: 'formed' | 'applied') => ({ id, role, note: '', title: id, projectId: 'p', at, storyId: 's', storyName: 'S' }) + const experience = (id: string, events: ReturnType[]) => ({ id, statement: id, conditions: '', boundaries: '', userEdited: false, events }) + const view: SupervisionStoryView = { unassigned: 0, canUndo: false, stories: [], experiences: [ + experience('new', [formedEvent('a', '2026-09-21T00:00:00Z', 'formed')]), + experience('reused', [formedEvent('b', '2026-09-01T00:00:00Z', 'formed'), formedEvent('c', '2026-09-22T00:00:00Z', 'applied')]), + experience('old', [formedEvent('d', '2026-09-01T00:00:00Z', 'formed')]) + ] } + expect(storyDigest(view, range).experiences.map(entry => [entry.experience.id, entry.role, entry.events.map(item => item.id)])) + .toEqual([['new', 'formed', ['a']], ['reused', 'applied', ['c']]]) +}) diff --git a/src/renderer/src/supervision-story-digest.ts b/src/renderer/src/supervision-story-digest.ts new file mode 100644 index 00000000..5d2ac31e --- /dev/null +++ b/src/renderer/src/supervision-story-digest.ts @@ -0,0 +1,61 @@ +import type { SupervisionExperience, SupervisionStory, SupervisionStoryEvent, SupervisionStoryView } from '../../shared/supervision-story-contracts' + +/** + * What happened to long-lived stories during one review period, read from stories already + * stored; no model call. A story counts by its own primary events (a cross story by its links). + * - advanced: had events in the period and already had events before it. + * - started: its first event falls in the period. + * - quiet: still active, had events before the period and none in it. Quiet is not "finished". + * - concluded: marked concluded by an event in the period. + * Experiences count as formed or applied when the corresponding event falls in the period. + */ +export type StoryDigestEntry = { story: SupervisionStory; events: SupervisionStoryEvent[]; latest: SupervisionStoryEvent } +export type ExperienceDigestEntry = { experience: SupervisionExperience; role: 'formed' | 'applied'; events: SupervisionExperience['events'] } +export type StoryDigest = { + advanced: StoryDigestEntry[] + started: StoryDigestEntry[] + concluded: StoryDigestEntry[] + quiet: Array<{ story: SupervisionStory; latest: SupervisionStoryEvent }> + experiences: ExperienceDigestEntry[] + unassigned: number +} + +const own = (story: SupervisionStory) => story.events.filter(event => story.level === 'cross' || event.primary) + +export function storyDigest(view: SupervisionStoryView, range: { from: string; to: string }): StoryDigest { + const from = Date.parse(range.from) + const to = Date.parse(range.to) + const inRange = (value: string) => { const at = Date.parse(value); return at >= from && at <= to } + const digest: StoryDigest = { advanced: [], started: [], concluded: [], quiet: [], experiences: [], unassigned: view.unassigned } + // Features show their threads' work too; threads are listed on their own only when the feature is absent. + const children = (story: SupervisionStory) => view.stories.filter(child => child.parentId === story.id) + for (const story of view.stories) { + if (story.level === 'thread' && view.stories.some(parent => parent.id === story.parentId)) continue + const events = [...own(story), ...children(story).flatMap(own)].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id)) + if (!events.length) continue + const during = events.filter(event => inRange(event.startedAt)) + const before = events.filter(event => Date.parse(event.startedAt) < from) + if (during.length) { + const entry = { story, events: during, latest: during[during.length - 1]! } + if (story.state === 'concluded' && story.stateEventId && during.some(event => event.id === story.stateEventId)) digest.concluded.push(entry) + else if (before.length) digest.advanced.push(entry) + else digest.started.push(entry) + } else if (before.length && story.state === 'active' && story.level !== 'cross') { + digest.quiet.push({ story, latest: before[before.length - 1]! }) + } + } + const byActivity = (a: StoryDigestEntry, b: StoryDigestEntry) => b.events.length - a.events.length || b.latest.startedAt.localeCompare(a.latest.startedAt) + digest.advanced.sort(byActivity) + digest.started.sort(byActivity) + digest.concluded.sort(byActivity) + digest.quiet.sort((a, b) => b.latest.startedAt.localeCompare(a.latest.startedAt)) + for (const experience of view.experiences) { + const applied = experience.events.filter(event => event.role === 'applied' && inRange(event.at)) + const formed = experience.events.filter(event => event.role === 'formed') + // Formed in the period when its earliest evidence is in the period. + const first = formed.map(event => event.at).sort()[0] + if (first && inRange(first)) digest.experiences.push({ experience, role: 'formed', events: formed.filter(event => inRange(event.at)) }) + else if (applied.length) digest.experiences.push({ experience, role: 'applied', events: applied }) + } + return digest +} diff --git a/src/renderer/src/supervisor-workspace.css b/src/renderer/src/supervisor-workspace.css index 0d615505..2a93bbce 100644 --- a/src/renderer/src/supervisor-workspace.css +++ b/src/renderer/src/supervisor-workspace.css @@ -262,6 +262,99 @@ margin-top: var(--space-6); font-size: var(--font-section-title); } +/* Work review by story: sections in a responsive grid, items as quiet buttons. */ +.supervisor-digest { + margin-top: var(--space-4); + padding: var(--space-6); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + background: var(--surface-raised); + min-width: 0; +} +.supervisor-digest__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: var(--space-6); + margin-top: var(--space-4); +} +.supervisor-digest section { + min-width: 0; +} +.supervisor-digest h3 { + display: flex; + align-items: baseline; + gap: var(--space-2); + margin: 0 0 var(--space-2); + font-size: var(--font-body); +} +.supervisor-digest h3 small { + color: var(--text-secondary); + font-weight: normal; +} +.supervisor-digest ul { + display: grid; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; +} +.supervisor-digest__item { + display: grid; + gap: 2px; + width: 100%; + padding: var(--space-2) var(--space-3); + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.supervisor-digest__item:hover { + background: var(--surface-muted); +} +.supervisor-digest__item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.supervisor-digest__name { + font-weight: 600; + overflow-wrap: anywhere; +} +.supervisor-digest__item small, +.supervisor-digest__latest { + color: var(--text-secondary); + overflow-wrap: anywhere; +} +.supervisor-digest__latest { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.supervisor-digest__quiet { + margin-top: var(--space-6); + padding-top: var(--space-4); + border-top: 1px solid var(--border-subtle); +} +.supervisor-digest__quiet ul { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); +} +.supervisor-digest__quiet li { + display: flex; + align-items: baseline; + gap: var(--space-2); +} +.supervisor-digest__quiet small { + color: var(--text-secondary); +} +.supervisor-digest > .link-button, +.supervisor-digest section > .link-button { + margin-top: var(--space-2); +} .supervisor-workspace__result-navigation { display: flex; flex-wrap: wrap;