chore: commit pending workspace changes (supervisor settings tabs, conversation keep-alive budget, PR article updates)

This commit is contained in:
mesalogo
2026-10-03 16:22:42 +08:00
parent 805acc3fce
commit b28ddd4ba8
14 changed files with 326 additions and 115 deletions
+16 -12
View File
@@ -325,8 +325,8 @@ const conversationPersistenceIntervalMs = 500;
const liveMessageFlushIntervalMs = 250;
const keepAliveExpirationMs = 60 * 60 * 1_000;
const keepAliveSweepIntervalMs = 5 * 60 * 1_000;
const maximumCachedConversations = 12;
const recentCachedConversations = 5;
const maximumCachedConversations = 6;
const recentCachedConversations = 2;
// Chat is the primary route and stays resident outside the capacity below,
// together with any route that reports unsaved edits.
const pinnedWorkspaceViews = ["chat"] as const;
@@ -1842,6 +1842,13 @@ function App(): React.JSX.Element {
},
[],
);
// Busy conversations (sidebar activity indicators and queued follow-ups) keep their cached view.
const busyConversationIdsRef = useRef<ReadonlySet<string>>(new Set());
const activeConversationViewIds = useCallback((): Set<string> => new Set([
...[...activeRuns.current.values()].map((run) => run.conversationId),
...preparingConversations.current,
...busyConversationIdsRef.current,
]), []);
const setActiveId = useCallback((update: SetStateAction<string>): void => {
const next =
typeof update === "function"
@@ -1850,23 +1857,17 @@ function App(): React.JSX.Element {
activeConversationIdRef.current = next;
if (next) {
const now = Date.now();
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId),
);
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId),
);
setCachedConversationViews((current) =>
touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedConversations,
protectedKeys: runningConversationIds,
protectedKeys: activeConversationViewIds(),
recentEntries: recentCachedConversations,
}),
);
}
setActiveIdState(next);
}, []);
}, [activeConversationViewIds]);
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
const [
conversationHtmlRenderingEnabled,
@@ -2387,7 +2388,7 @@ function App(): React.JSX.Element {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedConversations,
now,
protectedKeys: runningConversationIds,
protectedKeys: activeConversationViewIds(),
recentEntries: recentCachedConversations,
},
),
@@ -2407,7 +2408,7 @@ function App(): React.JSX.Element {
};
const interval = window.setInterval(sweep, keepAliveSweepIntervalMs);
return () => window.clearInterval(interval);
}, [activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view, conversationStore]);
}, [activeConversationViewIds, activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view, conversationStore]);
useEffect(() => {
livePrimarySidebarWidthRef.current = primarySidebarWidth;
@@ -3335,6 +3336,9 @@ function App(): React.JSX.Element {
),
[projectActivity],
);
useLayoutEffect(() => {
busyConversationIdsRef.current = new Set([...activityByConversationId.keys(), ...queuedConversationIds]);
}, [activityByConversationId, queuedConversationIds]);
const pendingSidebarApprovals = usePendingSidebarApprovals(conversationStore);
const sidebarArtifacts = useMemo<SidebarArtifact[]>(
() =>
+24 -2
View File
@@ -219,7 +219,12 @@ describe('HeartbeatCenter', () => {
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
expect(within(panel).queryByRole('heading', { name: t(key) })).not.toBeInTheDocument()
expect(within(panel).queryByRole('tablist', { name: t('supervisor.navigation') })).not.toBeInTheDocument()
if (key !== 'supervisor.graph') {
if (key === 'supervisor.settings') {
const settingsNavigation = within(panel).getByRole('tablist', { name: t('settingsTabs.label') })
expect(screen.getAllByRole('tablist')).toEqual([navigation, settingsNavigation])
expect(within(settingsNavigation).getAllByRole('tab').map((tab) => tab.textContent))
.toEqual(['model', 'review', 'stories', 'suggestions'].map((id) => t(`settingsTabs.${id}`)))
} else if (key !== 'supervisor.graph') {
expect(screen.getAllByRole('tablist')).toEqual([navigation])
expect(within(panel).queryByRole('tablist')).not.toBeInTheDocument()
}
@@ -539,7 +544,14 @@ describe('HeartbeatCenter', () => {
expect(screen.queryByRole('heading', { name })).not.toBeInTheDocument()
}
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
expect(screen.getByRole('heading', { level: 2, name: '模型超时与并发' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '回顾整理' }))
expect(screen.getByRole('heading', { level: 2, name: '回顾算法' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '模型超时与并发' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '故事与经验' }))
expect(screen.getByRole('switch', { name: '跨项目关联' })).toBeVisible()
fireEvent.click(screen.getByRole('tab', { name: '建议' }))
expect(screen.getByLabelText(/故事多少天没有新进展/)).toBeVisible()
expect(screen.queryByRole('button', { name: '新建计划' })).not.toBeInTheDocument()
})
@@ -644,7 +656,17 @@ describe('HeartbeatCenter', () => {
expect(screen.queryByRole('region', { name: '心跳计划' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
expect(screen.getByRole('heading', { name: '回顾算法' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '模型超时与并发' })).toBeInTheDocument()
})
it('keeps an unsaved draft when switching settings tabs', () => {
renderComponent(<HeartbeatCenter {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
fireEvent.click(screen.getByRole('tab', { name: '回顾整理' }))
fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '33' } })
fireEvent.click(screen.getByRole('tab', { name: '建议' }))
fireEvent.click(screen.getByRole('tab', { name: '回顾整理' }))
expect(screen.getByLabelText(/每批消息数/)).toHaveValue(33)
})
it('passes manual project and time selections without requiring an automatic plan', async () => {
+27 -7
View File
@@ -19,7 +19,7 @@ import type {
} from '../../shared/assistant-contracts'
import { HeartbeatSettings } from './HeartbeatSettings'
import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defaultSupervisorModelConcurrency, supervisorModelConcurrencySchema, type ApplicationSettings, type ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
import { SupervisionReviewSettings } from './SupervisionReviewSettings'
import { SupervisionReviewSettings, type SupervisionSettingsSection } from './SupervisionReviewSettings'
import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace'
import { SupervisorActivity } from './SupervisorActivity'
import './supervisor-workspace.css'
@@ -165,17 +165,37 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
setPageTab('activity')
window.requestAnimationFrame(() => centerRef.current?.querySelector<HTMLElement>('#supervisor-tab-activity')?.focus())
}} />
{pageTab === 'settings' && <>
<SupervisionModelSettings {...props} />
<SupervisionReviewSettings settings={props.applicationSettings}
disabled={props.applicationSettingsPending || props.applicationSettingsLocked}
onSave={props.onUpdateApplicationSettings} />
</>}
{pageTab === 'settings' && <SupervisorSettingsTabs {...props} />}
</div>
</section>
)
}
type SettingsTab = 'model' | SupervisionSettingsSection
/** Supervisor settings grouped by purpose. Every section stays mounted, so switching tabs keeps unsaved drafts. */
function SupervisorSettingsTabs(props: HeartbeatCenterProps): React.JSX.Element {
const { t } = useTranslation('heartbeat')
const [tab, setTab] = useState<SettingsTab>('model')
const reviewProps = { settings: props.applicationSettings,
disabled: props.applicationSettingsPending || props.applicationSettingsLocked,
onSave: props.onUpdateApplicationSettings }
const tabs: { id: SettingsTab; label: string }[] = [
{ id: 'model', label: t('settingsTabs.model') },
{ id: 'review', label: t('settingsTabs.review') },
{ id: 'stories', label: t('settingsTabs.stories') },
{ id: 'suggestions', label: t('settingsTabs.suggestions') }
]
return <div className="supervisor-settings">
<PageTabs ariaLabel={t('settingsTabs.label')} idPrefix="supervisor-settings" value={tab} onChange={setTab}
variant="segmented" tabs={tabs} />
{tabs.map(({ id }) => <div key={id} role="tabpanel" id={`supervisor-settings-panel-${id}`}
aria-labelledby={`supervisor-settings-tab-${id}`} hidden={tab !== id}>
{id === 'model' ? <SupervisionModelSettings {...props} /> : <SupervisionReviewSettings section={id} {...reviewProps} />}
</div>)}
</div>
}
function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Element {
const { t } = useTranslation('heartbeat')
const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
@@ -28,8 +28,7 @@ it('saves validated algorithm controls and retains the draft until saved setting
fireEvent.change(screen.getByLabelText(/每次读取来源条数/), { target: { value: '17' } })
fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } })
fireEvent.change(screen.getByLabelText(/单次响应容量/), { target: { value: '2048' } })
const cross = screen.getByRole('switch', { name: '跨项目关联' })
expect(cross).not.toBeChecked()
expect(screen.queryByRole('switch', { name: '跨项目关联' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048, crossProject: false, storyThreadEvents: 20, experienceMinEvents: 5, stalledDays: 14 } })
expect(screen.getByLabelText(/每次读取来源条数/)).toHaveValue(17)
@@ -38,9 +37,23 @@ it('saves validated algorithm controls and retains the draft until saved setting
it('enables cross-project linking only when the switch is turned on', () => {
const save = vi.fn()
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
render(<SupervisionReviewSettings settings={settings} onSave={save} />)
expect(screen.getByRole('button', { name: '保存回顾算法' })).toBeDisabled()
render(<SupervisionReviewSettings section="stories" settings={settings} onSave={save} />)
expect(screen.getByRole('button', { name: '保存故事与经验设置' })).toBeDisabled()
fireEvent.click(screen.getByRole('switch', { name: '跨项目关联' }))
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
fireEvent.click(screen.getByRole('button', { name: '保存故事与经验设置' }))
expect(save).toHaveBeenCalledWith({ supervisionReview: expect.objectContaining({ crossProject: true }) })
})
it('saves one section over stored values without carrying another section\'s draft', () => {
const save = vi.fn()
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
render(<>
<SupervisionReviewSettings section="review" settings={settings} onSave={save} />
<SupervisionReviewSettings section="suggestions" settings={settings} onSave={save} />
</>)
expect(screen.queryByLabelText(/故事多少天没有新进展/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } })
fireEvent.change(screen.getByLabelText(/故事多少天没有新进展/), { target: { value: '7' } })
fireEvent.click(screen.getByRole('button', { name: '保存建议设置' }))
expect(save).toHaveBeenCalledWith({ supervisionReview: expect.objectContaining({ stalledDays: 7, batchMessages: 20 }) })
})
+42 -18
View File
@@ -2,47 +2,71 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ApplicationSettings, ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
import { defaultExperienceMinEvents, defaultStalledDays, defaultStoryThreadEvents, defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes'
const fields = [
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384], ['storyThreadEvents', 4, 500], ['experienceMinEvents', 2, 200], ['stalledDays', 1, 365]
] as const
export type SupervisionSettingsSection = 'review' | 'stories' | 'suggestions'
export function SupervisionReviewSettings({ settings, disabled, onSave }: {
type NumericKey = 'pageSize' | 'batchCharacters' | 'batchMessages' | 'responseKiB' | 'storyThreadEvents' | 'experienceMinEvents' | 'stalledDays'
const sections: Record<SupervisionSettingsSection, { fields: readonly (readonly [NumericKey, number, number])[]; crossProject?: boolean }> = {
review: { fields: [['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384]] },
stories: { fields: [['storyThreadEvents', 4, 500], ['experienceMinEvents', 2, 200]], crossProject: true },
suggestions: { fields: [['stalledDays', 1, 365]] }
}
const titles = { review: 'reviewSettings.title', stories: 'reviewSettings.storiesTitle', suggestions: 'reviewSettings.suggestionsTitle' } as const
const saves = { review: 'reviewSettings.save', stories: 'reviewSettings.saveStories', suggestions: 'reviewSettings.saveSuggestions' } as const
/**
* One settings section of the supervisor's review configuration. Each section edits only its own
* fields and saves them over the current stored values, so another section's unsaved draft is not
* overwritten or reset when this one saves.
*/
export function SupervisionReviewSettings({ settings, disabled, onSave, section = 'review' }: {
settings?: ApplicationSettings
disabled?: boolean
onSave?: (value: ApplicationSettingsUpdate) => void | Promise<unknown>
section?: SupervisionSettingsSection
}) {
const { t } = useTranslation('heartbeat')
const { fields, crossProject } = sections[section]
const current = { ...defaultSupervisionReviewSettings, ...settings?.supervisionReview,
responseKiB: settings?.supervisionReview?.responseKiB ?? 1024, crossProject: settings?.supervisionReview?.crossProject ?? false,
storyThreadEvents: settings?.supervisionReview?.storyThreadEvents ?? defaultStoryThreadEvents,
experienceMinEvents: settings?.supervisionReview?.experienceMinEvents ?? defaultExperienceMinEvents,
stalledDays: settings?.supervisionReview?.stalledDays ?? defaultStalledDays }
const same = (left: typeof current, right: typeof current) =>
fields.every(([key]) => left[key] === right[key]) && (!crossProject || left.crossProject === right.crossProject)
const [saved, setSaved] = useState(current)
const [draft, setDraft] = useState(current)
if (fields.some(([key]) => saved[key] !== current[key]) || saved.crossProject !== current.crossProject) { setSaved(current); setDraft(current) }
const unchanged = fields.every(([key]) => draft[key] === current[key]) && draft.crossProject === current.crossProject
const valid = supervisionReviewSettingsSchema.safeParse(draft).success
if (!same(saved, current)) { setSaved(current); setDraft(current) }
const unchanged = same(draft, current)
useWorkspaceUnsavedChanges(!unchanged)
const next = { ...current, ...Object.fromEntries(fields.map(([key]) => [key, draft[key]])), ...(crossProject ? { crossProject: draft.crossProject } : {}) }
const valid = supervisionReviewSettingsSchema.safeParse(next).success
const locked = disabled || !settings || !onSave
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={event => {
event.preventDefault()
if (valid && !locked) void onSave?.({ supervisionReview: draft })
if (valid && !locked) void onSave?.({ supervisionReview: next })
}}>
<h2>{t('reviewSettings.title')}</h2>
<p>{t('reviewSettings.help')}</p>
<h2>{t(titles[section])}</h2>
{section === 'review' && <p>{t('reviewSettings.help')}</p>}
{section === 'suggestions' && <p>{t('reviewSettings.suggestionsHelp')}</p>}
{fields.map(([key, min, max]) => <label key={key} className="heartbeat-settings__field">
{t(`reviewSettings.${key}`)} ({min}..{max})
<input type="number" min={min} max={max} step={1} value={draft[key]} disabled={locked}
onChange={event => setDraft({ ...draft, [key]: Number(event.target.value) })} />
</label>)}
<label className="toggle-row">
<span>{t('reviewSettings.crossProject')}</span>
<input type="checkbox" role="switch" checked={draft.crossProject} disabled={locked}
onChange={event => setDraft({ ...draft, crossProject: event.target.checked })} />
</label>
<p>{t('reviewSettings.crossProjectHelp')}</p>
<p>{t('reviewSettings.pauseHelp')}</p>
{crossProject && <>
<label className="toggle-row">
<span>{t('reviewSettings.crossProject')}</span>
<input type="checkbox" role="switch" checked={draft.crossProject} disabled={locked}
onChange={event => setDraft({ ...draft, crossProject: event.target.checked })} />
</label>
<p>{t('reviewSettings.crossProjectHelp')}</p>
</>}
{section === 'review' && <p>{t('reviewSettings.pauseHelp')}</p>}
{!valid && <p role="alert">{t('reviewSettings.invalid')}</p>}
<button type="submit" className="primary-button" disabled={locked || !valid || unchanged}>{t('reviewSettings.save')}</button>
<button type="submit" className="primary-button" disabled={locked || !valid || unchanged}>{t(saves[section])}</button>
</form>
}
+28 -24
View File
@@ -498,6 +498,9 @@ export function SupervisorWorkspace({
// The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat
// graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead.
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, GRAPH_MIN_HEIGHT)
const graphEmptyRef = useRef<HTMLDivElement>(null)
// Without a graph, the empty state takes the visible height so its message sits in the middle of the page.
useFillHeight(graphEmptyRef, tab === 'graph' && !loading && !loadError && graph.events.length === 0, 24, 320)
// 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
@@ -700,31 +703,32 @@ export function SupervisorWorkspace({
)}
{tab === 'graph' && (
<>
{/* With a graph, scope and refresh live in the canvas heading; without one, keep refresh reachable here. */}
{!graph.events.length && <div className="supervisor-workspace__action-bar">
<span />
<button
className="secondary-button"
disabled={busy}
onClick={() => void refresh()}
>
{t('center.actions.refresh')}
</button>
</div>}
{/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */}
{!loading && !loadError && !graph.events.length && (
<EmptyState
icon={<Network size={28} />}
title={t('supervisor.graphEmpty')}
description={t('supervisor.emptyHint')}
action={
<button
className="secondary-button"
onClick={() => onTabChange?.('overview')}
>
{t('supervisor.recap')}
</button>
}
/>
<div className="supervisor-workspace__graph-empty" ref={graphEmptyRef}>
<EmptyState
icon={<Network size={28} />}
title={t('supervisor.graphEmpty')}
description={t('supervisor.emptyHint')}
action={
<div className="supervisor-workspace__empty-actions">
<button
className="primary-button"
onClick={() => onTabChange?.('overview')}
>
{t('supervisor.recap')}
</button>
<button
className="secondary-button"
disabled={busy}
onClick={() => void refresh()}
>
{t('center.actions.refresh')}
</button>
</div>
}
/>
</div>
)}
{graph.events.length > 0 && (
<div className="supervisor-workspace__graph-layout" ref={graphLayoutRef}>
@@ -9,6 +9,9 @@ export const heartbeat = {
experienceMinEvents: 'Minimum events before a story is considered for experiences (default 5)',
stalledDays: 'Days without new events before a story is flagged (default 14)',
save: 'Save review algorithm',
storiesTitle: 'Stories and experiences', saveStories: 'Save story and experience settings',
suggestionsTitle: 'Supervisor suggestions', saveSuggestions: 'Save suggestion settings',
suggestionsHelp: 'Used when a heartbeat plan is set to suggest. Suggestions only remind you; they never change a story\'s status.',
help: 'Page size controls database reads, not total coverage. Batch limits split work without dropping remaining text. These settings and the organize timeout are frozen for each new review; continuing uses its saved configuration. Response capacity uses the current setting for each request; increase it before continuing a failed review.',
pageSize: 'Sources read at a time (default 50)', batchCharacters: 'Text size per batch (default 8000)',
batchMessages: 'Messages per batch (default 20)', executionSeconds: 'Time before pausing, seconds (default 300)',
@@ -31,6 +34,7 @@ export const heartbeat = {
runError: 'This run failed. Review the error details before continuing; saved batches remain available.',
diagnostics: 'Error details'
},
settingsTabs: { label: 'Supervisor settings sections', model: 'Model', review: 'Review processing', stories: 'Stories and experiences', suggestions: 'Suggestions' },
suggestions: {
title: 'Supervisor suggestions',
kind: { open_item: 'Open item', conflict: 'Disagreement', convention: 'Possible convention', revision: 'Important revision', stalled: 'No recent progress', experience: 'Relevant experience' },
@@ -6,6 +6,9 @@ export const heartbeat = {
experienceMinEvents: '故事参与经验整理的最少事件数(默认 5)',
stalledDays: '故事多少天没有新进展时提醒(默认 14)',
save: '保存回顾算法',
storiesTitle: '故事与经验', saveStories: '保存故事与经验设置',
suggestionsTitle: '监督建议', saveSuggestions: '保存建议设置',
suggestionsHelp: '心跳计划选择“生成建议”时使用。只提醒,不会改变故事的状态。',
help: '每页条数只控制数据库读取量,不限制回顾总量。分批上限用于拆分工作,剩余正文会继续处理。这些设置及整理超时在新回顾开始时固定;继续回顾沿用已保存的配置。响应容量按每次请求的当前设置生效,调高后可继续失败的回顾。',
pageSize: '每次读取来源条数(默认 50)', batchCharacters: '每批文本量(默认 8000)',
batchMessages: '每批消息数(默认 20)', executionSeconds: '单次整理时长,秒(默认 300)',
@@ -28,6 +31,7 @@ export const heartbeat = {
runError: '本次运行失败,请查看错误详情后继续。已保存的批次仍保留。',
diagnostics: '错误详情'
},
settingsTabs: { label: '监督者设置分类', model: '模型', review: '回顾整理', stories: '故事与经验', suggestions: '建议' },
suggestions: {
title: '监督建议',
kind: { open_item: '未决事项', conflict: '分歧', convention: '候选约定', revision: '重要修订', stalled: '暂无进展', experience: '可用经验' },
+14
View File
@@ -14,6 +14,20 @@
box-sizing: border-box;
align-self: stretch;
}
.supervisor-workspace__graph-empty {
display: grid;
min-height: var(--fill-height, 320px);
box-sizing: border-box;
}
.supervisor-workspace__graph-empty > .empty-state {
min-height: 0;
}
.supervisor-workspace__empty-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-3);
}
.supervisor-workspace__run-status {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;