diff --git a/apps/cockpit/src/app/[...slug]/page.spec.tsx b/apps/cockpit/src/app/[...slug]/page.spec.tsx new file mode 100644 index 000000000..da4ab68ed --- /dev/null +++ b/apps/cockpit/src/app/[...slug]/page.spec.tsx @@ -0,0 +1,72 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('next/navigation', () => ({ + redirect: vi.fn(() => { + throw new Error('redirect() should not be called for a canonical slug'); + }), +})); + +vi.mock('../../lib/content-bundle', () => ({ + getContentBundle: vi.fn().mockResolvedValue({ + codeFiles: {}, + promptFiles: {}, + runtimeUrl: null, + docSections: [], + narrativeDocs: [], + }), +})); + +import CockpitRoutePage from './page'; +import { getCockpitPageModel } from '../../lib/cockpit-page'; + +describe('CockpitRoutePage', () => { + it('keys the rendered CockpitShell on the canonical path', async () => { + const slug = [ + 'langgraph', + 'core-capabilities', + 'streaming', + 'overview', + 'python', + ]; + const { canonicalPath } = getCockpitPageModel(slug); + + const element = await CockpitRoutePage({ + params: Promise.resolve({ slug }), + }); + + expect(element.key).toBe(canonicalPath); + }); + + it('gives two different capabilities two different keys', async () => { + const streamingSlug = [ + 'langgraph', + 'core-capabilities', + 'streaming', + 'overview', + 'python', + ]; + const persistenceSlug = [ + 'langgraph', + 'core-capabilities', + 'persistence', + 'overview', + 'python', + ]; + + const streamingElement = await CockpitRoutePage({ + params: Promise.resolve({ slug: streamingSlug }), + }); + const persistenceElement = await CockpitRoutePage({ + params: Promise.resolve({ slug: persistenceSlug }), + }); + + expect(streamingElement.key).not.toBe(persistenceElement.key); + expect(streamingElement.key).toBe( + getCockpitPageModel(streamingSlug).canonicalPath + ); + expect(persistenceElement.key).toBe( + getCockpitPageModel(persistenceSlug).canonicalPath + ); + }); +}); diff --git a/apps/cockpit/src/app/[...slug]/page.tsx b/apps/cockpit/src/app/[...slug]/page.tsx index 0f2b14af6..9d67b5324 100644 --- a/apps/cockpit/src/app/[...slug]/page.tsx +++ b/apps/cockpit/src/app/[...slug]/page.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { redirect } from 'next/navigation'; import { CockpitShell } from '../../components/cockpit-shell'; import { getContentBundle } from '../../lib/content-bundle'; @@ -27,6 +28,7 @@ export default async function CockpitRoutePage({ return ( = { Capability: true, Runtime: true, @@ -106,7 +105,7 @@ const seedMode = ( JSON.stringify({ version: 1, docs: { expanded: { Learn: true, Environment: false } }, - cockpit: { activeMode, expanded }, + cockpit: { expanded }, }) ); }; @@ -140,6 +139,11 @@ const renderShellFor = ( ); }; +// The Run rail item's accessible name carries the live runtime phase +// ('Run, runtime ready'), so these mode assertions match the label prefix +// instead of a name that depends on which phase the fixture happens to be in. +const RUN_RAIL_ITEM = /^Run(,|$)/; + const openActivity = () => { fireEvent.click(screen.getByRole('button', { name: /^Activity/ })); return screen.getByRole('heading', { @@ -168,22 +172,34 @@ describe('CockpitShell operational composition', () => { vi.restoreAllMocks(); }); - it('initializes from the saved Cockpit mode after hydration', async () => { - seedMode('Docs'); + it('always opens in Run, ignoring a stored activeMode from an older visit', async () => { + window.localStorage.setItem( + CONTROL_PLANE_STORAGE_KEY, + JSON.stringify({ + version: 1, + docs: { expanded: { Learn: true, Environment: false } }, + cockpit: { + activeMode: 'Code', + expanded: { Capability: true, Runtime: true }, + }, + }) + ); renderShell(); await waitFor(() => { expect( screen - .getByRole('button', { name: 'Docs' }) + .getByRole('button', { name: RUN_RAIL_ITEM }) .getAttribute('aria-pressed') ).toBe('true'); }); - expect(screen.getByRole('region', { name: 'Docs mode' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'Code' }).getAttribute('aria-pressed') + ).toBe('false'); }); - it('consumes a valid mode query once and persists it over the saved mode', async () => { - seedMode('Docs'); + it('consumes a valid mode query once and lands in that mode', async () => { + seedExpanded(); window.history.replaceState({}, '', '/?mode=code&keep=1'); renderShell(); @@ -195,28 +211,73 @@ describe('CockpitShell operational composition', () => { ).toBe('true'); }); expect(window.location.search).toBe('?keep=1'); - expect( - JSON.parse(window.localStorage.getItem(CONTROL_PLANE_STORAGE_KEY) ?? '{}') - .cockpit.activeMode - ).toBe('Code'); }); - it('ignores invalid mode queries and uses the saved mode', async () => { - seedMode('API'); + it('ignores invalid mode queries and falls back to Run', async () => { + seedExpanded(); window.history.replaceState({}, '', '/?mode=preview'); renderShell(); await waitFor(() => { expect( - screen.getByRole('button', { name: 'API' }).getAttribute('aria-pressed') + screen + .getByRole('button', { name: RUN_RAIL_ITEM }) + .getAttribute('aria-pressed') ).toBe('true'); }); + expect(window.location.search).toBe(''); + }); + + it('lands a newly navigated-to capability on Run even after switching to Code, when the shell remounts on the route key', async () => { + const { rerender } = render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Code' })); + await waitFor(() => { + expect( + screen + .getByRole('button', { name: 'Code' }) + .getAttribute('aria-pressed') + ).toBe('true'); + }); + + rerender( + + + + ); + + await waitFor(() => { + expect( + screen + .getByRole('button', { name: RUN_RAIL_ITEM }) + .getAttribute('aria-pressed') + ).toBe('true'); + }); + expect( + screen.getByRole('button', { name: 'Code' }).getAttribute('aria-pressed') + ).toBe('false'); }); it('owns one controller and one Activity store shared by desktop and mobile adapters', async () => { renderShell(); await waitFor(() => - expect(screen.getByRole('button', { name: 'Run' })).toBeTruthy() + expect(screen.getByRole('button', { name: RUN_RAIL_ITEM })).toBeTruthy() ); expect(operationalMocks.controllerInstances).toBe(1); @@ -232,6 +293,75 @@ describe('CockpitShell operational composition', () => { expect(operationalMocks.controllerInstances).toBe(1); }); + it('flags an unread runtime problem until Activity is opened, and survives recovery', async () => { + renderShell(); + await waitFor(() => + expect(operationalMocks.latestControllerOptions).not.toBeNull() + ); + + // Routine activity must not light the indicator. + act(() => { + operationalMocks.latestControllerOptions?.onActivity({ + id: 'ready-event', + at: '2026-08-31T17:00:00.000Z', + kind: 'runtime_ready', + capability: 'streaming', + }); + }); + expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy(); + + act(() => { + operationalMocks.latestControllerOptions?.onActivity({ + id: 'unresponsive-event', + at: '2026-08-31T17:01:00.000Z', + kind: 'runtime_unresponsive', + capability: 'streaming', + }); + }); + expect( + screen.getAllByRole('button', { name: 'Activity, 1 unread problem' }) + ).not.toHaveLength(0); + + // A self-recovering runtime clears the phase but not the unread problem. + act(() => { + operationalMocks.latestControllerOptions?.onActivity({ + id: 'recovered-event', + at: '2026-08-31T17:02:00.000Z', + kind: 'runtime_recovered', + capability: 'streaming', + }); + }); + expect( + screen.getAllByRole('button', { name: 'Activity, 1 unread problem' }) + ).not.toHaveLength(0); + + openActivity(); + expect( + screen.getAllByRole('button', { name: 'Activity' }) + ).not.toHaveLength(0); + + // Clearing the log must reset the marker too. If it did not, the marker + // would stay at N over an empty log and silently swallow the next N + // problems for the rest of the page visit. + fireEvent.click( + screen.getAllByRole('button', { name: 'Activity actions' })[0] + ); + fireEvent.click( + screen.getAllByRole('menuitem', { name: 'Clear session activity' })[0] + ); + act(() => { + operationalMocks.latestControllerOptions?.onActivity({ + id: 'post-clear-event', + at: '2026-08-31T17:03:00.000Z', + kind: 'runtime_unresponsive', + capability: 'streaming', + }); + }); + expect( + screen.getAllByRole('button', { name: 'Activity, 1 unread problem' }) + ).not.toHaveLength(0); + }); + it('does not reset drawer focus when shared operational state rerenders', async () => { renderShell(); await waitFor(() => @@ -477,7 +607,7 @@ describe('CockpitShell operational composition', () => { it('records one fixed Activity event and one existing analytics event only for an actual mode change', async () => { renderShell(); await waitFor(() => - expect(screen.getByRole('button', { name: 'Run' })).toBeTruthy() + expect(screen.getByRole('button', { name: RUN_RAIL_ITEM })).toBeTruthy() ); fireEvent.click(screen.getByRole('button', { name: 'Code' })); @@ -507,7 +637,7 @@ describe('CockpitShell operational composition', () => { }); it('reloads only the iframe while preserving shell state and session Activity', async () => { - seedMode('Run', { Capability: true, Runtime: true }); + seedExpanded({ Capability: true, Runtime: true }); renderShell('https://runtime.test/path?secret=hidden'); const firstFrame = await screen.findByTitle( 'LangGraph Streaming live example' @@ -528,7 +658,9 @@ describe('CockpitShell operational composition', () => { ) ); expect( - screen.getByRole('button', { name: 'Run' }).getAttribute('aria-pressed') + screen + .getByRole('button', { name: 'Run, runtime starting' }) + .getAttribute('aria-pressed') ).toBe('true'); expect(window.location.pathname).toBe(routeBefore); expect( @@ -553,7 +685,7 @@ describe('CockpitShell operational composition', () => { for (let index = 0; index < 22; index += 1) { fireEvent.click( screen.getByRole('button', { - name: index % 2 === 0 ? 'Code' : 'Run', + name: index % 2 === 0 ? 'Code' : 'Run, runtime starting', }) ); } diff --git a/apps/cockpit/src/components/cockpit-shell.tsx b/apps/cockpit/src/components/cockpit-shell.tsx index 5d9085834..3f3a286ca 100644 --- a/apps/cockpit/src/components/cockpit-shell.tsx +++ b/apps/cockpit/src/components/cockpit-shell.tsx @@ -29,6 +29,7 @@ import type { } from '../lib/analytics/events'; import { activityReducer, + countUnseenProblems, createSessionActivityEvent, type ActivityMode, type RuntimeActivityInput, @@ -159,9 +160,11 @@ export function CockpitShell({ const queryHandled = useRef(false); const mobileTriggerRef = useRef(null); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [activeMode, setActiveMode] = useState('Run'); const [isMobileOverlayPresent, setIsMobileOverlayPresent] = useState(false); const [activeUtility, setActiveUtility] = useState(null); const [activityOpenCycle, setActivityOpenCycle] = useState(0); + const [seenActivityCount, setSeenActivityCount] = useState(0); const [events, dispatchActivity] = useReducer(activityReducer, []); const isCapability = presentation.kind === 'capability'; const codeAssetPaths = isCapability ? presentation.codeAssetPaths : []; @@ -203,12 +206,12 @@ export function CockpitShell({ const docsUrl = resolveDocsUrl(presentation.docsPath); useEffect(() => { - if (!preferences.hydrated || queryHandled.current) return; + if (queryHandled.current) return; queryHandled.current = true; const url = new URL(window.location.href); const rawMode = url.searchParams.get('mode'); const requestedMode = parseControlPlaneMode(rawMode); - if (requestedMode) preferences.setActiveMode(requestedMode); + if (requestedMode) setActiveMode(requestedMode); if (rawMode !== null) { url.searchParams.delete('mode'); window.history.replaceState( @@ -217,15 +220,14 @@ export function CockpitShell({ url.pathname + url.search + url.hash ); } - }, [preferences]); + }, []); - const activeMode: ControlPlaneMode = preferences.activeMode; const isMobileModalActive = isSidebarOpen || isMobileOverlayPresent; const handleModeChange = useCallback( (mode: ControlPlaneMode) => { if (mode === activeMode) return; - preferences.setActiveMode(mode); + setActiveMode(mode); appendActivity( createLocalActivityInput(entry.topic, { kind: 'mode_changed', @@ -238,17 +240,18 @@ export function CockpitShell({ to_mode: MODE_ANALYTICS[mode], }); }, - [activeMode, appendActivity, entry.topic, preferences] + [activeMode, appendActivity, entry.topic] ); const handleActiveUtilityChange = useCallback( (utility: CockpitUtility) => { if (utility === 'activity' && activeUtility !== 'activity') { setActivityOpenCycle((cycle) => cycle + 1); + setSeenActivityCount(events.length); } setActiveUtility(utility); }, - [activeUtility] + [activeUtility, events.length] ); const closeMobileNavigation = useCallback(() => { @@ -319,6 +322,7 @@ export function CockpitShell({ const handleClearActivity = useCallback(() => { dispatchActivity({ type: 'clear' }); + setSeenActivityCount(0); }, []); const handleRecheck = useCallback(() => { @@ -392,6 +396,7 @@ export function CockpitShell({ activityOpenCycle, runtimeSnapshot: controller.snapshot, events, + unseenProblems: countUnseenProblems(events, seenActivityCount), expanded: preferences.expanded, onExpandedChange: preferences.setExpanded, onClearActivity: handleClearActivity, @@ -417,6 +422,7 @@ export function CockpitShell({ navigationTree, preferences.expanded, preferences.setExpanded, + seenActivityCount, ] ); diff --git a/apps/cockpit/src/components/control-plane/activity-panel.spec.tsx b/apps/cockpit/src/components/control-plane/activity-panel.spec.tsx index f08df83be..0c46eb5b2 100644 --- a/apps/cockpit/src/components/control-plane/activity-panel.spec.tsx +++ b/apps/cockpit/src/components/control-plane/activity-panel.spec.tsx @@ -176,7 +176,7 @@ describe('ActivityPanel', () => { /> ); expect( - screen.getByRole('list', { name: 'Activity, attention required' }) + screen.getByRole('list', { name: 'Activity, unread problems' }) ).toBeTruthy(); expect( document diff --git a/apps/cockpit/src/components/control-plane/activity-panel.tsx b/apps/cockpit/src/components/control-plane/activity-panel.tsx index 0deb6dcf9..ee06dba89 100644 --- a/apps/cockpit/src/components/control-plane/activity-panel.tsx +++ b/apps/cockpit/src/components/control-plane/activity-panel.tsx @@ -81,7 +81,7 @@ export function ActivityPanel({ formatTimestamp = defaultTimestamp, }: ActivityPanelProps) { const orderedEvents = [...events].sort(byNewest); - const label = attention ? 'Activity, attention required' : 'Activity'; + const label = attention ? 'Activity, unread problems' : 'Activity'; return ( diff --git a/apps/cockpit/src/components/control-plane/cockpit-control-plane.spec.tsx b/apps/cockpit/src/components/control-plane/cockpit-control-plane.spec.tsx index b8ee72649..ede89bde1 100644 --- a/apps/cockpit/src/components/control-plane/cockpit-control-plane.spec.tsx +++ b/apps/cockpit/src/components/control-plane/cockpit-control-plane.spec.tsx @@ -1,5 +1,8 @@ /** @vitest-environment jsdom */ import React, { useState } from 'react'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; import { fireEvent, render, screen, within } from '@testing-library/react'; import { cockpitManifest } from '@threadplane/cockpit-registry'; import { ThemeProvider, type ControlPlaneMode } from '@threadplane/ui-react'; @@ -17,6 +20,11 @@ import { type CockpitUtility, } from './cockpit-control-plane'; +const cockpitCss = readFileSync( + resolve(fileURLToPath(import.meta.url), '../../../app/cockpit.css'), + 'utf8' +); + const entry = cockpitManifest.find( (candidate) => candidate.product === 'langgraph' && @@ -92,6 +100,7 @@ const renderControlPlane = (overrides: HarnessOverrides = {}) => { activityOpenCycle={1} runtimeSnapshot={runtimeSnapshot('ready')} events={activity} + unseenProblems={0} expanded={{ Capability: true, Runtime: true }} onExpandedChange={onExpandedChange} {...actions} @@ -116,10 +125,15 @@ describe('CockpitControlPlane', () => { within(rail) .getAllByRole('button') .slice(0, 4) - .map((button) => button.textContent) + .map( + (button) => + button.querySelector('[data-control-plane-rail-label]')?.textContent + ) ).toEqual(['Docs', 'Run', 'Code', 'API']); expect( - screen.getByRole('button', { name: 'Run' }).getAttribute('aria-pressed') + screen + .getByRole('button', { name: 'Run, runtime ready' }) + .getAttribute('aria-pressed') ).toBe('true'); const pane = screen.getByRole('complementary', { @@ -143,27 +157,30 @@ describe('CockpitControlPlane', () => { expect(within(pane).queryByText('Actions')).toBeNull(); }); - it('renders Activity above Settings with a nonnumeric attention indicator that opening does not clear', () => { - renderControlPlane({ runtimeSnapshot: runtimeSnapshot('unresponsive') }); + it('flags unseen problems on Activity and clears them when the panel opens', () => { + renderControlPlane({ unseenProblems: 1 }); const rail = screen.getByRole('navigation', { name: 'Cockpit modes' }); const utilities = within(rail).getAllByRole('button').slice(4); expect( utilities.map((button) => button.getAttribute('aria-label')) - ).toEqual(['Activity, attention required', 'Settings']); + ).toEqual(['Activity, 1 unread problem', 'Settings']); expect( document.querySelector('[data-cockpit-activity-attention]')?.textContent ).toBe(''); + }); - fireEvent.click( - screen.getByRole('button', { - name: 'Activity, attention required', - }) - ); - expect(screen.getByRole('heading', { name: 'Activity' })).toBeTruthy(); + it('does not flag Activity when nothing has gone wrong', () => { + renderControlPlane({ unseenProblems: 0 }); + expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy(); + expect( + document.querySelector('[data-cockpit-activity-attention]') + ).toBeNull(); + }); + + it('pluralises the unseen problem count', () => { + renderControlPlane({ unseenProblems: 3 }); expect( - screen.getByRole('button', { - name: 'Activity, attention required', - }) + screen.getByRole('button', { name: 'Activity, 3 unread problems' }) ).toBeTruthy(); }); @@ -172,7 +189,9 @@ describe('CockpitControlPlane', () => { fireEvent.click(screen.getByRole('button', { name: 'Activity' })); expect(screen.getByRole('heading', { name: 'Activity' })).toBeTruthy(); expect( - screen.getByRole('button', { name: 'Run' }).getAttribute('aria-pressed') + screen + .getByRole('button', { name: 'Run, runtime ready' }) + .getAttribute('aria-pressed') ).toBe('true'); fireEvent.click(screen.getByRole('button', { name: 'Settings' })); @@ -223,4 +242,73 @@ describe('CockpitControlPlane', () => { fireEvent.click(screen.getByRole('button', { name: 'Recheck' })); expect(result.onRecheck).toHaveBeenCalledTimes(1); }); + + it('separates the mode group from the utilities and lifts resting contrast', () => { + // The utilities separator must resolve to --ds-border-strong, not + // --ds-border: in dark mode --ds-border (rgb(45,45,45)) sits one value + // away from --ds-surface-tinted, the rail background (rgb(44,44,44)), + // which is an invisible 1.01:1 hairline. --ds-border-strong is what the + // pane divider already uses for the same reason. This is a text + // assertion, not a rendered-contrast check -- jsdom's getComputedStyle + // does not resolve var(), so it can't verify the resolved colour, only + // that the correct token is referenced. + expect(cockpitCss).toMatch( + /\[data-control-plane-rail-group="utilities"\][^}]*border-top:\s*1px solid var\(--ds-border-strong\)/ + ); + expect(cockpitCss).not.toMatch( + /\[data-control-plane-rail-item\]\s*\{[^}]*--ds-text-muted/ + ); + }); + + it('puts the runtime phase on the Run rail item', () => { + renderControlPlane({ runtimeSnapshot: runtimeSnapshot('unresponsive') }); + const run = screen.getByRole('button', { name: 'Run, runtime error' }); + expect( + run + .querySelector('[data-control-plane-rail-status]') + ?.getAttribute('data-control-plane-rail-status') + ).toBe('error'); + }); + + it('shows no dot on Run when no runtime is configured', () => { + renderControlPlane({ + runtimeSnapshot: runtimeSnapshot('not_configured'), + }); + const run = screen.getByRole('button', { name: 'Run' }); + expect(run.querySelector('[data-control-plane-rail-status]')).toBeNull(); + }); + + it('keeps the status dot ring on the item background and visible in forced colors', () => { + // jsdom does not resolve var() in getComputedStyle, so this asserts the + // authored rules, not a rendered colour. The ring must track the rail + // item's own background: --ds-surface-tinted at rest, --ds-surface on + // hover, which in dark is rgb(28,28,28) inside the rail's rgb(44,44,44) + // -- a fixed ring would read as a lighter halo whenever Run is hovered. + expect(cockpitCss).toMatch( + /\[data-control-plane-rail-status\]\s*\{[^}]*border:\s*2px solid var\(--cockpit-rail-status-ring\)/ + ); + expect(cockpitCss).toMatch( + /\[data-control-plane-rail-item\]\s*\{[^}]*--cockpit-rail-status-ring:\s*var\(--ds-surface-tinted\)/ + ); + expect(cockpitCss).toMatch( + /\[data-control-plane-rail-item\]:hover\s*\{[^}]*--cockpit-rail-status-ring:\s*var\(--ds-surface\)/ + ); + // Forced colors overrides background, so the dot needs an explicit + // treatment like the runtime pill it sits beside. + expect( + cockpitCss.slice(cockpitCss.indexOf('@media (forced-colors: active)')) + ).toMatch( + /\[data-control-plane-rail-status\]\s*\{[^}]*border:\s*1px solid CanvasText/ + ); + }); + + it('names the mode group as an ARIA group announced to screen readers', () => { + renderControlPlane(); + const rail = screen.getByRole('navigation', { name: 'Cockpit modes' }); + const group = screen.getByRole('group', { name: 'View' }); + expect(rail.contains(group)).toBe(true); + const cap = group.querySelector('[data-control-plane-rail-group-label]'); + expect(cap?.textContent).toBe('View'); + expect(cap?.getAttribute('aria-hidden')).toBeNull(); + }); }); diff --git a/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx b/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx index a6f94a285..35a284e3f 100644 --- a/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx +++ b/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx @@ -22,7 +22,7 @@ import type { NavigationProduct } from '../../lib/route-resolution'; import { PRODUCT_LABELS } from '../../lib/navigation-labels'; import type { SessionActivityEvent } from '../../lib/runtime/session-activity'; import { - runtimeNeedsAttention, + runtimeRailStatus, type RuntimeSnapshot, } from '../../lib/runtime/runtime-state'; import { CockpitSidebar } from '../sidebar/cockpit-sidebar'; @@ -59,6 +59,7 @@ export interface CockpitControlPlaneProps { activityOpenCycle: number; runtimeSnapshot: RuntimeSnapshot; events: readonly SessionActivityEvent[]; + unseenProblems: number; expanded: Record; onExpandedChange(key: string, open: boolean): void; onClearActivity(): void; @@ -86,6 +87,7 @@ export function CockpitControlPlane({ activityOpenCycle, runtimeSnapshot, events, + unseenProblems, expanded, onExpandedChange, onClearActivity, @@ -99,8 +101,13 @@ export function CockpitControlPlane({ }: CockpitControlPlaneProps) { const activityRef = useRef(null); const settingsRef = useRef(null); - const attention = runtimeNeedsAttention(runtimeSnapshot.phase); - const activityLabel = attention ? 'Activity, attention required' : 'Activity'; + const railStatus = runtimeRailStatus(runtimeSnapshot.phase); + const attention = unseenProblems > 0; + const activityLabel = attention + ? `Activity, ${unseenProblems} unread problem${ + unseenProblems === 1 ? '' : 's' + }` + : 'Activity'; const product = PRODUCT_LABELS[entry.product] ?? entry.product; const language = entry.language === 'typescript' ? 'TypeScript' : 'Python'; @@ -197,6 +204,7 @@ export function CockpitControlPlane({ > (