From d5b14bec1c48b2f97c2fbadcc39124c5c54ddec8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 13:37:02 -0400 Subject: [PATCH 01/17] fix(ui): align logs row and detail panel via shared accessors Both surfaces now consume `getDisplayModule / getDisplayStage / getDisplayRequestId / getDisplayLatency` from `utils.ts`, replacing the previous divergence where the list row fell back to `source` while the detail panel showed em-dash placeholders for the same entry. Also widens the request-id column in the row table from 88px to 112px and adds a hover-revealed clipboard icon for one-click copy of the full requestId. List header bumped to 12px sans-serif uppercase + 9h height for legibility. Refs #1138, #1141, #1142 --- ui/src/components/logs/logs-detail-panel.tsx | 24 ++++--- ui/src/components/logs/logs-entry-list.tsx | 5 +- ui/src/components/logs/logs-row.tsx | 76 ++++++++++++++++---- ui/src/components/logs/utils.ts | 36 +++++++++- 4 files changed, 115 insertions(+), 26 deletions(-) diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx index 9f977127..09c68564 100644 --- a/ui/src/components/logs/logs-detail-panel.tsx +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -7,7 +7,13 @@ import type { LogsEntry } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { LogLevelBadge } from './log-level-badge'; import { LogsEmpty } from './logs-empty'; -import { formatJson } from './utils'; +import { + formatJson, + getDisplayLatency, + getDisplayModule, + getDisplayRequestId, + getDisplayStage, +} from './utils'; import { FOCUS_RING, MONO_NUMERIC } from './tokens'; export interface LogsDetailPanelProps { @@ -26,19 +32,15 @@ interface OverviewRow { } function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] { + // Use shared accessors so this panel and the list row never diverge. return [ { label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true }, { label: 'Level', value: entry.level }, - { label: 'Module', value: entry.module ?? '—' }, - { label: 'Stage', value: entry.stage ?? '—' }, - { label: 'Request ID', value: entry.requestId ?? '—', mono: true }, - { - label: 'Latency', - value: - entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : '—', - mono: true, - }, - { label: 'Source', value: sourceLabel ?? entry.source }, + { label: 'Module', value: getDisplayModule(entry, sourceLabel) }, + { label: 'Stage', value: getDisplayStage(entry) }, + { label: 'Request ID', value: getDisplayRequestId(entry), mono: true }, + { label: 'Latency', value: getDisplayLatency(entry), mono: true }, + { label: 'Source', value: sourceLabel ?? entry.source ?? '—' }, { label: 'Run ID', value: entry.runId ?? '—', mono: true }, { label: 'Process ID', value: entry.processId ?? '—', mono: true }, ]; diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx index b03687e8..b12c0b24 100644 --- a/ui/src/components/logs/logs-entry-list.tsx +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -25,7 +25,7 @@ export interface LogsEntryListProps { } const COLS_TEMPLATE = - 'grid grid-cols-[88px_64px_140px_minmax(0,1fr)_72px_88px] items-center gap-3 px-3'; + 'grid grid-cols-[88px_64px_140px_minmax(0,1fr)_72px_112px] items-center gap-3 px-3'; export function LogsEntryList({ entries, @@ -85,6 +85,7 @@ export function LogsEntryList({ density={density} sourceLabel={sourceLabels[item.entry.source] ?? item.entry.source} onSelect={onSelect} + repeatCount={item.repeatCount} /> ); }, @@ -98,7 +99,7 @@ export function LogsEntryList({ role="row" className={cn( COLS_TEMPLATE, - 'h-8 shrink-0 border-b border-border bg-muted/30 text-[10px] font-medium uppercase tracking-wide text-muted-foreground' + 'h-9 shrink-0 border-b border-border bg-muted/30 text-[12px] font-medium uppercase tracking-wide text-muted-foreground' )} > Time diff --git a/ui/src/components/logs/logs-row.tsx b/ui/src/components/logs/logs-row.tsx index 8889fe43..74c5d020 100644 --- a/ui/src/components/logs/logs-row.tsx +++ b/ui/src/components/logs/logs-row.tsx @@ -1,8 +1,10 @@ -import { memo } from 'react'; +import { memo, useState } from 'react'; +import { Copy } from 'lucide-react'; import type { LogsEntry } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { LogLevelBadge } from './log-level-badge'; import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens'; +import { getDisplayLatency, getDisplayModule, getDisplayRequestId } from './utils'; export interface LogsRowProps { entry: LogsEntry; @@ -14,6 +16,8 @@ export interface LogsRowProps { indent?: number; /** Optional stage chip text (e.g. for child rows of a trace). */ stageHint?: string; + /** Optional repeat counter — when the row coalesces N identical consecutive entries. */ + repeatCount?: number; } function formatHms(timestamp: string): string { @@ -27,6 +31,14 @@ function formatHms(timestamp: string): string { }); } +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + // best-effort; clipboard may be unavailable in non-secure contexts + } +} + function LogsRowImpl({ entry, isSelected, @@ -35,7 +47,21 @@ function LogsRowImpl({ onSelect, indent = 0, stageHint, + repeatCount, }: LogsRowProps) { + const [justCopied, setJustCopied] = useState(false); + const moduleLabel = getDisplayModule(entry, sourceLabel); + const latencyLabel = getDisplayLatency(entry); + const shortRequestId = getDisplayRequestId(entry, { short: true }); + + const handleCopyRequestId = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!entry.requestId) return; + await copyText(entry.requestId); + setJustCopied(true); + window.setTimeout(() => setJustCopied(false), 1200); + }; + return ( ); @@ -107,6 +158,7 @@ export const LogsRow = memo(LogsRowImpl, (prev, next) => { prev.density === next.density && prev.indent === next.indent && prev.stageHint === next.stageHint && - prev.sourceLabel === next.sourceLabel + prev.sourceLabel === next.sourceLabel && + prev.repeatCount === next.repeatCount ); }); diff --git a/ui/src/components/logs/utils.ts b/ui/src/components/logs/utils.ts index 90de8d1b..1c77dbf3 100644 --- a/ui/src/components/logs/utils.ts +++ b/ui/src/components/logs/utils.ts @@ -1,4 +1,4 @@ -import type { LogsLevel } from '@/lib/api-client'; +import type { LogsEntry, LogsLevel } from '@/lib/api-client'; // NOTE: This module contains utility functions that are not directly i18n-aware. // String literals here ("No activity yet", "Error", etc.) are used as fallbacks // and defaults in non-component contexts. Components consuming these values @@ -80,3 +80,37 @@ export function getLevelLabel(level: LogsLevel) { return 'Debug'; } } + +// Field accessors shared by list row + detail panel. +// Without these, list and detail diverged — list fell back to source/sourceLabel +// while detail showed `—` for the same entry. Single source of truth. + +export function getDisplayModule(entry: LogsEntry, sourceLabel?: string): string { + return entry.module ?? sourceLabel ?? entry.source ?? '—'; +} + +export function getDisplayStage(entry: LogsEntry): string { + return entry.stage ?? '—'; +} + +export function getDisplayRequestId(entry: LogsEntry, options: { short?: boolean } = {}): string { + if (!entry.requestId) return '—'; + return options.short ? entry.requestId.slice(-8) : entry.requestId; +} + +export function getDisplayLatency(entry: LogsEntry): string { + if (entry.latencyMs === undefined || entry.latencyMs === null) return '—'; + return `${entry.latencyMs}ms`; +} + +/** + * Default filter pattern: dashboard self-polling sources start with `web-server:`. + * UI applies as a default exclusion to keep the logs view focused on signal, + * not the dashboard observing itself. + */ +export const DASHBOARD_INTERNALS_PATTERN = /^web-server:/i; + +export function isDashboardInternal(entry: LogsEntry): boolean { + if (!entry.source) return false; + return DASHBOARD_INTERNALS_PATTERN.test(entry.source); +} From e04598eef777746d1898d6ddc22eb527c9badcd3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 14:30:54 -0400 Subject: [PATCH 02/17] feat(ui): align logs page visual language with dashboard markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/components/logs/derive-trace-groups.ts | 96 ++++++++++- ui/src/components/logs/logs-header.tsx | 163 ++++++++++++------ ui/src/components/logs/logs-trace-row.tsx | 53 ++++-- 3 files changed, 247 insertions(+), 65 deletions(-) diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index 4638e6b1..d1e9059f 100644 --- a/ui/src/components/logs/derive-trace-groups.ts +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -6,10 +6,96 @@ const LEVEL_RANK: Record = { debug: 0, info: 1, warn: 2, erro export interface LeafItem { kind: 'leaf'; 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; +/** + * 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 * (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). * 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 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[] { const groups = new Map(); - const leaves: LeafItem[] = []; + const rawLeaves: LogsEntry[] = []; for (const entry of entries) { if (entry.requestId) { const bucket = groups.get(entry.requestId); if (bucket) bucket.push(entry); else groups.set(entry.requestId, [entry]); } else { - leaves.push({ kind: 'leaf', entry }); + rawLeaves.push(entry); } } + const leaves = coalesceLeaves(rawLeaves); + const groupItems: TraceGroup[] = []; for (const [requestId, children] of groups) { const sorted = [...children].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); diff --git a/ui/src/components/logs/logs-header.tsx b/ui/src/components/logs/logs-header.tsx index 4eee6979..e403b905 100644 --- a/ui/src/components/logs/logs-header.tsx +++ b/ui/src/components/logs/logs-header.tsx @@ -1,29 +1,40 @@ import { RefreshCw, Settings, ScrollText, HelpCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; 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 { isFetching: boolean; hasError: boolean; capturedCount: number; + stats?: LogsHeaderStats; onRefresh: () => void; onOpenSettings: () => void; onOpenShortcuts: () => void; } /** - * Calm 48px header for the logs surface. - * - Title + icon on the left - * - Live connection pill in the middle (idle/syncing/error) - * - Refresh + settings buttons on the right + * Logs page header. * - * 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({ isFetching, hasError, capturedCount, + stats, onRefresh, onOpenSettings, onOpenShortcuts, @@ -35,56 +46,102 @@ export function LogsHeader({ status === 'error' ? 'bg-red-500' : status === 'syncing' ? 'bg-amber-500' : 'bg-emerald-500'; return ( -
-
-
); } From 75cdc02c1f4a9881b1d64e8d6608da15d74d6378 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 15:37:21 -0400 Subject: [PATCH 09/17] fix(ui): include source in trace child coalesce key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-Agent re-review flagged that adjacent trace rows from different services with the same event/message/stage/level/module would still merge because `source` was missing from the coalesce key. A request that fans out across multiple services would lose the second service's row behind a `× 2` badge. Add `source` to the trace child coalesce key so each participating service stays visible in the trace timeline. Refs #1138, #1151 --- ui/src/components/logs/logs-trace-row.tsx | Bin 5175 -> 5482 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/ui/src/components/logs/logs-trace-row.tsx b/ui/src/components/logs/logs-trace-row.tsx index 5f6d6233d4df3131cab6f08c75d77fb98bc00a27..99a38b857149e8133b32b717dc14d1b88c97ceff 100644 GIT binary patch delta 465 zcmXw#F-}536h*Zl5@Y2GZej9~D6GIxQJA1HF_b2ZGw;JA!wk-w4^m<00(2opP27*A zl}qq_erzXq&O7&hb$+^Uqt;fk*vNVB2 z7&kYTLf&OirnrFPvZSPM4I{1C>Zdj58DgGMTC>nOI}NZk9wn1^vc*oov1AT5qhpZh zp(4q!Ce)v@0M?_(^4>+wjJfoYUA>3PAKQoX~M$V=2*Kv$vn5TS#2@)XwXgCG8H z`)S_$g=$co1Zd(&R>cZh@G=`GHG>NfP?0CJkvG6mX1EYjZxAE{qnANOgy60$pfi)L x$_MU%_&>s%GD)KzcExrL6OspKA%`Am=DkLRUO6Ik!|W(J@(6T^#itwG|2z} From 39ec84a8a9a78489565c27e1e599d8383291400a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 15:37:50 -0400 Subject: [PATCH 10/17] fix(ui): only show "Copied" feedback when clipboard write succeeds PR-Agent re-review flagged that `copyText()` swallowed clipboard errors but the row's `setJustCopied(true)` always fired regardless, so users in insecure contexts (or with denied clipboard permission) saw a fake success indicator while nothing was actually copied. Make `copyText()` return a boolean and gate the "Copied" UI state on it. When the clipboard write fails, the button stays in its idle state and the user can retry rather than being misled. Refs #1138, #1151 --- ui/src/components/logs/logs-row.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/src/components/logs/logs-row.tsx b/ui/src/components/logs/logs-row.tsx index 5e9d0822..0f966572 100644 --- a/ui/src/components/logs/logs-row.tsx +++ b/ui/src/components/logs/logs-row.tsx @@ -31,11 +31,14 @@ function formatHms(timestamp: string): string { }); } -async function copyText(text: string): Promise { +async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); + return true; } catch { - // best-effort; clipboard may be unavailable in non-secure contexts + // Insecure context or clipboard permission denied. Caller should NOT + // claim success in the UI when this returns false. + return false; } } @@ -65,7 +68,8 @@ function LogsRowImpl({ const handleCopyRequestId = async (e: React.MouseEvent) => { e.stopPropagation(); if (!entry.requestId) return; - await copyText(entry.requestId); + const ok = await copyText(entry.requestId); + if (!ok) return; setJustCopied(true); window.setTimeout(() => setJustCopied(false), 1200); }; From 6f1396cf1e99dfac460040b0a6a3f5d7f72bec5d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 15:44:36 -0400 Subject: [PATCH 11/17] fix(ui): import MouseEvent type explicitly in logs-row PR-Agent flagged that `React.MouseEvent` referenced an unimported `React` namespace; in stricter TypeScript builds this would fail typecheck. Import the type alias from `react` directly. Refs #1138, #1151 --- ui/src/components/logs/logs-row.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/components/logs/logs-row.tsx b/ui/src/components/logs/logs-row.tsx index 0f966572..8875484a 100644 --- a/ui/src/components/logs/logs-row.tsx +++ b/ui/src/components/logs/logs-row.tsx @@ -1,4 +1,4 @@ -import { memo, useState, type KeyboardEvent } from 'react'; +import { memo, useState, type KeyboardEvent, type MouseEvent } from 'react'; import { Copy } from 'lucide-react'; import type { LogsEntry } from '@/lib/api-client'; import { cn } from '@/lib/utils'; @@ -65,7 +65,7 @@ function LogsRowImpl({ } }; - const handleCopyRequestId = async (e: React.MouseEvent) => { + const handleCopyRequestId = async (e: MouseEvent) => { e.stopPropagation(); if (!entry.requestId) return; const ok = await copyText(entry.requestId); From 871b6826f786b08f89caa86f7aff439594c9fdf7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 15:45:02 -0400 Subject: [PATCH 12/17] fix(ui): coalesce leaves only when truly adjacent in original stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-Agent flagged that the previous derive-trace-groups split entries into trace and leaf buckets *before* coalescing leaves. That meant two identical no-requestId entries separated by other rows in the real stream would still merge into a single `×N` row, hiding signal. Walk `entries` in input order with single-pass coalescing: a leaf only extends the previous leaf when no other entry (trace child or otherwise) appeared between them. Trace children still gather across interleaving since they're identified by `requestId`. Group children get sorted ts-ascending for trace-timeline display, with the group's positional ts pinned to the oldest child for the reverse-chrono display sort. Refs #1138, #1151 --- ui/src/components/logs/derive-trace-groups.ts | Bin 4991 -> 4934 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index bbe747450b8bf3d8365244b150ee7358e3182c9f..24f22ebc6599f118f0d4160cdd09e3bec731c731 100644 GIT binary patch literal 4934 zcmaJ_?{XWr5&y2I*nSB~g*-~`H#(H+iJjIH#qHQ`^FgC2!8;szF?hHFI8&^mnLb2k z@<4f$K1qMOINXtv6OTeEOKRT7`KReqt{h4fTpmG*?XXfl4XU=k4`#?8u{_*DK zpYLD2`+h<1b#7hp4c{(TPN{BpYwfOR38MY!bT3+YCy@@>#NdN?EcRMo|TMh0uJf(RbC!(wom*XHNv_@s0CajR>( z!cAdYdEztVbmO$gDNpZ+eb9Lus?VCL0*h6yjHs-%#~~GHt3KmsA~ zb`NcJp`CW*w|4JI(XYR}qqQnFn!Ke#`R&>&SCH4HoHf>%s@_CxrLnf~RM>hHs6b3> zD8uEx+SJNqA~eS*?A0oKYuh?pFS>xTk2#d7#_Qs}sy7;250c-HenCDsT=SBG9e(4; zKK8K{oV?Z!eistiOc%0iRKw{;o%~rjN_szON13ik)go<`=Ab3 z_Mxitz-q=i!{w{Ldjuc)H@X0>7Nks-!va+o&-d1Ly_#h7<~|zCT2@9ueSY}CcV~7w zs6qv`qP5Od>ox4Q_$*vi25NAb)BkEUkk1~FtyxQOtvO($XzN13SWdL3|NZB`Fp2L% zoS+5(-rLp`1OT%4F!FF5Nobm%l`Z_M>;Say^xqH~K}@7~!@e$$QI&b*C5%Ve zXp%Ly8BYe(h$_B}Sxb!I+^IBM_4c%d-+OV&uWlzG9k1!^JstEREp_T?;9( z2F*-}1<^*L5e#oElw-JYIuGU$tr1~na_99d-#S}c^dT7_x~s)Fm}BgoL-AXDAmNdD z%O}7d%`>-mVEflYtG>MxLWt#AVJbqyKiyu^?H$)y6cOya1)1EVj%&TAA5`;AoDll? zY&>~M^ErxAIvdD*c7<^B5ym~=0d)o;;(ET}X{=yvvrE3_cpc3O>mwcyNd|>+G$P`d zi8L0}M0_}ArT*@;g(+?xCy|Jgj9vlnXzNImzL!B@6Vu6+pQGY{L(l;tEq}U;JIQG9 z5Q}W1JNnIrnbDO^a9ZG0qc zFj_ox7N?ADT(daRfq)JY{roGgbj>|F-21WU=(=Bs4LeH3i7Sc3-v$PcJ^NTbt|a;A z`vLHi>}K3rK@Pj-`1nrU%Tpi;9SsoCo?wGAH)#mt=RqG0#Df_R1hJr7x#Mow#5fxj zAYBa57+-|0k1j>R66j~B?-=|c-FSz{!LE1C=(0b`9g6<|fv-B+iwIr=X6Oq%xbb6R z=GuB-`GmYL6$xug>qC+7>Jt#)$`2>4txReopm_pcrm0c)!(nea_xW@YgZ^qs7d$P< zIp8?$;p8!rqjK&>&>m5pBonHo?h&ye@GOpa_U{uM9Y0&c40$=BuSvLq9VTgd+3EY` z3CMYluw78=T#L<79fOw4LO9XlMbI4gx2n1UfDNy`A z?b=SBJn1bP430EEe0_17V+h9gBzKsik5-c%caz>ET-M2^E2A!cR3mcOOLP`P6~6=- zO*&J*mg7kzsYsL~VNRFgru1Wyll@cDgp@2Kkyy)He!9e3?!CXgkimdM$0=oziDWv% zc(cUJt%@NZQQa}%UdM?_5QeWJ^q%LWvT~U50itfC7vSIlc>;h3?5C>2D`^QtJcSZn rGfX^TR?ePOY_uf0wdT|7FP#RI`1JbAr#tJ`;Ryg$5ZFE9^YQHe?e;U> literal 4991 zcma)A+j1MZ5zVu{qW5JA!E-$jG)Fw>6IV(@3Z=K5YpPX&#UM9J$pz?>|6xHNbY@-tE#EFz&dD_URJ$u$!l+!Q&2~XA=2V)(F6iZ)4$7GYy_%DD&bkHt zjvk;`--$1!3ECC7kZ!ap-Uhv=`!k}CrO6jGsjvnsh~R-cE(Vu*?ehE_f9ah5zSD-T za8uZoo>+#QuATOf^7NkA2c0#c{G_SOu~=rR5|yR)5K@75>XVMjQVnI9Rh9Oc&J+4z z571_E?X)AmvjUd? z87}wb)~G6xLznmqd$kI0Y-7UfMH^7|F@rKyd!2t!W~;GvFZsRo3-ZC?nr|rB;TuQx zp^vTLNGJ73V zu7cW7sx#Gi4ZAJ==B_LP)ws;*e>NJ(XAj64))HJ(9kG!&CRZ?)6Yc3g|N1vf;=2$h zr~!ZvwyAOg0NHyOc^pR)nl8_Z2L6?H0NM}wIK|-;jL*6pOGP*x8fR$5kcs7xME$Q{ z(_}IqEw>4xryG(7UYDy9ZySfgdvZ<^HP+Y$<8A)*>|-74Et()nLx1x)|;a6ZEVBiMr3;AY zW>i}Mtq!O&t6!46$v@K{?3TUJU!eEpsD~r9MMNz8p!3jW>VR(_TrPu7kGcdYg zC0>w((2e@#Y7I1DS32x!Er==$lvfOLQfaeAn3u~Xy_muNh_!WWK|BF$6HXK?X(hXr zMmaa;d#%u~c_v&lis>_YSXE&n@IThlo|6uTf4jIXlswxkneU$ zS|i(4RE>w-G@a4%O57r1$G&Qf>YU;G;tn=iE8y(QSt~>AVNyfc^!{Y}U~I*@Y!%W2 zfX%udNLR?qy?hyqw+p93ES3|?ovItwM=)J*NX5uuyTfIi1mi(~voVAifHDiPAklm1 zqm4xhd0 zS51)v5upf2YaW95z=33xM!VYD0@}v?@jHxL)OUQ7Afarmui__Kl7sP zKe9jHqxJodx%ZhcYIOli^OKHsOe7ivFvVQS*RF|Ojat}fn={h2ykk2%dj%4y69xA; z&isqPU$L((h@xcTk(V%?B$HWE+Zx@cpQ@rNroYWKizl8b%M`r$rUWggbAw`>htTSIi zKGI6h+xdA>EAQv@=c$=>7G$?owofID*5MR$jhCstHCrU&iN9YC)FJYa5g~xajxq4d;#Y>sP^LXZhcOW^NgKe6i!z#*Y<1g6l0hUE zVXXRien#1r>eQq@hIz?iEuPn-XWt3i`0WNVH{|g7Xs&wC(Tlm5(AU@8x1&>C1Y`U>su@*=q$EkwpT?8Gh3Qny zDHT4YhIyM*7O!Ug#_nsB!89FqW^F{6xb4-Q{3sApFCTHoM1BuuMJnka1h_(obWZyr}bl)-H*bG`L-mL@DD&SEip3m8jF|}crGSVhXej% geA~9b(&=;304z(V&r7!^r{NLIRS Date: Thu, 30 Apr 2026 15:58:48 -0400 Subject: [PATCH 13/17] fix(ui): make trace chevron actually collapse auto-expanded traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first click on a trace's chevron did nothing visible when the trace's child was the currently-selected entry. Root cause: `toggle` only checked the `expanded` set; auto-expand kept re-injecting the requestId so the row stayed open even after the user added it to `expanded` (and oscillating subsequent clicks). Replace the single `expanded` set with a tristate driven by two sets — `userExpanded` (explicitly opened) and `userCollapsed` (explicitly closed). Auto-expand only fires when the id is in neither, so a click on an auto-expanded chevron now writes the collapse intent to `userCollapsed` and the row collapses on the first click. Refs #1138, #1151 --- ui/src/components/logs/logs-entry-list.tsx | 80 +++++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx index d672bb0a..58049b9f 100644 --- a/ui/src/components/logs/logs-entry-list.tsx +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -40,30 +40,72 @@ export function LogsEntryList({ density = 'cozy', }: LogsEntryListProps) { const items = useMemo(() => deriveTraceGroups(entries), [entries]); - const [expanded, setExpanded] = useState>(new Set()); + // Two-set tristate: `userExpanded` = explicitly opened, `userCollapsed` + // = explicitly closed. Auto-expand (when selection lives inside a trace) + // only fires when the id is in neither set, so a user click on an + // auto-expanded chevron actually collapses — previously it added to + // `expanded` while auto-expand kept showing it, so no visible change. + const [userExpanded, setUserExpanded] = useState>(new Set()); + const [userCollapsed, setUserCollapsed] = useState>(new Set()); - const toggle = useCallback((requestId: string) => { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(requestId)) next.delete(requestId); - else next.add(requestId); - return next; - }); - }, []); - - // Auto-expand the trace whose child is currently selected. - const effectiveExpanded = useMemo(() => { - if (!selectedEntryId) return expanded; + // Compute the auto-expand id (if any) — derived inside toggle and the + // effective set so a single source of truth drives both render and + // click logic. + const autoExpandedId = useMemo(() => { + if (!selectedEntryId) return null; const owning = items.find( (it) => it.kind === 'trace' && it.children.some((c) => c.id === selectedEntryId) ); - if (owning && owning.kind === 'trace' && !expanded.has(owning.requestId)) { - const next = new Set(expanded); - next.add(owning.requestId); - return next; + return owning && owning.kind === 'trace' ? owning.requestId : null; + }, [items, selectedEntryId]); + + const toggle = useCallback( + (requestId: string) => { + const isAutoExpanded = autoExpandedId === requestId; + const isExplicitlyOpen = userExpanded.has(requestId); + const isExplicitlyClosed = userCollapsed.has(requestId); + const isCurrentlyOpen = isExplicitlyOpen || (isAutoExpanded && !isExplicitlyClosed); + + if (isCurrentlyOpen) { + // Close: clear user-open, mark user-closed (beats auto-expand). + setUserExpanded((prev) => { + if (!prev.has(requestId)) return prev; + const next = new Set(prev); + next.delete(requestId); + return next; + }); + setUserCollapsed((prev) => { + if (prev.has(requestId)) return prev; + const next = new Set(prev); + next.add(requestId); + return next; + }); + } else { + // Open: clear user-closed, mark user-open. + setUserExpanded((prev) => { + if (prev.has(requestId)) return prev; + const next = new Set(prev); + next.add(requestId); + return next; + }); + setUserCollapsed((prev) => { + if (!prev.has(requestId)) return prev; + const next = new Set(prev); + next.delete(requestId); + return next; + }); + } + }, + [autoExpandedId, userExpanded, userCollapsed] + ); + + const effectiveExpanded = useMemo(() => { + const result = new Set(userExpanded); + if (autoExpandedId && !userCollapsed.has(autoExpandedId)) { + result.add(autoExpandedId); } - return expanded; - }, [expanded, items, selectedEntryId]); + return result; + }, [userExpanded, userCollapsed, autoExpandedId]); const renderItem = useCallback( (_index: number, item: DerivedItem) => { From 40cc62a67f03a86f163d679ae15ef0df78e6e248 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 16:09:50 -0400 Subject: [PATCH 14/17] fix(ui): reserve stage column on every row for consistent alignment PR-Agent flagged that trace child rows rendered an extra stage-hint cell while the header grid template only had 7 columns. Result: every expanded child shifted module/message/latency/request rightward, and the stage chip overlapped its neighbour in the trace timeline. Add a dedicated 72px "Stage" column to the header grid template and to every row variant. Standalone leaves and trace heads render an empty span at the same width; trace children with stage hints render the chip in that slot. Column edges now match across all row types. Refs #1138, #1151 --- ui/src/components/logs/logs-entry-list.tsx | 10 +++++++--- ui/src/components/logs/logs-row.tsx | 12 +++++++++++- ui/src/components/logs/logs-trace-row.tsx | Bin 5482 -> 5639 bytes 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx index 58049b9f..f8fe46dc 100644 --- a/ui/src/components/logs/logs-entry-list.tsx +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -24,10 +24,13 @@ export interface LogsEntryListProps { density?: RowDensity; } -// Leading 16px slot reserves space for the trace-expand chevron so that -// trace rows and standalone leaf rows align under the same column edges. +// Column layout shared by the header and every row variant: +// chevron(16) · time(88) · level(64) · stage(72) · module(140) · message(flex) · latency(72) · request(112) +// The dedicated `stage` column is rendered as an empty span on rows that +// have no stage hint so column edges align across leaf, trace head, and +// trace child rows alike. const COLS_TEMPLATE = - 'grid grid-cols-[16px_88px_64px_140px_minmax(0,1fr)_72px_112px] items-center gap-3 px-3'; + 'grid grid-cols-[16px_88px_64px_72px_140px_minmax(0,1fr)_72px_112px] items-center gap-3 px-3'; export function LogsEntryList({ entries, @@ -149,6 +152,7 @@ export function LogsEntryList({