feat: organize the work review by story
This commit is contained in:
@@ -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<Record<string, boolean>>({})
|
||||
const digest = storyDigest(view, range)
|
||||
const total = digest.advanced.length + digest.started.length + digest.concluded.length + digest.experiences.length
|
||||
const limit = <T,>(key: string, items: T[]) => expanded[key] ? items : items.slice(0, shown)
|
||||
const more = (key: string, count: number) => count > shown && !expanded[key]
|
||||
? <button type="button" className="link-button" onClick={() => setExpanded({ ...expanded, [key]: true })}>{t('supervisor.digest.more', { count: count - shown })}</button>
|
||||
: null
|
||||
const storyItem = (entry: StoryDigestEntry) => <li key={entry.story.id}>
|
||||
<button type="button" className="supervisor-digest__item" aria-label={t('supervisor.digest.open', { name: entry.story.name })}
|
||||
onClick={() => onOpen({ kind: 'story', id: entry.story.id })}>
|
||||
<span className="supervisor-digest__name">{entry.story.name}</span>
|
||||
<small>{[entry.story.projectName, t('supervisor.digest.events', { count: entry.events.length })].filter(Boolean).join(' · ')}</small>
|
||||
<span className="supervisor-digest__latest">{t('supervisor.digest.latest', { title: entry.latest.title })}</span>
|
||||
</button>
|
||||
</li>
|
||||
const section = (key: 'advanced' | 'started' | 'concluded', items: StoryDigestEntry[]) => items.length > 0 && <section key={key} aria-labelledby={`digest-${key}`}>
|
||||
<h3 id={`digest-${key}`}>{t(`supervisor.digest.${key}`)} <small>{items.length}</small></h3>
|
||||
<ul>{limit(key, items).map(storyItem)}</ul>
|
||||
{more(key, items.length)}
|
||||
</section>
|
||||
return <article className="supervisor-digest" aria-label={t('supervisor.digest.label')}>
|
||||
<div className="supervisor-workspace__section-heading"><h2>{t('supervisor.digest.title')}</h2></div>
|
||||
{total === 0 && <p className="supervisor-workspace__muted">{t('supervisor.digest.empty')}</p>}
|
||||
<div className="supervisor-digest__grid">
|
||||
{section('advanced', digest.advanced)}
|
||||
{section('started', digest.started)}
|
||||
{section('concluded', digest.concluded)}
|
||||
{digest.experiences.length > 0 && <section aria-labelledby="digest-experiences">
|
||||
<h3 id="digest-experiences">{t('supervisor.digest.experiences')} <small>{digest.experiences.length}</small></h3>
|
||||
<ul>{limit('experiences', digest.experiences).map(entry => <li key={entry.experience.id}>
|
||||
<button type="button" className="supervisor-digest__item" aria-label={t('supervisor.digest.open', { name: entry.experience.statement })}
|
||||
onClick={() => onOpen({ kind: 'experience', id: entry.experience.id })}>
|
||||
<span className="supervisor-digest__name">{entry.experience.statement}</span>
|
||||
<small>{[t(`supervisor.digest.${entry.role}`), [...new Set(entry.events.map(event => event.storyName).filter(Boolean))].join('、')].filter(Boolean).join(' · ')}</small>
|
||||
</button>
|
||||
</li>)}</ul>
|
||||
{more('experiences', digest.experiences.length)}
|
||||
</section>}
|
||||
</div>
|
||||
{digest.quiet.length > 0 && <section className="supervisor-digest__quiet" aria-labelledby="digest-quiet">
|
||||
<h3 id="digest-quiet">{t('supervisor.digest.quiet')} <small>{digest.quiet.length}</small></h3>
|
||||
<ul>{limit('quiet', digest.quiet).map(entry => <li key={entry.story.id}>
|
||||
<button type="button" className="link-button" aria-label={t('supervisor.digest.open', { name: entry.story.name })}
|
||||
onClick={() => onOpen({ kind: 'story', id: entry.story.id })}>{entry.story.name}</button>
|
||||
<small>{t('supervisor.digest.lastAt', { date: date(entry.latest.startedAt) })}</small>
|
||||
</li>)}</ul>
|
||||
{more('quiet', digest.quiet.length)}
|
||||
</section>}
|
||||
{digest.unassigned > 0 && total > 0 && <p className="supervisor-workspace__muted">{t('supervisor.digest.unassigned', { count: digest.unassigned })}</p>}
|
||||
</article>
|
||||
}
|
||||
@@ -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(<SupervisorWorkspace tab="overview" onTabChange={onTabChange} />)
|
||||
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: '监督者' },
|
||||
|
||||
@@ -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')}
|
||||
</button>}
|
||||
</div>}
|
||||
{latest && recapStories.available && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
|
||||
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
|
||||
{latest ? (
|
||||
<article className="supervisor-workspace__recap">
|
||||
<div className="supervisor-workspace__section-heading">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof event>[], extra: Partial<SupervisionStory> = {}): 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<typeof formedEvent>[]) => ({ 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']]])
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user