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', () => {