diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx index c58e38f284..69968eefc7 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, type ReactNode } from 'react'; import { Icon } from '@openbitfun/ui'; import { CircleAlert, CircleCheck, CirclePause, CircleStop, CloudOff, Hand, Loader2, MessageCircleQuestion } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; @@ -17,7 +17,18 @@ const presentation = { syncing: { glyph: CloudOff, tone: 'secondary', label: 'nav.sessions.status.syncing' }, } as const; -export const SessionStatusIndicator = memo(function SessionStatusIndicator({ sessionId }: { sessionId: string }) { +export const SessionStatusIndicator = memo(function SessionStatusIndicator({ + sessionId, + idleFallback, +}: { + sessionId: string; + /** + * Rendered in the same trailing cell while the session has nothing to report, + * so a second row mark can sit on the row's right edge without claiming a + * second cell. The cell still yields to the row menu on hover or focus. + */ + idleFallback?: ReactNode; +}) { const { t } = useI18n('common'); const status = useSessionNavStatus(sessionId); const appearance = status.kind === 'idle' ? null : presentation[status.kind]; @@ -27,6 +38,14 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ ses : t(appearance.label) : ''; + if (!appearance && idleFallback) { + return ( + + {idleFallback} + + ); + } + return appearance ? ( = ({ const backgroundSubagentActivityCount = backgroundSubagentActivity?.totalCount ?? 0; const showBackgroundSubagentActivity = !isChildSession && backgroundSubagentActivityCount > 0; const scheduledJobCount = cronJobCountsBySession.get(session.sessionId) ?? 0; + // Same mark the session status indicator draws, in the same trailing + // cell: one 12px secondary clock on the row's right edge, no count. + // Sessions with a status to report keep that status instead. + const scheduledJobMark = scheduledJobCount > 0 ? ( + + ) : undefined; const parentSessionId = relationship.parentSessionId; const parentSession = parentSessionId ? flowChatState.sessions.get(parentSessionId) : undefined; const parentTitle = parentSession ? resolveSessionTitle(parentSession) : ''; @@ -1724,15 +1735,6 @@ const SessionsSection: React.FC = ({ {dispatchPresentation?.badgeLabel} ) : null} - {scheduledJobCount > 0 ? ( - - - {scheduledJobCount} - - ) : null} {reviewActivityKind ? ( @@ -1770,7 +1772,10 @@ const SessionsSection: React.FC = ({
- +
{ const btwBadgeBlock = extractInlineItemBlock(stylesheet, 'btw-badge'); const reviewBadgeBlock = extractInlineItemBlock(stylesheet, 'review-badge'); const backgroundSubagentBadgeBlock = extractInlineItemBlock(stylesheet, 'background-subagent-badge'); - // Shared by session rows and workspace rows, so it must hold its slot width. - const cronBadgeBlock = extractInlineItemBlock(stylesheet, 'cron-badge'); expect(labelBlock).toContain('flex: 1 1 0;'); expect(labelBlock).toContain('overflow: hidden;'); @@ -191,10 +189,13 @@ describe('SessionsSection layout styles', () => { expect(backgroundSubagentBadgeBlock).toContain('line-height: 0;'); expect(backgroundSubagentBadgeBlock).toContain('width: var(--openbitfun-control-icon-size-md);'); expect(backgroundSubagentBadgeBlock).toContain('height: var(--openbitfun-control-icon-size-md);'); - expect(cronBadgeBlock).toContain('flex: 0 0 auto;'); - expect(cronBadgeBlock).toContain('display: inline-flex;'); - expect(cronBadgeBlock).toContain('align-items: center;'); - expect(cronBadgeBlock).toContain('white-space: nowrap;'); + // The scheduled-job mark is not a chip beside the title: it is one 12px + // secondary clock in the trailing cell, the same drawing and slot the session + // status indicator uses, so no `cron` rule may come back to the stylesheet. + expect(stylesheet).not.toContain('cron'); + expect(readSessionsSectionSource()).toContain('idleFallback={scheduledJobMark}'); + expect(readSessionsSectionSource()).toMatch(/ = ({ }) => { const { t } = useI18n('common'); const { t: tFiles } = useTranslation('panels/files'); - useEffect(() => { ensureCronJobCountsListener(); }, []); - const cronJobCounts = useSyncExternalStore( - subscribeCronJobCounts, - getCronJobCountsSnapshot, - getCronJobCountsSnapshot, - ); - const scheduledJobCount = cronJobCounts.byWorkspaceId.get(workspace.id) ?? 0; - // Rendered through ActionItem's trailing slot so both name rows keep the badge on - // the name line. The rows flatten that label slot with `display: contents`, so a - // badge appended to the label children would silently break if that rule changed. - const scheduledJobBadge = scheduledJobCount > 0 ? ( - - - {scheduledJobCount} - - ) : undefined; const { setActiveWorkspace, closeWorkspaceById, @@ -878,7 +854,6 @@ const WorkspaceItem: React.FC = ({ className="openbitfun-nav-panel__assistant-item-name-action" triggerClassName="openbitfun-nav-panel__assistant-item-name-btn" labelBehavior="static" - metadata={scheduledJobBadge} onClick={e => { e.stopPropagation(); handleCollapseToggle(); }} data-testid="nav-workspace-name-btn" data-workspace-id={workspace.id} @@ -1185,7 +1160,6 @@ const WorkspaceItem: React.FC = ({ className="openbitfun-nav-panel__workspace-item-name-action" triggerClassName="openbitfun-nav-panel__workspace-item-name-btn" labelBehavior="static" - metadata={scheduledJobBadge} onClick={e => { e.stopPropagation(); handleCollapseToggle(); }} data-testid="nav-workspace-name-btn" data-workspace-id={workspace.id} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts index d7d50ce78f..2ecd6a2dd9 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts @@ -159,17 +159,19 @@ describe('WorkspaceListSection layout styles', () => { expect(source.match(/data-testid="nav-workspace-new-session-btn"/g)).toHaveLength(2); }); - it('keeps the scheduled-job badge on the trailing slot of both workspace rows', () => { + it('keeps the scheduled-job mark off workspace rows', () => { const source = readWorkspaceItemSource(); - // The workspace row and the assistant-group row both flatten their ActionItem - // label slot with `display: contents`, so the badge must stay one flex sibling of - // the name and be rendered by each variant from a single source. - expect(source).toContain('const scheduledJobBadge = scheduledJobCount > 0 ? ('); - expect(source.match(/metadata=\{scheduledJobBadge\}/g)).toHaveLength(2); - expect(source).not.toContain('{scheduledJobCount > 0 ?'); - expect(source).toContain('className="openbitfun-nav-panel__inline-item-cron-badge"'); - expect(source).toContain("title={t('nav.scheduledJobs.badgeTooltip', { count: scheduledJobCount })}"); + // A workspace row owns no session, so the scheduled-job count it used to + // borrow from its sessions belongs on the session rows only. The workspace + // row must not subscribe to the counts store or claim the name row's + // trailing metadata slot for it. + expect(source).not.toContain('cronJobCountsStore'); + expect(source).not.toContain('scheduledJobCount'); + expect(source).not.toContain('scheduledJobBadge'); + expect(source).not.toContain('inline-item-cron'); + expect(source).not.toContain('nav.scheduledJobs.badgeTooltip'); + expect(source).not.toContain('metadata={scheduledJobBadge}'); }); it('keeps workspace and assistant rows flat on hover', () => {