mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 06:23:18 +00:00
feat(ui): align logs page visual language with dashboard markers
Logs page was visually disconnected from the rest of the dashboard. Health uses ornamental `HEALTH.ATTENTIONREQUIRED` markers, Home uses `LIVE Account Monitor` + stat cards. Logs shipped with a 14px h1 + clinical table that felt like a different product. - Header now opens with a `LOGS.STREAM` mono-uppercase marker matching the dashboard's `HEALTH.X` style, plus a 16px "Live activity" title and a status pill. - Below the header, a stat strip mirrors the home page's monitor layout: ENTRIES / TRACES / ERRORS counters, errors highlighted red when >0. - Trace row + child row font sizes lifted from 11px to 12-13px; request-id column widened to 112px to match the standalone-row table. - Stage-hint fallback derived from event names so the trace timeline still renders meaningful chips when backend entries lack an explicit `stage` field (e.g. dashboard self-polling). - Intra-trace coalesce: identical consecutive child rows collapse to a single row with `× N` badge so a 149-stage self-poll trace renders as 3 rows of signal instead of 149 rows of noise. Refs #1138, #1141, #1142
This commit is contained in:
@@ -6,10 +6,96 @@ const LEVEL_RANK: Record<LogsLevel, number> = { debug: 0, info: 1, warn: 2, erro
|
|||||||
export interface LeafItem {
|
export interface LeafItem {
|
||||||
kind: 'leaf';
|
kind: 'leaf';
|
||||||
entry: LogsEntry;
|
entry: LogsEntry;
|
||||||
|
/**
|
||||||
|
* When >1, this leaf represents N consecutive identical entries that have
|
||||||
|
* been coalesced. Row renderer shows a ×N badge so dashboard self-polling
|
||||||
|
* floods don't drown real signal.
|
||||||
|
*/
|
||||||
|
repeatCount?: number;
|
||||||
|
collapsedRange?: { fromTs: string; toTs: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DerivedItem = LeafItem | TraceGroup;
|
export type DerivedItem = LeafItem | TraceGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tuple key for coalescing: two entries are "same enough" to collapse when
|
||||||
|
* (event, module, level, requestId, source) all match.
|
||||||
|
*/
|
||||||
|
function coalesceKey(entry: LogsEntry): string {
|
||||||
|
return [
|
||||||
|
entry.event ?? '',
|
||||||
|
entry.module ?? entry.source ?? '',
|
||||||
|
entry.level,
|
||||||
|
entry.requestId ?? '',
|
||||||
|
entry.source ?? '',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesce a list of entries: any run of consecutive entries with identical
|
||||||
|
* `coalesceKey` collapses to a single leaf with `repeatCount` set.
|
||||||
|
*
|
||||||
|
* Conservative — only collapses *adjacent* duplicates. Mixing different
|
||||||
|
* events between repeats prevents collapsing on purpose: hiding interleaved
|
||||||
|
* signal would be worse than the noise.
|
||||||
|
*/
|
||||||
|
function coalesceLeaves(entries: LogsEntry[]): LeafItem[] {
|
||||||
|
if (entries.length === 0) return [];
|
||||||
|
const out: LeafItem[] = [];
|
||||||
|
let runHead: LogsEntry | null = null;
|
||||||
|
let runCount = 0;
|
||||||
|
let runFromTs = '';
|
||||||
|
let runToTs = '';
|
||||||
|
let runKey = '';
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!runHead) return;
|
||||||
|
if (runCount === 1) {
|
||||||
|
out.push({ kind: 'leaf', entry: runHead });
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
kind: 'leaf',
|
||||||
|
entry: runHead,
|
||||||
|
repeatCount: runCount,
|
||||||
|
collapsedRange: { fromTs: runFromTs, toTs: runToTs },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
runHead = null;
|
||||||
|
runCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = coalesceKey(entry);
|
||||||
|
if (runHead && key === runKey) {
|
||||||
|
runCount += 1;
|
||||||
|
runToTs = entry.timestamp;
|
||||||
|
} else {
|
||||||
|
flush();
|
||||||
|
runHead = entry;
|
||||||
|
runCount = 1;
|
||||||
|
runFromTs = entry.timestamp;
|
||||||
|
runToTs = entry.timestamp;
|
||||||
|
runKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For an entry without an explicit `stage` field, derive a short chip label
|
||||||
|
* from the event name so the trace timeline still renders meaningful badges
|
||||||
|
* instead of empty pills. Last `.`-segment, capped at 12 chars.
|
||||||
|
*/
|
||||||
|
export function deriveStageHint(entry: LogsEntry): string | undefined {
|
||||||
|
if (entry.stage && entry.stage.length > 0) return entry.stage;
|
||||||
|
if (entry.event && entry.event.length > 0) {
|
||||||
|
const last = entry.event.split('.').pop() ?? entry.event;
|
||||||
|
return last.slice(0, 12);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure helper: derive a flat list of either standalone leaves or trace groups
|
* Pure helper: derive a flat list of either standalone leaves or trace groups
|
||||||
* (entries sharing a `requestId`). Stable, single-pass, O(n).
|
* (entries sharing a `requestId`). Stable, single-pass, O(n).
|
||||||
@@ -17,20 +103,26 @@ export type DerivedItem = LeafItem | TraceGroup;
|
|||||||
* Sort within a group is `ts asc` (defensive — backend may not guarantee it).
|
* Sort within a group is `ts asc` (defensive — backend may not guarantee it).
|
||||||
* Group ts = min child ts, used to slot the group amongst standalone leaves.
|
* Group ts = min child ts, used to slot the group amongst standalone leaves.
|
||||||
* Standalone leaves keep their original `ts` slot — never silently dropped.
|
* Standalone leaves keep their original `ts` slot — never silently dropped.
|
||||||
|
*
|
||||||
|
* Standalone leaves are coalesced: consecutive identical entries collapse
|
||||||
|
* into a single leaf with `repeatCount` so dashboard self-polling floods
|
||||||
|
* don't drown out real signal.
|
||||||
*/
|
*/
|
||||||
export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] {
|
export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] {
|
||||||
const groups = new Map<string, LogsEntry[]>();
|
const groups = new Map<string, LogsEntry[]>();
|
||||||
const leaves: LeafItem[] = [];
|
const rawLeaves: LogsEntry[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.requestId) {
|
if (entry.requestId) {
|
||||||
const bucket = groups.get(entry.requestId);
|
const bucket = groups.get(entry.requestId);
|
||||||
if (bucket) bucket.push(entry);
|
if (bucket) bucket.push(entry);
|
||||||
else groups.set(entry.requestId, [entry]);
|
else groups.set(entry.requestId, [entry]);
|
||||||
} else {
|
} else {
|
||||||
leaves.push({ kind: 'leaf', entry });
|
rawLeaves.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const leaves = coalesceLeaves(rawLeaves);
|
||||||
|
|
||||||
const groupItems: TraceGroup[] = [];
|
const groupItems: TraceGroup[] = [];
|
||||||
for (const [requestId, children] of groups) {
|
for (const [requestId, children] of groups) {
|
||||||
const sorted = [...children].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
const sorted = [...children].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
import { RefreshCw, Settings, ScrollText, HelpCircle } from 'lucide-react';
|
import { RefreshCw, Settings, ScrollText, HelpCircle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FOCUS_RING } from './tokens';
|
import { FOCUS_RING, MONO_NUMERIC } from './tokens';
|
||||||
|
|
||||||
|
export interface LogsHeaderStats {
|
||||||
|
entries: number;
|
||||||
|
traces: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LogsHeaderProps {
|
export interface LogsHeaderProps {
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
capturedCount: number;
|
capturedCount: number;
|
||||||
|
stats?: LogsHeaderStats;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onOpenShortcuts: () => void;
|
onOpenShortcuts: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calm 48px header for the logs surface.
|
* Logs page header.
|
||||||
* - Title + icon on the left
|
|
||||||
* - Live connection pill in the middle (idle/syncing/error)
|
|
||||||
* - Refresh + settings buttons on the right
|
|
||||||
*
|
*
|
||||||
* No glows, no decorative captions, no scale animations.
|
* Two rows:
|
||||||
|
* 1. Title bar (`LOGS.STREAM` ornamental marker matching dashboard's
|
||||||
|
* `HEALTH.X` design language) + status pill + actions.
|
||||||
|
* 2. Stat strip — entries / traces / errors counters mirroring the home
|
||||||
|
* page's `LIVE Account Monitor` card grid.
|
||||||
|
*
|
||||||
|
* Visual unity is the goal here, not minimalism for its own sake.
|
||||||
*/
|
*/
|
||||||
export function LogsHeader({
|
export function LogsHeader({
|
||||||
isFetching,
|
isFetching,
|
||||||
hasError,
|
hasError,
|
||||||
capturedCount,
|
capturedCount,
|
||||||
|
stats,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenShortcuts,
|
onOpenShortcuts,
|
||||||
@@ -35,56 +46,102 @@ export function LogsHeader({
|
|||||||
status === 'error' ? 'bg-red-500' : status === 'syncing' ? 'bg-amber-500' : 'bg-emerald-500';
|
status === 'error' ? 'bg-red-500' : status === 'syncing' ? 'bg-amber-500' : 'bg-emerald-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4">
|
<div className="shrink-0 border-b border-border bg-background">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex h-14 items-center justify-between gap-4 px-4">
|
||||||
<ScrollText className="h-4 w-4 text-foreground/70" aria-hidden="true" />
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-sm font-semibold tracking-tight text-foreground">Logs</h1>
|
<ScrollText className="h-4 w-4 text-foreground/70" aria-hidden="true" />
|
||||||
<span
|
<span className="text-[11px] font-mono uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
role="status"
|
LOGS.STREAM
|
||||||
className="ml-3 inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] font-medium text-foreground/70"
|
</span>
|
||||||
>
|
<h1 className="text-base font-semibold tracking-tight text-foreground">Live activity</h1>
|
||||||
<span className={cn('h-1.5 w-1.5 rounded-full', dotClass)} aria-hidden="true" />
|
<span
|
||||||
{statusLabel}
|
role="status"
|
||||||
</span>
|
aria-live="polite"
|
||||||
<span className="text-[11px] font-medium tabular-nums text-muted-foreground">
|
className="ml-2 inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[12px] font-medium text-foreground/80"
|
||||||
{capturedCount} entries
|
>
|
||||||
</span>
|
<span className={cn('h-1.5 w-1.5 rounded-full', dotClass)} aria-hidden="true" />
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label="Refresh logs"
|
||||||
|
className={cn('h-8 gap-2 px-2.5 text-xs font-medium', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onOpenShortcuts}
|
||||||
|
aria-label="Show keyboard shortcuts"
|
||||||
|
title="Keyboard shortcuts (?)"
|
||||||
|
className={cn('h-8 w-8', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
aria-label="Open logging settings"
|
||||||
|
className={cn('h-8 w-8', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Stat strip — visual unity with home page's `LIVE Account Monitor`. */}
|
||||||
<Button
|
<div className="flex items-center gap-6 border-t border-border/60 bg-muted/20 px-4 py-2">
|
||||||
variant="ghost"
|
<Stat label="Entries" value={capturedCount} fallback={`${capturedCount}`} />
|
||||||
size="sm"
|
{stats ? (
|
||||||
onClick={onRefresh}
|
<>
|
||||||
aria-label="Refresh logs"
|
<Stat label="Traces" value={stats.traces} fallback={`${stats.traces}`} />
|
||||||
className={cn('h-8 gap-2 px-2 text-xs font-medium', FOCUS_RING)}
|
<Stat
|
||||||
>
|
label="Errors"
|
||||||
<RefreshCw
|
value={stats.errors}
|
||||||
className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')}
|
fallback={`${stats.errors}`}
|
||||||
aria-hidden="true"
|
tone={stats.errors > 0 ? 'error' : 'neutral'}
|
||||||
/>
|
/>
|
||||||
Refresh
|
</>
|
||||||
</Button>
|
) : null}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onOpenShortcuts}
|
|
||||||
aria-label="Show keyboard shortcuts"
|
|
||||||
title="Keyboard shortcuts (?)"
|
|
||||||
className={cn('h-8 w-8', FOCUS_RING)}
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
aria-label="Open logging settings"
|
|
||||||
className={cn('h-8 w-8', FOCUS_RING)}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StatProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
fallback: string;
|
||||||
|
tone?: 'neutral' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, fallback, tone = 'neutral' }: StatProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[14px] font-semibold tabular-nums',
|
||||||
|
tone === 'error' ? 'text-red-600 dark:text-red-400' : 'text-foreground',
|
||||||
|
MONO_NUMERIC
|
||||||
|
)}
|
||||||
|
title={String(value)}
|
||||||
|
>
|
||||||
|
{fallback}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { memo } from 'react';
|
|||||||
import { ChevronDown, ChevronRight, GitBranch } from 'lucide-react';
|
import { ChevronDown, ChevronRight, GitBranch } from 'lucide-react';
|
||||||
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { deriveStageHint } from './derive-trace-groups';
|
||||||
import { LogLevelBadge } from './log-level-badge';
|
import { LogLevelBadge } from './log-level-badge';
|
||||||
import { LogsRow } from './logs-row';
|
import { LogsRow } from './logs-row';
|
||||||
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
||||||
@@ -38,6 +39,37 @@ function formatHms(timestamp: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesce identical consecutive children inside a single trace. Same key
|
||||||
|
* tuple as standalone-leaf coalescing — `(event, stage, level)` since all
|
||||||
|
* children share `requestId` already.
|
||||||
|
*/
|
||||||
|
function coalesceChildren(children: LogsEntry[]): Array<{ head: LogsEntry; count: number }> {
|
||||||
|
if (children.length === 0) return [];
|
||||||
|
const out: Array<{ head: LogsEntry; count: number }> = [];
|
||||||
|
let head: LogsEntry | null = null;
|
||||||
|
let count = 0;
|
||||||
|
let key = '';
|
||||||
|
const flush = () => {
|
||||||
|
if (head) out.push({ head, count });
|
||||||
|
head = null;
|
||||||
|
count = 0;
|
||||||
|
};
|
||||||
|
for (const child of children) {
|
||||||
|
const k = `${child.event ?? ''}|${child.stage ?? ''}|${child.level}|${child.module ?? ''}`;
|
||||||
|
if (head && k === key) {
|
||||||
|
count += 1;
|
||||||
|
} else {
|
||||||
|
flush();
|
||||||
|
head = child;
|
||||||
|
count = 1;
|
||||||
|
key = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function LogsTraceRowImpl({
|
function LogsTraceRowImpl({
|
||||||
group,
|
group,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
@@ -66,7 +98,7 @@ function LogsTraceRowImpl({
|
|||||||
<span
|
<span
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[88px] shrink-0 truncate text-[11px] text-muted-foreground',
|
'w-[88px] shrink-0 truncate text-[12px] text-muted-foreground',
|
||||||
MONO_NUMERIC
|
MONO_NUMERIC
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -77,18 +109,18 @@ function LogsTraceRowImpl({
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
role="cell"
|
role="cell"
|
||||||
className="flex w-[140px] shrink-0 items-center gap-1.5 truncate text-[11px] font-medium text-foreground/80"
|
className="flex w-[140px] shrink-0 items-center gap-1.5 truncate text-[12px] font-medium text-foreground/80"
|
||||||
>
|
>
|
||||||
<GitBranch className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
|
<GitBranch className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
|
||||||
{group.module}
|
{group.module}
|
||||||
</span>
|
</span>
|
||||||
<span role="cell" className="min-w-0 flex-1 truncate text-foreground/90">
|
<span role="cell" className="min-w-0 flex-1 truncate text-[13px] text-foreground/90">
|
||||||
<span className="text-muted-foreground">trace · {group.children.length} stages</span>
|
<span className="text-muted-foreground">trace · {group.children.length} stages</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden w-[72px] shrink-0 truncate text-right text-[11px] text-muted-foreground sm:inline-block',
|
'hidden w-[72px] shrink-0 truncate text-right text-[12px] text-muted-foreground sm:inline-block',
|
||||||
MONO_NUMERIC
|
MONO_NUMERIC
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -97,7 +129,7 @@ function LogsTraceRowImpl({
|
|||||||
<span
|
<span
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden w-[88px] shrink-0 truncate text-right text-[11px] text-muted-foreground lg:inline-block',
|
'hidden w-[112px] shrink-0 truncate text-right text-[12px] text-muted-foreground lg:inline-block',
|
||||||
MONO_NUMERIC
|
MONO_NUMERIC
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -105,16 +137,17 @@ function LogsTraceRowImpl({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? group.children.map((child) => (
|
? coalesceChildren(group.children).map((run) => (
|
||||||
<LogsRow
|
<LogsRow
|
||||||
key={child.id}
|
key={run.head.id}
|
||||||
entry={child}
|
entry={run.head}
|
||||||
isSelected={child.id === selectedEntryId}
|
isSelected={run.head.id === selectedEntryId}
|
||||||
density={density}
|
density={density}
|
||||||
sourceLabel={sourceLabel}
|
sourceLabel={sourceLabel}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
indent={20}
|
indent={20}
|
||||||
stageHint={child.stage}
|
stageHint={deriveStageHint(run.head)}
|
||||||
|
repeatCount={run.count > 1 ? run.count : undefined}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
|||||||
Reference in New Issue
Block a user