From 48713ba6e76f281a7fd4a670177c8443fd2709e6 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Mon, 24 Aug 2026 01:32:29 +0800 Subject: [PATCH 1/3] ux(timeline): group the toolbar toggles, overflow the rare ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/DESIGN-core-and-capture.md §6's first half: the eight flat toolbar toggles. They sat in one undifferentiated row — compress, follow, timezone, anomaly, auditor, dividers — mixing three different kinds of control (time/axis, filter, view) that the operator has to scan past every time. Not the reverted approach: this does NOT invent modes or personas onto flag combinations (that shipped and was reverted in 59ff5fc because the categories were reverse-engineered and the defaults landed on "debug"). It groups by what each control *does*, honestly, and moves the low-frequency view/audit ones — session dividers, timezone, and the auditor view — behind a single "More" control. Those are set once and rarely touched; each was taking a slot in the row the operator reads on every glance. The high-frequency controls stay in the row where they cluster naturally: zoom and compress (axis), the filter box and anomaly chip (filter), collapse and minimap (view). The auditor view in particular was worth demoting rather than featuring — it hides the logged tier to show the chained chain, a tamper-evidence concern the core revision moved off centre. e2e/timeline-toolbar-overflow.spec.ts verifies the rare controls are gone from the flat row, reachable in the overflow, still functional, and that the menu closes on outside click. No existing e2e referenced the moved controls. npm test 847 passed · overflow e2e 3 passed Co-Authored-By: Claude Opus 5 --- e2e/timeline-toolbar-overflow.spec.ts | 49 ++++++++++ src/renderer/src/components/Timeline.tsx | 109 +++++++++++++---------- src/renderer/src/i18n/en.json | 5 +- src/renderer/src/i18n/zh-TW.json | 5 +- 4 files changed, 117 insertions(+), 51 deletions(-) create mode 100644 e2e/timeline-toolbar-overflow.spec.ts diff --git a/e2e/timeline-toolbar-overflow.spec.ts b/e2e/timeline-toolbar-overflow.spec.ts new file mode 100644 index 00000000..361abdf6 --- /dev/null +++ b/e2e/timeline-toolbar-overflow.spec.ts @@ -0,0 +1,49 @@ +import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAIN_ENTRY, REPO_ROOT, openTestProject, openView } from './helpers' + +// docs/DESIGN-core-and-capture.md §6: the eight flat toolbar toggles grouped by +// effect. The low-frequency view/audit controls (session dividers, timezone, +// auditor view) moved behind one "More" control so the row is scannable instead +// of a flat wall of chips. This checks they are reachable there, not lost. + +let app: ElectronApplication +let page: Page + +test.describe.serial('timeline toolbar overflow', () => { + test.beforeAll(async () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'redlog-toolbar-')) + app = await electron.launch({ args: [MAIN_ENTRY], cwd: REPO_ROOT, + env: { ...process.env, NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_E2E: '1' } }) + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + await page.evaluate(() => localStorage.setItem('redlog-locale', 'en')) + await openTestProject(page, 'toolbar-overflow') + await openView(page, 'timeline') + }) + test.afterAll(async () => { await app?.close() }) + + test('the rare view controls are not in the flat row', async () => { + // The timezone select and auditor chip should NOT be visible until the + // overflow is opened — that is the whole point of moving them. + await expect(page.locator('[data-testid="timeline-tz-select"]')).toHaveCount(0) + await expect(page.locator('[data-testid="timeline-more-menu"]')).toBeVisible() + }) + + test('the overflow reveals them and they still work', async () => { + await page.locator('[data-testid="timeline-more-menu"]').click() + const tz = page.locator('[data-testid="timeline-tz-select"]') + await expect(tz).toBeVisible() + // Toggling the timezone through the menu persists to localStorage. + await tz.selectOption('utc') + const stored = await page.evaluate(() => localStorage.getItem('redlog-timeline-tz')) + expect(stored).toContain('utc') + }) + + test('the menu closes on outside click', async () => { + await page.mouse.click(5, 5) + await expect(page.locator('[data-testid="timeline-tz-select"]')).toHaveCount(0) + }) +}) diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 1d6c4c3b..f8abc76d 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -1049,6 +1049,10 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // doesn't crash the panel — the dropdown just stays disabled. const [savedViews, setSavedViews] = useState(null) const [viewsOpen, setViewsOpen] = useState(false) + // Overflow for the low-frequency view/audit controls (session dividers, + // timezone, auditor view) so the toolbar row groups by effect instead of + // listing eight flat toggles (DESIGN-core-and-capture.md §6). + const [moreOpen, setMoreOpen] = useState(false) const [viewsName, setViewsName] = useState('') // v0.6.96 Clean-3: `views` is now non-optional in env.d.ts (preload always // exports it). The old cast is gone; direct access is type-safe. @@ -2799,31 +2803,6 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr )} - {/* v0.6.91 S3: session boundaries visibility toggle. */} - - - {/* v0.6.91 S7: timezone selector. Wraps a setTz(e.target.value as TzMode)} - title={t('timeline.tz.tooltip')} - className="shrink-0 whitespace-nowrap text-xs px-1 py-0.5 rounded font-mono bg-redlog-elevated/60 text-redlog-text border border-redlog-border hover:bg-redlog-elevated focus:outline-none focus-visible:ring-1 focus-visible:ring-redlog-text-dim" - > - - - - - {/* v0.6.89.5 feature 4: anomaly filter — dims every event without an integrity badge (clock anomaly / recovery / evidence removal / anchor failure / chain break). First chip so it's the fastest @@ -2848,30 +2827,62 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr > {t('timeline.anomalies.chip', { count: anomalyCount })} - {/* v0.14 §9.2 auditor-view chip: hides logged-tier rows so the - chained (audit) chain is what the reviewer sees. Uses the - chain glyph `⛓` to visually tie it to the per-row tier badge - shipped in v0.14.0 (§9.1). Disabled at opacity 0.25 when - there are no logged rows to hide (matches the anomaly chip - pattern). */} - + {/* Overflow: the low-frequency view/audit controls, grouped off the + flat row (§6). Session dividers, timezone and the auditor view are + set once and rarely touched, so they live behind one control + rather than each taking a slot the operator scans past. */} +
+ + {moreOpen && ( + <> +
setMoreOpen(false)} /> +
+
{t('timeline.more.viewGroup')}
+ + +
+ {t('timeline.tz.tooltip')} + +
+
+ + )} +
{/* v0.6.87 C2: export the currently-visible time window as JSON. The window is derived from the minimap view (left..left+width in percent) mapped back to (timeStart..timeEnd). Bug-bounty writeups diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 2a40b744..a5069129 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -920,5 +920,8 @@ "timeline.targetFocus.exit": "Clear target focus", "settings.transcriptTailerGroup": "Windows terminal output", "settings.transcriptTailerEnable": "Follow PowerShell Start-Transcript", - "settings.transcriptTailerEnableHint": "Windows only. Captures full command output without the Redlog-Run prefix by following a PowerShell transcript file. Add hooks/start-transcript-hook.ps1 to your $PROFILE, then turn this on." + "settings.transcriptTailerEnableHint": "Windows only. Captures full command output without the Redlog-Run prefix by following a PowerShell transcript file. Add hooks/start-transcript-hook.ps1 to your $PROFILE, then turn this on.", + "timeline.more.button": "More", + "timeline.more.hint": "Session dividers, timezone, auditor view", + "timeline.more.viewGroup": "View" } diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 3f63fea9..d6e2083b 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -920,5 +920,8 @@ "timeline.targetFocus.exit": "清除目標聚焦", "settings.transcriptTailerGroup": "Windows 終端輸出", "settings.transcriptTailerEnable": "跟隨 PowerShell Start-Transcript", - "settings.transcriptTailerEnableHint": "僅 Windows。透過跟隨 PowerShell transcript 檔,不需 Redlog-Run 前綴即可擷取完整指令輸出。把 hooks/start-transcript-hook.ps1 加進你的 $PROFILE,再開啟此項。" + "settings.transcriptTailerEnableHint": "僅 Windows。透過跟隨 PowerShell transcript 檔,不需 Redlog-Run 前綴即可擷取完整指令輸出。把 hooks/start-transcript-hook.ps1 加進你的 $PROFILE,再開啟此項。", + "timeline.more.button": "更多", + "timeline.more.hint": "工作階段分隔、時區、稽核檢視", + "timeline.more.viewGroup": "檢視" } From 183b9ac8b5e74abe837d30853f82f99984f5d95c Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Mon, 24 Aug 2026 01:51:24 +0800 Subject: [PATCH 2/3] ux(timeline): band the eighteen lanes by capture group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/DESIGN-core-and-capture.md §6's second half. Eighteen lanes is past reliable scanning; most engagements touch three or four. The lanes now group into the same capture-group bands the readiness card uses — commands, traffic, artifacts — plus a signals band for the derived/alert lanes, so the timeline and the readiness model describe capture the same way. A band opens collapsed to a single row whose dots keep their per-lane colour (what happened is still legible at a glance) and expands to its member lanes on click; empty bands contribute no row, exactly as empty lanes did. The default is all-collapsed, so the timeline opens as three or four bands instead of a wall of eighteen. Band-collapse is a project-scoped preference, like lane visibility — the lanes that matter differ by engagement. The change is a row-key abstraction over the existing geometry: a row is a band id when collapsed or a lane id when expanded, and every place that keyed on a lane (visible rows, event buckets, virtualisation, labels, keyboard navigation) now keys on the row. Dot colour still comes from each event's own lane, so a collapsed band's row is multi-coloured by source. The e2e caught a temporal-dead-zone crash that the unit suite could not: the project-scoped band-load effect referenced `projectIdForKeys` in its dependency array while sitting ABOVE that const's declaration — a throw during render in the esbuild-bundled app, invisible to vitest's source transform. Same class as the TargetView crash, caught the same way: app-in-the-loop. Moved below the declaration. Verified in a running app (e2e/timeline-lane-bands.spec.ts): opens as bands not eighteen lanes, the collapsed bands still draw their events, and clicking one expands it to its lanes. The P0-2 vertical-scroll geometry test now expands all bands first to recreate the many-rows overflow it exists for. npm test 847 passed · npm run e2e 63 passed Co-Authored-By: Claude Opus 5 --- e2e/timeline-geometry.spec.ts | 7 + e2e/timeline-lane-bands.spec.ts | 69 +++++++++ src/renderer/src/components/Timeline.tsx | 172 ++++++++++++++++++++--- src/renderer/src/i18n/en.json | 8 +- src/renderer/src/i18n/zh-TW.json | 8 +- 5 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 e2e/timeline-lane-bands.spec.ts diff --git a/e2e/timeline-geometry.spec.ts b/e2e/timeline-geometry.spec.ts index 83ae898e..9b4fcf06 100644 --- a/e2e/timeline-geometry.spec.ts +++ b/e2e/timeline-geometry.spec.ts @@ -119,6 +119,13 @@ test.describe.serial('timeline geometry + startup gates', () => { // ---------------------------------------------------------------- P0-2 test('lane stack scrolls vertically instead of clipping (v0.9.4 P0-2)', async () => { + // Lanes now open banded (§6). Expand every band so all 18 lanes show, which + // is the many-rows overflow scenario this test exists for. + for (const b of ['commands', 'traffic', 'artifacts', 'signals']) { + const chip = page.locator(`[data-testid="timeline-band-${b}"]`) + if (await chip.count()) await chip.click() + } + await page.waitForTimeout(200) const geom = await page.evaluate(() => { const outer = document.querySelector('div.overflow-y-auto.min-h-0') as HTMLElement | null if (!outer) return null diff --git a/e2e/timeline-lane-bands.spec.ts b/e2e/timeline-lane-bands.spec.ts new file mode 100644 index 00000000..998cc755 --- /dev/null +++ b/e2e/timeline-lane-bands.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAIN_ENTRY, REPO_ROOT, openTestProject, openView } from './helpers' + +// docs/DESIGN-core-and-capture.md §6: the 18 lanes banded by capture group. +// The timeline opens as capture-group bands (collapsed), each expandable to its +// lanes, instead of a wall of eighteen. This is the product's most complex +// screen, so this verifies the real render: bands by default, expand reveals +// lanes, and the events are actually drawn (a band that shows no dots would be +// worse than eighteen lanes). + +let app: ElectronApplication +let page: Page + +test.describe.serial('timeline lane bands', () => { + test.beforeAll(async () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'redlog-bands-')) + app = await electron.launch({ args: [MAIN_ENTRY], cwd: REPO_ROOT, + env: { ...process.env, NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_E2E: '1' } }) + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + await page.evaluate(() => localStorage.setItem('redlog-locale', 'en')) + await openTestProject(page, 'lane-bands') + await page.waitForTimeout(1200) + const base = `http://127.0.0.1:${readFileSync(join(tmpHome, '.redlog', 'api-port'), 'utf-8').trim()}` + const token = readFileSync(join(tmpHome, '.redlog', 'api-token'), 'utf-8').trim() + const post = (agent_type: string, data: Record): Promise => + fetch(`${base}/api/events/seed`, { method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ agent_type, data }) }) + // one event in each of the four bands + await post('shell', { subtype: 'command_end', command: 'whoami' }) // commands + await post('scanner', { subtype: 'connection', proto: 'tcp', remote_addr: '10.0.0.9', remote_port: 445 }) // traffic + await post('screenshot', { trigger: 'manual' }) // artifacts + await post('marker', { title: 'note', severity: 'info' }) // signals + await page.waitForTimeout(600) + await openView(page, 'timeline') + // Wait for the band row to render rather than a fixed sleep — under full- + // suite load the render can lag past a fixed timeout. + await page.locator('[data-testid="timeline-band-commands"]').waitFor({ state: 'visible', timeout: 10_000 }) + }) + test.afterAll(async () => { await app?.close() }) + + test('opens as capture-group bands, not eighteen lanes', async () => { + await expect(page.locator('[data-testid="timeline-band-commands"]')).toBeVisible() + await expect(page.locator('[data-testid="timeline-band-traffic"]')).toBeVisible() + await expect(page.locator('[data-testid="timeline-band-artifacts"]')).toBeVisible() + await expect(page.locator('[data-testid="timeline-band-signals"]')).toBeVisible() + // The band rows collapse many lanes into four; far fewer than 18 rows. + const bandRows = await page.locator('[data-testid^="timeline-band-"]').count() + expect(bandRows).toBeLessThanOrEqual(4) + }) + + test('the collapsed bands still draw their events', async () => { + // A band that hid its dots would defeat the point. Dots render in the track. + const dots = await page.locator('[data-timeline-event]').count() + expect(dots, 'no events rendered in the collapsed bands').toBeGreaterThan(0) + }) + + test('clicking a band expands it into its lanes', async () => { + await page.locator('[data-testid="timeline-band-commands"]').click() + // The commands band is now gone (expanded); its shell lane label shows. + await expect(page.locator('[data-testid="timeline-band-commands"]')).toHaveCount(0) + const text = await page.evaluate(() => document.querySelector('[data-testid="view-root"]')?.textContent ?? '') + expect(text.toLowerCase()).toContain('shell') + }) +}) diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index f8abc76d..e3f64ba3 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -41,6 +41,28 @@ const BODY_GONE = '\u0000__redlog_body_gone__' const LANES = ['shell', 'agent', 'http_navigation', 'scanner', 'browser', 'dns', 'pivot', 'screenshot', 'clipboard', 'file_transfer', 'credential_use', 'c2_checkin', 'marker', 'loot', 'cleanup', 'scope', 'process', 'system'] as const type LaneId = (typeof LANES)[number] +// Capture-group bands (docs/DESIGN-core-and-capture.md §6). Eighteen lanes is +// past reliable scanning; most engagements touch three or four. The bands are +// the same grouping the capture-readiness model uses (commands / traffic / +// artifacts) plus a signals band for the derived/alert lanes — so the timeline +// and the readiness card describe capture the same way. A band collapses to a +// single row whose dots keep their per-lane colour (so what happened is still +// legible), and expands to its member lanes. Every lane belongs to exactly one +// band; the order here is the row order. +type BandId = 'commands' | 'traffic' | 'artifacts' | 'signals' +const BANDS: ReadonlyArray<{ id: BandId; lanes: readonly LaneId[] }> = [ + { id: 'commands', lanes: ['shell', 'agent', 'process'] }, + { id: 'traffic', lanes: ['http_navigation', 'scanner', 'browser', 'dns', 'pivot', 'c2_checkin'] }, + { id: 'artifacts', lanes: ['screenshot', 'clipboard', 'file_transfer', 'loot'] }, + { id: 'signals', lanes: ['marker', 'scope', 'credential_use', 'cleanup', 'system'] } +] +const BAND_OF: Record = Object.fromEntries( + BANDS.flatMap((b) => b.lanes.map((l) => [l, b.id])) +) as Record +// Lanes in band order — replaces the raw LANES order for row layout so a +// band's members are contiguous. +const LANES_BY_BAND: readonly LaneId[] = BANDS.flatMap((b) => b.lanes) + // Lanes with no built-in producer — populated only by external agents // (custom MCP tools, third-party plugins) posting to /api/events. Showing // them as plain "empty" is misleading; the chip tooltip says so explicitly. @@ -765,6 +787,14 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // operator who solo'd "shell" doesn't lose that filter after a reload. // Stored as a JSON array of lane ids; unknown ids (renamed lanes from an // old release) are dropped silently. + // Which capture-group bands are collapsed to a single aggregate row (§6). + // Default: all collapsed, so the timeline opens as three or four bands + // instead of up to eighteen lanes. The operator expands the one they are + // working in. Persisted per project. + // Default all bands collapsed; the per-project stored set (if any) is loaded + // when the project id resolves, in the effect below. + const [collapsedBands, setCollapsedBands] = useState>(() => new Set(BANDS.map((b) => b.id))) + const bandsLoadedFor = useRef(null) const [hiddenLanes, setHiddenLanes] = useState>(() => { try { const raw = localStorage.getItem('redlog-timeline-hidden-lanes') @@ -826,6 +856,21 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // typing, focus toggle — doesn't get clobbered when project.active() // arrives seconds later. Migration only runs once per (project, mount). const [projectIdForKeys, setProjectIdForKeys] = useState(null) + // Project-scoped band-collapse load (placed AFTER projectIdForKeys is + // declared — its dep array evaluates during render, so an earlier placement + // is a temporal-dead-zone crash the e2e caught). + useEffect(() => { + if (!projectIdForKeys) return + if (bandsLoadedFor.current === projectIdForKeys) return + bandsLoadedFor.current = projectIdForKeys + try { + const raw = localStorage.getItem(`redlog-timeline-collapsed-bands:${projectIdForKeys}`) + if (raw === null) { setCollapsedBands(new Set(BANDS.map((b) => b.id))); return } + const arr = JSON.parse(raw) + setCollapsedBands(new Set((Array.isArray(arr) ? arr : []).filter((b): b is BandId => BANDS.some((x) => x.id === b)))) + } catch { setCollapsedBands(new Set(BANDS.map((b) => b.id))) } + }, [projectIdForKeys]) + const migrationAppliedFor = useRef(null) const [anomalyFilter, setAnomalyFilter] = useState(() => { try { return localStorage.getItem('redlog-timeline-anomaly-filter') === '1' } catch { return false } @@ -1203,17 +1248,40 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // `recentEvents` below. }, [events, pluginTypes]) + // The lane an event's row is keyed on: its band's id when that band is + // collapsed, otherwise the lane itself. + const rowKeyOf = useCallback( + (lane: LaneId): string => (collapsedBands.has(BAND_OF[lane]) ? BAND_OF[lane] : lane), + [collapsedBands] + ) + + // The rows actually rendered, in band order. A band with any populated lane + // contributes either one aggregate row (collapsed) or its populated, + // non-hidden lanes (expanded). Empty bands contribute nothing, exactly as + // empty lanes did. + const visibleRows = useMemo(() => { + const rows: string[] = [] + for (const band of BANDS) { + const popLanes = band.lanes.filter((l) => populatedLanes.has(l)) + if (popLanes.length === 0) continue + if (collapsedBands.has(band.id)) rows.push(band.id) + else for (const l of popLanes) if (!hiddenLanes.has(l)) rows.push(l) + } + return rows + }, [populatedLanes, hiddenLanes, collapsedBands]) + + // Still keyed on individual lanes; retained for the zoom-ceiling scan below. const visibleLanes = useMemo( () => LANES.filter((l) => populatedLanes.has(l) && !hiddenLanes.has(l)), [populatedLanes, hiddenLanes] ) const laneH = useMemo(() => { - if (visibleLanes.length === 0) return MIN_LANE_H + if (visibleRows.length === 0) return MIN_LANE_H const axisH = 28 const available = containerH - axisH - return Math.max(MIN_LANE_H, Math.floor(available / visibleLanes.length)) - }, [containerH, visibleLanes.length]) + return Math.max(MIN_LANE_H, Math.floor(available / visibleRows.length)) + }, [containerH, visibleRows.length]) const loadMore = useCallback(() => { if (loading || allLoaded) return @@ -1483,7 +1551,7 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr }, [fromX, view.left, view.width, TRACK_W]) useContributeExport({ label: t('export.slice'), run: exportSlice }) - const totalH = visibleLanes.length * laneH + const totalH = visibleRows.length * laneH const laneEvents = useMemo(() => { const map = Object.fromEntries(LANES.map((l) => [l, [] as RedLogEvent[]])) as Record @@ -1491,6 +1559,21 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr return map }, [events, pluginTypes]) + // Events grouped by the row they render in — a collapsed band's row holds + // every event from its lanes. Only rows that are actually visible get a + // bucket, so a hidden lane's events fall out here. + const rowEvents = useMemo(() => { + const map: Record = {} + for (const r of visibleRows) map[r] = [] + for (const e of events) { + const lane = toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes) + const key = collapsedBands.has(BAND_OF[lane]) ? BAND_OF[lane] : lane + const bucket = map[key] + if (bucket) bucket.push(e) + } + return map + }, [events, visibleRows, collapsedBands, pluginTypes]) + // Debounced so a held key or a fast typist does not run the scan per // character. 120 ms sits below the point where the filter feels laggy and // above a burst of keystrokes. @@ -1643,8 +1726,9 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr if (!projectIdForKeys) return try { localStorage.setItem(`redlog-timeline-hidden-lanes:${projectIdForKeys}`, JSON.stringify([...hiddenLanes])) + localStorage.setItem(`redlog-timeline-collapsed-bands:${projectIdForKeys}`, JSON.stringify([...collapsedBands])) } catch { /* ignore */ } - }, [hiddenLanes, projectIdForKeys]) + }, [hiddenLanes, collapsedBands, projectIdForKeys]) useEffect(() => { try { if (selectedEvent?.id) localStorage.setItem('redlog-timeline-focus-event', selectedEvent.id) @@ -1721,15 +1805,16 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr useEffect(() => { maxZoomRef.current = maxZoom }, [maxZoom]) const clusters = useMemo(() => { const out: Array<{ key: string; lane: LaneId; li: number; x: number; y: number; events: RedLogEvent[] }> = [] - visibleLanes.forEach((lane, li) => { - const evs = laneEvents[lane] - if (!evs.length) return + visibleRows.forEach((rowKey, li) => { + const evs = rowEvents[rowKey] + if (!evs || !evs.length) return let bucket: RedLogEvent[] = [] let curBi = NaN const flush = (): void => { if (!bucket.length) return const x = bucket.reduce((a, e) => a + toX(displayTs(e)), 0) / bucket.length - out.push({ key: `${lane}-${bucket[0].id}`, lane, li, x, y: li * laneH + laneH / 2, events: bucket }) + const colorLane = toLane(bucket[0].agentType, bucket[0].data?.subtype as string | undefined, pluginTypes) + out.push({ key: `${rowKey}-${bucket[0].id}`, lane: colorLane, li, x, y: li * laneH + laneH / 2, events: bucket }) bucket = [] } for (const e of evs) { @@ -1741,7 +1826,7 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr flush() }) return out - }, [visibleLanes, laneEvents, toX, laneH]) + }, [visibleRows, rowEvents, toX, laneH, pluginTypes]) // v0.6.91 S3: derive session-band segments from `shell.session_end` + // `system.recording_paused`/`recording_resumed` pairs. session_start is @@ -2111,6 +2196,14 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // the operator on empty track. }, [TRACK_W, timeMap, updateView, timeStart, timeEnd, loading]) + const toggleBand = useCallback((band: BandId) => { + setCollapsedBands((prev) => { + const next = new Set(prev) + if (next.has(band)) next.delete(band); else next.add(band) + return next + }) + }, []) + const toggleLane = useCallback((lane: LaneId) => { setHiddenLanes((prev) => { const next = new Set(prev) @@ -2199,8 +2292,8 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr events, hiddenLanes, pluginTypes, - laneOrder: LANES as readonly LaneId[], - laneOf: (ev) => toLane(ev.agentType, ev.data?.subtype as string | undefined, pluginTypes), + laneOrder: visibleRows, + laneOf: (ev) => rowKeyOf(toLane(ev.agentType, ev.data?.subtype as string | undefined, pluginTypes)), tsOf: displayTs }) if (next) setSelectedEvent(next) @@ -2966,16 +3059,49 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr {/* Lane labels */}
- {visibleLanes.map((id) => ( -
- - {laneLabels[id]} -
- ))} + {visibleRows.map((rowKey) => { + const band = BANDS.find((b) => b.id === rowKey) + if (band) { + // A collapsed band row: caret + name + how many of its lanes + // have events, clickable to expand. Dots for its events render + // in the track at this row, each keeping its lane colour. + const popCount = band.lanes.filter((l) => populatedLanes.has(l)).length + return ( + + ) + } + const id = rowKey as LaneId + // An expanded lane row: indented under its band, with a caret on + // the band-owning first lane so the operator can collapse back. + return ( +
+ + {laneLabels[id]} + +
+ ) + })}
{/* Scrollable track */} @@ -3048,7 +3174,7 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr {/* Lanes area */}
- {visibleLanes.map((_, i) => ( + {visibleRows.map((_, i) => (
Date: Mon, 24 Aug 2026 01:51:45 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=C2=A76=20timeline=20toggles=20+=20?= =?UTF-8?q?18=20lanes=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN-core-and-capture.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/DESIGN-core-and-capture.md b/docs/DESIGN-core-and-capture.md index b9bd0932..358ab55b 100644 --- a/docs/DESIGN-core-and-capture.md +++ b/docs/DESIGN-core-and-capture.md @@ -382,9 +382,13 @@ recordings. to 27 when connection capture added one. Three groups still need a scope ruling rather than a merge: the deconfliction webhook, operator tokens, and the MCP server. -- Timeline view modes — the audit-mode shape does not survive §1, but the - eight flat toggles are still a real problem. -- 18 lanes. +- ~~Timeline view modes / the eight flat toggles.~~ **Done 2026-08-24.** The + three-view-modes attempt was reverted (it invented personas); the toggles are + now grouped by effect, with the low-frequency view/audit controls (session + dividers, timezone, auditor view) behind one "More" control. +- ~~18 lanes.~~ **Done 2026-08-24.** Banded by capture group (commands / + traffic / artifacts / signals), collapsible; the timeline opens as three or + four bands instead of eighteen lanes. - ~~The plugin marketplace, publisher trust and revocation lists.~~ **Removed 2026-08-22.** Browsing a registry, pinning publishers by Ed25519 fingerprint, and reading revocation lists are the machinery of *distributing*