From c5e0e29f92e5ef30496744d554d98718cc751f45 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 14:54:49 +0800 Subject: [PATCH 01/47] docs: design principles + necessity model from the 2026-08 decision review Adds DESIGN-PRINCIPLES.md as the source-of-truth for the durable design laws settled in the grilling: evidence-core (capture is the feeder), capture-broad- sanitize-later, the two-tier attribute law (record facts, never interpretation- as-fact), two front doors (evidence recorder + live-OPSEC HUD), accept-but- freeze, agents-are-a-capture-source, one-implementation control plane, and timeline-as-reconstruction. Refines the existing docs to match: PRODUCT-POSITIONING drops the "two co-equal halves" framing for an evidence-core + HUD-second-front-door model and adds the P4 HUD-only persona; ROADMAP gains a necessity-model section (marketplace shelved, secondary identities frozen, timeline/control-plane direction); the UX audit/timeline/backlog docs carry dated banners noting the reframes. Co-Authored-By: Claude Opus 4.8 --- docs/DESIGN-PRINCIPLES.md | 177 ++++++++++++++++++++++++++++++++++++ docs/PRODUCT-POSITIONING.md | 56 +++++++++--- docs/README.md | 1 + docs/ROADMAP.md | 55 ++++++++++- docs/UX-AUDIT-2026-08.md | 9 ++ docs/UX-BACKLOG-TICKETS.md | 10 ++ docs/UX-TIMELINE-2026-08.md | 11 +++ 7 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 docs/DESIGN-PRINCIPLES.md diff --git a/docs/DESIGN-PRINCIPLES.md b/docs/DESIGN-PRINCIPLES.md new file mode 100644 index 00000000..5f6d5064 --- /dev/null +++ b/docs/DESIGN-PRINCIPLES.md @@ -0,0 +1,177 @@ +# Design Principles + +Written 2026-08-11. These are the durable laws behind RedLog's design — the +things that should still be true five versions from now, and the yardstick every +"should we build X?" question answers to. They were settled in a decision review +(the grilling of 2026-08-10/11) and are the source of truth that +`PRODUCT-POSITIONING.md`, `ROADMAP.md` and the UX docs elaborate. When a specific +doc disagrees with a principle here, the principle wins and the doc is the bug. + +Each principle carries a one-line *why* and, where it matters, the *test* it +gives you for future decisions. + +--- + +## 1. RedLog is an evidence tool; passive capture is the feeder + +The irreducible core is the tamper-evident record a third party can verify: +hash chain + OpenTimestamps + operator attribution + signed bundle. Passive +capture is **how the chain gets fed**, not the point. It is necessary — a record +you must remember to make has holes exactly where the interesting things +happened, and a holey record is not defensible — but its necessity is *derived +from* the evidentiary core, not independent of it. + +**Test:** a feature earns its place iff it (i) strengthens the chain, (ii) feeds +capture that would otherwise be lost, or (iii) serves the live-OPSEC front door +(§4). Anything else is a frozen secondary identity (§3) or scope creep. + +## 2. Capture broad, sanitize/prune later — and invest in detection + +For an evidence tool, capturing *more content* is not safer: exhaustive capture +vacuums up client PII, credentials and out-of-scope data, which is evidentiary +*liability*. But capturing *narrowly* loses reconstruction you can't get back, +and you cannot always know at capture time what will matter. RedLog resolves this +the way `redaction-design.md` already prescribes: **capture full, sanitize on +export.** Bytes never enter the chain (only their sha256); bodies live in a +prunable sidecar; sanitization is a downstream, chained, attributable action. + +This makes the **detection/sanitize/prune machinery necessary, not optional** — +loot detector, entropy redaction, scope monitor, io sidecar, retention pruning, +export sanitize. Broad capture without strong detection is just an +undifferentiated pile of sensitive data in your evidence store. + +**Corollary — default-on vs opt-in by source:** capture the operator's *own +deliberate actions* (commands, agent tool calls, navigation, targets) on by +default; ambient *vacuums* (clipboard, periodic screenshots) opt-in — not because +they shouldn't be captured, but because they must run continuously and carry an +exposure window, so the operator should consciously accept that bet. (RedLog +already does this: clipboard off by default.) + +## 3. Two-tier attributes: record facts, never write interpretation as fact + +RedLog records **facts** — including the fact "detector D fired at T with +confidence C." It **never** records an *interpretation* as authoritative ground +truth. Authoritative assertions come from exactly two places: the **operator** +(a marker) or **primary capture** (a command was actually run). Everything +interpreted — phase, target when ambiguous, loot type, scope verdict, any MITRE +tag — is a **suggestion**: labelled `inferred`, confidence-scored, attributable +to the detector, and **promotable by the operator** to an authoritative marker. + +This is not a new philosophy — loot detection ("looks like an AWS key, +confidence high"), scope violations and redaction spans already work this way. It +is the law that keeps RedLog honest as it adds convenience. + +**Test — every "should RedLog auto-detect X?" has one answer:** *yes, as a +labelled suggestion; never as fact.* Inference lives in the derived/review layer +or as a typed detection event; it never mutates the underlying event's data, and +it is always visually distinct from an operator's assertion (e.g. a dashed +segment vs. a solid one), with one-click promotion. + +## 4. Two front doors, one reason to exist + +RedLog has two entry points: + +- **Evidence recorder** (primary) — the reason RedLog exists. Remove it and + you have an OPSEC widget, not RedLog. +- **Live-OPSEC HUD** (co-headline) — a genuinely standalone-usable second front + door. Some operators use *only* the HUD (external-IP/EXPOSED alarm, pivot + chain, capture-health) and never record an engagement. + +The HUD is a *main* feature — polished, marketed, usable on its own — but its +OPSEC scope is **frozen** (§3-of-the-freeze-list): it does not grow new OPSEC +features. "Main" means important and standalone-capable, not ever-expanding. + +**Consequences:** the IP/OPSEC/pivot monitors are *doubly* necessary (they feed +the chain *and* the HUD); there must be a **first-class HUD-only runtime mode** +(main window closed, monitors + overlay + tray keep running); and first-run +leads with the zero-setup HUD value while **stating plainly that evidence +recording is the core**, with the Capture Readiness checklist as the invited next +step. Value-first, commitment-later — but never misrepresenting which is the +point. + +## 5. Accept but freeze + +A secondary identity can be *legitimate yet capped*. This is the principled line +that lets RedLog say no to the next almost-free add-on without pretending the +existing one shouldn't exist. Currently frozen (kept, not grown): + +- **Live-OPSEC** — HUD at current scope (§4). +- **Team affordances** — multi-operator tokens, deconfliction webhook, config + profile sync. **No central server, no shared DB, no team dashboard.** +- **Local code plugins** — drop-in + trust gate; **no** exporters/monitors + contributions until there is demand. +- **Cloud share** — opt-in BYO-bucket worker; not invested in, not core surface. + +**Shelved** (a different verdict — zero demand × high ongoing cost × dangerous if +half-maintained): the **plugin marketplace** (signed registry, publisher trust, +revocation). RedLog is a **product, not a platform**. Revisit only when a real +"someone published a plugin, someone wants to install another's plugin" signal +appears. + +**Kept as necessary extensibility:** *declarative* plugins (loot/redaction/target +patterns, event types) — they extend the detection layer §2 deems necessary. + +## 6. Agents are a capture source, not a product direction + +Agent transcripts are *a capture source*, same chain, same attribution, same +pause semantics as the shell hook — not a new product line. The "AI-agent-native" +angle is real and worth marketing (natively recording agent actions into a +tamper-evident log is a genuine 2026 wedge), but it is a pillar of the **capture +layer**, not of the **control layer**. It never justifies growing an +agent-operations platform. + +## 7. Control plane: one implementation, many thin faces + +Different consumers legitimately need different transports — agents want MCP, +scripts want REST, humans want shell functions. The rule is **one canonical +implementation** (the localhost REST handlers) with every other surface a +**thin, ideally generated, adapter** that cannot drift: MCP is the blessed +agent-native face; shell functions and the Codex JSON schema are thin wrappers. +The control-op set stays minimal and **evidence-relevant only** — write evidence +(mark, quickmark), operate the chain (anchor, recording pause/resume), read for +agent decisions (scope, status, search, whoami). It never duplicates capture +(hooks own that) and never grows evidence-irrelevant operations. + +## 8. The timeline is a reconstruction surface, not a live monitor + +Live awareness belongs to the HUD (§4). The timeline's job is **post-hoc +reconstruction and review** — "reconstruct what happened, to whom, in what order; +prove scope-compliance; hand it to a third party." This lets it shed live-ops +weight (follow-mode urgency, live-tail primacy) and its two consequences follow: + +- **Organizing axis = target / phase, not source-type.** Source-type (the 18 + lanes) is a live-firehose axis answering "which streams are active" — the HUD's + job now. Review asks "what happened to *this target*" and "reconstruct *this + phase*." Source-type demotes to a filter; TargetView folds in as the target + grouping; the 18-colour problem dissolves because far fewer groups are active + at once. (Phase per §3: operator-marker segments, with inferred dashed + suggestions.) +- **Timeline = event map; transcript/exchange = I/O reader.** The timeline shows + *what happened & to whom* and surfaces I/O only as a presence glyph; the + actual input/output content is read in the paired transcript/exchange view via + drill-down. Neither tries to be the other. This also rationalises the + Timeline/TargetView/Transcript/Search sprawl into three views with distinct + jobs. + +## 9. Sequence, don't just add — surface weight mirrors necessity + +RedLog's complexity problem is that P2/P3/expert surfaces are presented to the +solo operator (P1) at equal weight from the first screen — under-sequenced, not +over-featured (with the marketplace the one genuine over-build, §5). The fix is +**progressive disclosure**: essentials prominent, frozen secondary identities +behind "advanced," shelved features removed. Settings, onboarding and the +timeline all follow this — importance decides visual weight, and "find it" +(search) is a complement to sequencing, never a substitute for it. + +--- + +## Using these + +For any proposed change, walk the tests: Does it serve §1's core (or §4's second +front door)? If it's capture, does §2's default-on/opt-in line apply? If it +interprets anything, is it a §3 suggestion, not a fact? If it's a secondary +identity, is it §5-frozen or genuinely core? Does it keep the timeline a §8 +reconstruction surface and the control plane a §7 single implementation? And does +it earn its §9 surface weight? A change that can't answer these is either scope +creep or an unstated change to the principles — make the latter explicit and +deliberate, never a side effect. diff --git a/docs/PRODUCT-POSITIONING.md b/docs/PRODUCT-POSITIONING.md index 5a24771e..58e48563 100644 --- a/docs/PRODUCT-POSITIONING.md +++ b/docs/PRODUCT-POSITIONING.md @@ -25,18 +25,39 @@ explains it. > and hand a client or my own legal team something they can trust — **without > spending the engagement taking notes.** -Two halves, and both are load-bearing: - -1. **Complete + defensible** — the record has to survive scrutiny. That is the - hash chain, the OpenTimestamps anchor, the operator attribution, the signed - bundle. This is what separates RedLog from a scratchpad. -2. **Without taking notes** — capture has to be passive. The moment logging - depends on the operator remembering to log, the record has holes exactly - where the interesting things happened. This is why hooks are the backbone - and MCP is only the control plane (see `agent-integration.md`). - -Everything RedLog does is in service of one of these two. A feature that serves -neither is scope creep. +One core, one feeder — not two co-equal halves (see `DESIGN-PRINCIPLES.md` §1): + +1. **The core is evidentiary.** The reason RedLog exists is the tamper-evident + record a third party can verify: the hash chain, the OpenTimestamps anchor, + the operator attribution, the signed bundle. This is what separates RedLog + from a scratchpad, and it is what every feature ultimately answers to. +2. **Passive capture is the feeder, not the point.** Capture must be passive + because a record you must remember to make has holes exactly where the + interesting things happened, and a holey record is not defensible. So passive + capture is *necessary* — but its necessity is derived from the evidentiary + core, not independent of it. This is why hooks are the backbone and MCP is + only the control plane (see `agent-integration.md`). + +**Necessity test** (the yardstick for every feature): it earns its place iff it +strengthens the chain, feeds capture that would otherwise be lost, or serves the +live-OPSEC front door (below). Anything else is a *frozen secondary identity* or +scope creep. The full tiering and the design laws behind it live in +`DESIGN-PRINCIPLES.md`. + +## Two front doors, one reason to exist + +RedLog has two entry points, and conflating them mis-sizes half the roadmap: + +- **Evidence recorder** *(primary)* — the reason RedLog exists. Remove it and + what's left is an OPSEC widget, not RedLog. +- **Live-OPSEC HUD** *(co-headline)* — a genuinely standalone-usable second + front door. Some operators use *only* the HUD (external-IP / EXPOSED alarm, + live pivot chain, capture-health) and never record an engagement. It is a main, + marketed, on-its-own-usable feature — but its OPSEC scope is **frozen**: it + does not grow new OPSEC features. There is a first-class HUD-only runtime mode + (main window closed; monitors + overlay + tray keep running), and first-run + leads with the zero-setup HUD value while stating plainly that evidence + recording is the core. See `DESIGN-PRINCIPLES.md` §4. ## Who it's for @@ -66,6 +87,17 @@ the transcript tailer + shell hooks (passive, can't be forgotten) with MCP as an explicit control plane. This persona is why "hooks log, MCP operates" is a hard rule, not a preference. +### P4 — The HUD-only operator + +Uses RedLog purely as a live-OPSEC display — external-IP / EXPOSED alarm, live +pivot chain, capture-health at a glance — and may never record an engagement. +Enters through the second front door (above). Cares about: **zero setup, always +on top, out of the way.** This persona is why the HUD must run with the main +window closed and why first-run must give OPSEC value before asking for any +capture wiring. They are a first-class user, not an edge case — but the tray and +overlay always keep the evidence core one click away, because that is what RedLog +is *for*. + ### S1 — The evidence consumer *(stakeholder, not a user)* The client, the client's counsel, or the operator's own QA. Never opens the app. diff --git a/docs/README.md b/docs/README.md index 8c2bc6fa..6967dd1d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links ## Product & planning +- **[Design principles](DESIGN-PRINCIPLES.md)** — the durable design laws behind RedLog: evidence-core, capture-broad-sanitize-later, the two-tier attribute law (record facts, never interpretation-as-fact), two front doors, accept-but-freeze, agents-are-a-capture-source, one-implementation control plane, timeline-as-reconstruction. The yardstick every "should we build X?" answers to; positioning and roadmap elaborate it. - **[Product positioning](PRODUCT-POSITIONING.md)** — the single source of truth for who RedLog is for (personas P1–P3 + the evidence-consumer stakeholder), the job to be done, the explicit non-goals, and where RedLog sits vs. Ghostwriter / RedEye / PwnDoc / manual oplogs. When positioning and roadmap disagree, this page wins. - **[Architecture](ARCHITECTURE.md)** — process/layer model, startup order, DB schema and migration strategy, the capture pipeline end to end, tailer host, evidence chain, export, plugin system, IPC conventions. Replaces the stale ASCII diagram in the README. - **[Roadmap](ROADMAP.md)** — what ships next and the v1.0 gate. Also states what is deliberately not planned. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fa09dbca..5768a01a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -107,9 +107,11 @@ privileged path that skips the gate: Until now this roadmap has had one axis: correctness and trust. The 2026-08-10 review (`UX-AUDIT-2026-08.md`, `UX-TIMELINE-2026-08.md`) added a second — the primary persona's *success*, not just the log's honesty. The finding is that -RedLog is under-*sequenced*, not over-*featured*: P2/P3/expert surfaces are -presented to the solo operator (P1) at full weight from the first screen, and -the timeline's interactions are invisible, overloaded, and context-dependent. +RedLog is **mostly** under-*sequenced* rather than over-*featured*: P2/P3/expert +surfaces are presented to the solo operator (P1) at full weight from the first +screen, and the timeline's interactions are invisible, overloaded, and +context-dependent. The one genuine *over-build* is the plugin **marketplace** — +shelved as premature (see the necessity model below). Full ticket specs (F1–F7, T1–T6) live in `UX-BACKLOG-TICKETS.md`. The tension to hold: none of this is a new product direction — it is making the passive- @@ -141,6 +143,53 @@ Structural, sequenced later: header "View options" menu (T4), the remaining timeline seam extractions (T5), and lane virtualization (T6) — which is the same work as **V4** below and, once done, deletes the wheel-mode branch behind T2. +## Necessity model & design decisions (added 2026-08-11) + +A decision review settled what RedLog is, which features earn their place, and +how the kept ones should be designed. The durable laws live in +`DESIGN-PRINCIPLES.md`; the roadmap-affecting outcomes: + +**Verdicts on the feature surface** (necessity test: strengthen the chain ∨ feed +capture ∨ serve the live-OPSEC front door): + +- **Core, untouchable:** chain + OTS + signing + bundle + operator attribution; + the capture that feeds it; the detection/sanitize/prune machinery that makes + broad capture safe (loot, redaction, scope, io sidecar, retention, export + sanitize); the proxied browser (capture enabler); the IP/OPSEC/pivot monitors + (doubly necessary — feed the chain *and* the HUD). +- **Second front door, main but frozen:** the live-OPSEC HUD — first-class + HUD-only runtime mode; OPSEC scope does not grow. +- **Accept but freeze:** team affordances (multi-op, deconfliction, config sync — + no central server); local code plugins (no exporters/monitors); cloud share + (opt-in BYO). +- **Shelved:** the plugin **marketplace** — product, not platform. Do not invest + or market as core until a real distribution signal appears. +- **Kept as necessary extensibility:** declarative plugins. + +**Design direction for the kept features:** + +- **Timeline** = reconstruction/review surface (live goes to the HUD); reorganise + around a **target/phase axis** (source-type demotes to a filter, TargetView + folds in); timeline is the **event map**, transcript/exchange the **I/O + reader**, linked by drill-down. Reframes tickets T1–T6 and folds the + Timeline/TargetView/Transcript/Search sprawl toward three distinct-job views. +- **Capture items** obey the **two-tier attribute law**: facts are authoritative; + every interpretation (phase, ambiguous target, loot type, scope verdict, MITRE) + is a labelled, promotable *suggestion*, never written as fact. New: `phase` as + operator-marker segments with inferred dashed suggestions; conservative target + attribution with an "untargeted" catch-all. +- **Onboarding** = value-first (zero-setup HUD) with evidence stated as the core + and the Capture Readiness checklist as the invited next step. Extends F1/F1-b. +- **Settings** reorganise by necessity tier / front door (essentials prominent, + frozen behind "advanced", marketplace removed) — the structural half of F3. +- **Control plane** = one canonical implementation (REST) with thin, generated + adapters (MCP blessed, shell/Codex thin); minimal, evidence-relevant op set. + +These are not scheduled as a single release — they are the lens the F/T tickets +above are re-read through. Anything here that contradicts "report/ATT&CK/ +correlation stay downstream" would be a change to the non-goals, and there is +none: inference stays a suggestion, never authoritative output. + ## v0.12.0 — durability and scale | Item | Ref | diff --git a/docs/UX-AUDIT-2026-08.md b/docs/UX-AUDIT-2026-08.md index 009f0f60..274346aa 100644 --- a/docs/UX-AUDIT-2026-08.md +++ b/docs/UX-AUDIT-2026-08.md @@ -1,5 +1,14 @@ # UX & Complexity Audit — 2026-08-10 +> **Refined 2026-08-11 by the necessity model (`DESIGN-PRINCIPLES.md`, roadmap +> "Necessity model & design decisions").** Two theses below are updated: (1) +> "not over-featured, under-sequenced" is now *mostly* under-sequenced — the +> plugin **marketplace** is a genuine over-build, **shelved**. (2) The HUD is not +> just an OPSEC add-on but a **co-headline second front door** with its own +> (P4) persona, so the onboarding findings (F1) are reframed as *value-first with +> evidence stated as the core* and a **HUD-only** path. Findings and line +> numbers below stand; read them through that lens. + Written against v0.11.5 by a full-tree read of `src/renderer/`, cross-checked against `PRODUCT-POSITIONING.md` (personas) and external research on how pentesters actually adopt tools. Companion to the correctness-focused diff --git a/docs/UX-BACKLOG-TICKETS.md b/docs/UX-BACKLOG-TICKETS.md index e0b1823e..958542b2 100644 --- a/docs/UX-BACKLOG-TICKETS.md +++ b/docs/UX-BACKLOG-TICKETS.md @@ -1,5 +1,15 @@ # UX Backlog — Ticket Specs +> **Updated 2026-08-11 by the necessity model (`DESIGN-PRINCIPLES.md`).** Three +> tickets change scope: **F3** (Settings) is now the *structural* reorg by +> necessity tier / front door, not just a search box. **F1** onboarding is +> *value-first with evidence stated as the core* + a **HUD-only** path (new: a +> first-class HUD-only runtime mode). The **T-series** is re-read through the +> timeline reframe (job = review; axis = target/phase; map vs. I/O reader — see +> `UX-TIMELINE-2026-08.md`'s banner). And a new decision: the plugin +> **marketplace is shelved** (product, not platform), so any marketplace polish +> is out of scope. Acceptance criteria below stand. + Written 2026-08-10 against v0.11.6. Each finding from `UX-AUDIT-2026-08.md` (F-series, whole renderer) and `UX-TIMELINE-2026-08.md` (T-series, timeline) is broken out here into an **independently implementable ticket**: enough spec, diff --git a/docs/UX-TIMELINE-2026-08.md b/docs/UX-TIMELINE-2026-08.md index f30e0aaf..847fa6db 100644 --- a/docs/UX-TIMELINE-2026-08.md +++ b/docs/UX-TIMELINE-2026-08.md @@ -1,5 +1,16 @@ # Timeline UX — Why It Feels Complex, and How to Simplify +> **Reframed 2026-08-11 by the necessity model (`DESIGN-PRINCIPLES.md` §8).** +> Three decisions now sit *above* the T-series fixes below and change what +> "simpler" means: (1) the timeline's job is **post-hoc reconstruction/review** — +> live awareness goes to the HUD — so it sheds live-ops weight (follow-mode +> urgency, live-tail primacy). (2) Its organizing axis becomes **target/phase, +> not source-type**; the 18 source-type lanes demote to a filter and TargetView +> folds in — which dissolves the 18-colour problem at its root. (3) The timeline +> is the **event map**; the transcript/exchange is the **I/O reader**; they link +> by drill-down, so the timeline never grows inline I/O content. The specific +> T1–T6 fixes below remain valid *within* this reframing. + Written 2026-08-10 against v0.11.5, from a full read of `Timeline.tsx`. This is the deep-dive behind finding **F2** in `UX-AUDIT-2026-08.md`. The complaint — "the timeline is too complex and not intuitive to operate" — is correct and, on From e6f03952c34574503120b9d0dfa87204fd299b94 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 14:55:06 +0800 Subject: [PATCH 02/47] feat(ux): implement UX backlog F3-F5, T1-T3 test-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six UX tickets, each built as a pure, unit-tested seam in src/renderer/src/lib/ then wired into the component that renders it (the DEV-REQUIREMENTS TDD loop): - F3 settingsSearch — Settings filter box; matchGroups() + cross-tab flattened results wired into Settings.tsx - F4 emptyState — emptyStateFor() drives the previously-dead EmptyState CTA in 6 empty views (loot/marks/screenshots get working actions; targets/ transcript get the shared component) - F5 shortcuts — single SHORTCUTS registry; the Dashboard card and the Timeline `?` cheatsheet now render from it (picks up the omitted Cmd-. pause) - T1 legend — persistent timeline interaction legend (3 core gestures) - T2 timelineWheel — wheelMode() resolves the pan-vs-scroll ambiguity + an overflow-only hint - T3 timelineModes — activeModes() renders a dismissible active-filters row so an empty track always explains itself Verification: 9 test files / 99 tests green, electron-vite build clean, i18n en/zh parity (869/869). Seams follow DESIGN-PRINCIPLES (renderer-only, pure, facts-not-interpretation). Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/App.tsx | 61 +++-- src/renderer/src/components/FindingsView.tsx | 29 ++- src/renderer/src/components/LootPanel.tsx | 30 ++- src/renderer/src/components/Settings.tsx | 113 ++++++++- src/renderer/src/components/TargetView.tsx | 20 +- src/renderer/src/components/Timeline.tsx | 237 ++++++++++++++---- .../src/components/TranscriptView.tsx | 21 +- src/renderer/src/i18n/en.json | 34 +++ src/renderer/src/i18n/zh-TW.json | 34 +++ src/renderer/src/lib/emptyState.ts | 107 ++++++++ src/renderer/src/lib/settingsSearch.ts | 34 +++ src/renderer/src/lib/shortcuts.ts | 70 ++++++ src/renderer/src/lib/timelineModes.ts | 91 +++++++ src/renderer/src/lib/timelineWheel.ts | 46 ++++ test/empty-state.test.ts | 121 +++++++++ test/settings-search.test.ts | 61 +++++ test/shortcuts.test.ts | 90 +++++++ test/timeline-modes.test.ts | 130 ++++++++++ test/timeline-wheel.test.ts | 52 ++++ 19 files changed, 1290 insertions(+), 91 deletions(-) create mode 100644 src/renderer/src/lib/emptyState.ts create mode 100644 src/renderer/src/lib/settingsSearch.ts create mode 100644 src/renderer/src/lib/shortcuts.ts create mode 100644 src/renderer/src/lib/timelineModes.ts create mode 100644 src/renderer/src/lib/timelineWheel.ts create mode 100644 test/empty-state.test.ts create mode 100644 test/settings-search.test.ts create mode 100644 test/shortcuts.test.ts create mode 100644 test/timeline-modes.test.ts create mode 100644 test/timeline-wheel.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1320415c..b5147630 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -15,12 +15,14 @@ import { ErrorBoundary } from './components/ErrorBoundary' import { QuickMarksView } from './components/FindingsView' import TerminalView from './components/TerminalView' import { ToastContainer } from './components/Toast' -import { LoadingSpinner } from './components/Feedback' +import { LoadingSpinner, EmptyState } from './components/Feedback' import { ConfirmDialogContainer, confirm as confirmDialog } from './components/ConfirmDialog' import { toast } from './components/Toast' import { computeCaptureReadiness } from './lib/captureReadiness' +import { emptyStateFor } from './lib/emptyState' +import { shortcutsForScope, MOD_TOKEN } from './lib/shortcuts' import { useI18n } from './i18n' -import { loadSidebarOrder, onSidebarOrderChanged, type SidebarViewId } from './lib/sidebarOrder' +import { loadSidebarOrder, type SidebarViewId } from './lib/sidebarOrder' import logoUrl from './assets/logo.svg' type View = SidebarViewId | 'settings' | 'search' @@ -64,6 +66,16 @@ export default function App(): JSX.Element { const [markerAtTs, setMarkerAtTs] = useState(undefined) const { t } = useI18n() + // F4: turns an empty view's CTA target (from emptyStateFor) into an app-level + // action. Threaded to the empty views so their "nothing here" screens have a + // way forward instead of being dead ends. 'doc' has no in-app destination yet, + // so those CTAs are suppressed at the view rather than wired to a no-op. + const handleEmptyAction = (target: string): void => { + if (target === 'dashboard') setView('dashboard') + else if (target === 'marker') setShowMarker(true) + else if (target === 'screenshot') window.redlog.screenshot.capture().catch(() => {}) + } + useEffect(() => { window.redlog.project.active().then((p) => { if (p) setProject(p) @@ -223,13 +235,14 @@ export default function App(): JSX.Element { { setFocusEvent({ id, ts }); onNavigate('timeline') }} + onEmptyAction={handleEmptyAction} /> )} - {view === 'screenshots' && } - {view === 'targets' && } + {view === 'screenshots' && } + {view === 'targets' && } {view === 'scope' && { setFocusEvent({ id: '', ts }); setView('timeline') }} />} - {view === 'loot' && { setFocusEvent({ id, ts }); setView('timeline') }} />} - {view === 'marks' && { setFocusEvent({ id: '', ts }); setView('timeline') }} />} + {view === 'loot' && { setFocusEvent({ id, ts }); setView('timeline') }} onEmptyAction={handleEmptyAction} />} + {view === 'marks' && { setFocusEvent({ id: '', ts }); setView('timeline') }} onEmptyAction={handleEmptyAction} />} {view === 'settings' && } {view === 'search' && { setFocusEvent({ id, ts }); setView('timeline') }} />} @@ -831,8 +844,14 @@ function DashboardView({ onNavigate }: { onNavigate: (v: string) => void }): JSX // Settings is pinned to ⌘9 rather than carried by shortcutOrder, // so it has to be listed explicitly (v0.11.2). [`${modKey}9`, t('sidebar.settings')] as [string, string], - [isMac ? '⌘⇧M' : 'Ctrl+Shift+M', t('dashboard.addMarker')] as [string, string], - [`${modKey}/`, t('dashboard.search')] as [string, string] + // Non-view global shortcuts come from the single shortcut registry + // (lib/shortcuts.ts) so this card and the Timeline `?` cheatsheet + // stay in sync — adding a global shortcut there surfaces it here, + // and it picks up ones this card used to omit (e.g. ⌘. pause). + // switch-view is rendered dynamically above (per reorderable order). + ...shortcutsForScope('global') + .filter((s) => s.id !== 'switch-view') + .map((s) => [s.keys.replaceAll(MOD_TOKEN, isMac ? '⌘' : 'Ctrl+'), t(s.labelKey)] as [string, string]) ].map(([key, label]) => (
{key} @@ -865,7 +884,7 @@ function StatCard({ label, value, sub, tone = 'neutral' }: { ) } -function ScreenshotsView(): JSX.Element { +function ScreenshotsView({ onEmptyAction }: { onEmptyAction?: (target: string) => void }): JSX.Element { const [screenshots, setScreenshots] = useState([]) const [thumbs, setThumbs] = useState>({}) const [expanded, setExpanded] = useState(null) @@ -937,15 +956,21 @@ function ScreenshotsView(): JSX.Element { {t('screenshots.captureNow')}
- {screenshots.length === 0 ? ( -
-
- ◻ -
-

{t('screenshots.empty')}

-

{t('screenshots.emptyDesc')}

-
- ) : (() => { + {screenshots.length === 0 ? (() => { + // F4: the shared EmptyState with a working CTA, driven by the pure + // emptyStateFor map, instead of a hand-rolled dead end. + const es = emptyStateFor('screenshots', { captureDark: false }) + return ( + onEmptyAction?.(es.action!.target) } + : undefined} + /> + ) + })() : (() => { // Trigger filter (audit #32) — all captures land in one grid mixing // periodic / manual / mark-triggered. Chip toggles narrow the view. const triggerCounts = new Map() diff --git a/src/renderer/src/components/FindingsView.tsx b/src/renderer/src/components/FindingsView.tsx index 8f76d24e..f8fc89a5 100644 --- a/src/renderer/src/components/FindingsView.tsx +++ b/src/renderer/src/components/FindingsView.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' +import { EmptyState } from './Feedback' +import { emptyStateFor } from '../lib/emptyState' import { confirm } from './ConfirmDialog' import { toast } from './Toast' @@ -20,7 +22,10 @@ function getTagColor(title: string): typeof TAG_COLORS[0] { return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] } -export function QuickMarksView({ onOpenInTimeline }: { onOpenInTimeline?: (ts: number) => void } = {}): JSX.Element { +export function QuickMarksView({ onOpenInTimeline, onEmptyAction }: { + onOpenInTimeline?: (ts: number) => void + onEmptyAction?: (target: string) => void +} = {}): JSX.Element { const [marks, setMarks] = useState([]) const [selected, setSelected] = useState(null) const [creating, setCreating] = useState(false) @@ -166,9 +171,25 @@ export function QuickMarksView({ onOpenInTimeline }: { onOpenInTimeline?: (ts: n ) })} {filteredMarks.length === 0 && !creating && ( -
- {marks.length === 0 ? t('marks.empty') : t('marks.noSearchMatches', { query: search })} -
+ marks.length === 0 ? (() => { + // True-empty (no marks at all) gets the shared EmptyState + CTA; + // the search-filtered-empty case keeps its plain "no matches" line. + const es = emptyStateFor('marks', { captureDark: false }) + return ( + onEmptyAction?.(es.action!.target) } + : undefined} + /> + ) + })() : ( +
+ {t('marks.noSearchMatches', { query: search })} +
+ ) )} diff --git a/src/renderer/src/components/LootPanel.tsx b/src/renderer/src/components/LootPanel.tsx index 1929dc74..78727633 100644 --- a/src/renderer/src/components/LootPanel.tsx +++ b/src/renderer/src/components/LootPanel.tsx @@ -1,9 +1,13 @@ import { useState, useEffect, useMemo } from 'react' import { useI18n } from '../i18n' -import { LoadingSpinner } from './Feedback' +import { LoadingSpinner, EmptyState } from './Feedback' +import { emptyStateFor } from '../lib/emptyState' import { toast } from './Toast' -export function LootPanel({ onOpenInTimeline }: { onOpenInTimeline?: (eventId: string, ts: number) => void }): JSX.Element { +export function LootPanel({ onOpenInTimeline, onEmptyAction }: { + onOpenInTimeline?: (eventId: string, ts: number) => void + onEmptyAction?: (target: string) => void +}): JSX.Element { const [lootEvents, setLootEvents] = useState -
- ◆ -
-

{t('loot.empty')}

-

{t('loot.emptyDesc')}

- - ) : ( + {lootEvents.length === 0 ? (() => { + const es = emptyStateFor('loot', { captureDark: false }) + return ( + onEmptyAction?.(es.action!.target) } + : undefined} + /> + ) + })() : ( // v0.7.1 P1: rendering uses the same `visibleList` that feeds the // header count, so what you see and what the header says can never // disagree. Each source event keeps its own grouping so the "click diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 4a777389..3fe9affc 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -3,6 +3,7 @@ import { useI18n, type Locale } from '../i18n' import { toast } from './Toast' import { confirm as confirmDialog } from './ConfirmDialog' import { setLastVerifyResult, type FullVerifyResult as CachedFullVerifyResult } from '../lib/verifyResultCache' +import { matchGroups, type SettingsGroupIndex } from '../lib/settingsSearch' // The Wi-Fi-name toggle only means anything on macOS (where the SSID is gated // behind Location Services). Windows/Linux read the SSID directly, so the @@ -68,9 +69,71 @@ const LOCALE_LABELS: Record = { 'zh-TW': '繁體中文' } +type TabId = 'general' | 'hud' | 'capture' | 'network' | 'scope' | 'integrations' | 'data' | 'plugins' + +// F3 (UX-BACKLOG / DESIGN-PRINCIPLES §9): a static index of every FieldGroup on +// the page so the header filter can find a setting by its group title OR any of +// its field labels without the operator having to remember which of the 8 tabs +// it lives in. Titles and labels are i18n keys resolved at render time; the pure +// match rule lives in lib/settingsSearch.ts. Order mirrors on-screen order so a +// filtered result list reads top-to-bottom like the real page. This is +// presentation metadata only — it points at controls, it does not own them. +interface SettingsGroupSource { + tab: TabId + groupId: string + titleKey: string + labelKeys: string[] +} + +const SETTINGS_GROUPS: SettingsGroupSource[] = [ + // general + { tab: 'general', groupId: 'engagement', titleKey: 'settings.engagement', labelKeys: ['settings.id', 'settings.name'] }, + { tab: 'general', groupId: 'operator', titleKey: 'settings.operatorGroup', labelKeys: ['settings.id', 'settings.name'] }, + { tab: 'general', groupId: 'language', titleKey: 'settings.language', labelKeys: [] }, + { tab: 'general', groupId: 'uiScale', titleKey: 'settings.uiScale', labelKeys: [] }, + // capture + { tab: 'capture', groupId: 'hooks', titleKey: 'settings.hooksDetected', labelKeys: [] }, + { tab: 'capture', groupId: 'agents', titleKey: 'settings.agents', labelKeys: [] }, + { tab: 'capture', groupId: 'screenshotQuality', titleKey: 'settings.screenshotQuality', labelKeys: ['settings.jpegQuality'] }, + { tab: 'capture', groupId: 'clipboard', titleKey: 'settings.clipboardGroup', labelKeys: ['settings.clipboardEnable', 'settings.clipboardStorePreview'] }, + { tab: 'capture', groupId: 'fileWatcher', titleKey: 'settings.fileWatcherGroup', labelKeys: ['settings.fileWatcherPaths', 'settings.fileWatcherIgnore'] }, + { tab: 'capture', groupId: 'processMonitor', titleKey: 'settings.processMonitorGroup', labelKeys: ['settings.processMonitorIgnore'] }, + // scope + { tab: 'scope', groupId: 'scopeEnforcement', titleKey: 'settings.scopeEnforcement', labelKeys: ['settings.warnOnViolation'] }, + { tab: 'scope', groupId: 'inScopeTargets', titleKey: 'settings.inScopeTargets', labelKeys: ['settings.targetsLabel'] }, + { tab: 'scope', groupId: 'excludedTargets', titleKey: 'settings.excludedTargets', labelKeys: ['settings.excludeLabel'] }, + { tab: 'scope', groupId: 'scopeFile', titleKey: 'settings.scopeFile', labelKeys: ['settings.scopeFileLabel'] }, + // network + { tab: 'network', groupId: 'ipSafety', titleKey: 'settings.ipSafety', labelKeys: ['settings.whitelist', 'settings.blacklist'] }, + { tab: 'network', groupId: 'polling', titleKey: 'settings.polling', labelKeys: ['settings.ipMode', 'settings.checkInterval', 'settings.confirmations', 'settings.ipProviders', 'settings.showWifiName'] }, + { tab: 'network', groupId: 'vpnAdapters', titleKey: 'settings.vpnAdapters', labelKeys: [] }, + // hud + { tab: 'hud', groupId: 'overlay', titleKey: 'settings.overlayGroup', labelKeys: ['settings.overlayShowInDock'] }, + // integrations + { tab: 'integrations', groupId: 'mcp', titleKey: 'settings.mcp', labelKeys: [] }, + { tab: 'integrations', groupId: 'hookExcludedPaths', titleKey: 'settings.hookExcludedPaths.title', labelKeys: [] }, + { tab: 'integrations', groupId: 'operators', titleKey: 'settings.operators', labelKeys: [] }, + { tab: 'integrations', groupId: 'deconfliction', titleKey: 'settings.deconfliction', labelKeys: [] }, + { tab: 'integrations', groupId: 'browser', titleKey: 'settings.browser', labelKeys: [] }, + { tab: 'integrations', groupId: 'cdp', titleKey: 'settings.cdp', labelKeys: ['settings.testConnection'] }, + // data + { tab: 'data', groupId: 'screenshotSchedule', titleKey: 'settings.screenshotGroup', labelKeys: [] }, + { tab: 'data', groupId: 'update', titleKey: 'settings.updateGroup', labelKeys: ['settings.checkUpdate'] }, + { tab: 'data', groupId: 'exportAll', titleKey: 'settings.exportAll', labelKeys: ['settings.exportJson'] }, + { tab: 'data', groupId: 'exportBundle', titleKey: 'settings.exportBundleTitle', labelKeys: [] }, + { tab: 'data', groupId: 'scopeExport', titleKey: 'settings.scopeExport', labelKeys: ['settings.exportScopeJson'] }, + { tab: 'data', groupId: 'cloudShare', titleKey: 'cloudShare.title', labelKeys: [] }, + { tab: 'data', groupId: 'integrity', titleKey: 'settings.integrity', labelKeys: [] }, + { tab: 'data', groupId: 'profileSync', titleKey: 'settings.profileSync', labelKeys: ['settings.exportProfile', 'settings.importProfile'] }, + // plugins + { tab: 'plugins', groupId: 'plugins', titleKey: 'settings.plugins', labelKeys: [] }, + { tab: 'plugins', groupId: 'marketplace', titleKey: 'settings.marketplace', labelKeys: [] } +] + export default function Settings(): JSX.Element { const [config, setConfig] = useState(null) - const [tab, setTab] = useState<'general' | 'hud' | 'capture' | 'network' | 'scope' | 'integrations' | 'data' | 'plugins'>('general') + const [tab, setTab] = useState('general') + const [filter, setFilter] = useState('') const [saved, setSaved] = useState(false) const [exportResult, setExportResult] = useState(null) const [hooks, setHooks] = useState([]) @@ -127,6 +190,20 @@ export default function Settings(): JSX.Element { { id: 'plugins' as const, label: t('settings.plugins') } ] + // F3: resolve the static group index against the current locale and run the + // pure matcher. An empty/whitespace query means "no filter" — the page keeps + // its normal tabbed view; a real query flattens hits from every tab into one + // navigable list so a setting is found by name, not by remembering its tab. + const query = filter.trim() + const groupIndex: SettingsGroupIndex[] = SETTINGS_GROUPS.map((g) => ({ + tab: g.tab, + groupId: g.groupId, + title: t(g.titleKey), + labels: g.labelKeys.map((k) => t(k)) + })) + const matchedGroups = query ? matchGroups(filter, groupIndex) : [] + const tabLabel = (id: string): string => tabs.find((tb) => tb.id === id)?.label ?? id + return (
@@ -141,10 +218,40 @@ export default function Settings(): JSX.Element { {tb.label} ))} - {saved && {t('settings.saved')}} + setFilter(e.target.value)} + placeholder={t('settings.filterPlaceholder')} + aria-label={t('settings.filterPlaceholder')} + className="ml-auto w-44 px-2 py-1 text-xs bg-zinc-900 border border-redlog-border rounded text-zinc-200 placeholder-zinc-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40" + /> + {saved && {t('settings.saved')}}
+ {query ? ( + matchedGroups.length === 0 ? ( +

{t('settings.filterNoResults', { query: filter })}

+ ) : ( + // Flattened cross-tab results: each hit is a button that jumps to the + // owning tab (and clears the filter so the real controls show). The + // tab name travels with every hit so the operator knows where the + // setting lives — the F3 acceptance case ("typing 'exclude' surfaces + // the scope exclusion list regardless of active tab"). + matchedGroups.map((g) => ( + + )) + ) + ) : ( + <> {tab === 'general' && ( <> @@ -633,6 +740,8 @@ export default function Settings(): JSX.Element { )} {tab === 'plugins' && } + + )}
diff --git a/src/renderer/src/components/TargetView.tsx b/src/renderer/src/components/TargetView.tsx index 6f7b99d8..1ac42722 100644 --- a/src/renderer/src/components/TargetView.tsx +++ b/src/renderer/src/components/TargetView.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' +import { EmptyState } from './Feedback' +import { emptyStateFor } from '../lib/emptyState' interface TargetEntry { target: string @@ -30,7 +32,7 @@ function matchesScope(target: string, pattern: string): boolean { return target === pattern } -export function TargetView(): JSX.Element { +export function TargetView({ onEmptyAction }: { onEmptyAction?: (target: string) => void } = {}): JSX.Element { const [targets, setTargets] = useState([]) const [filter, setFilter] = useState<'all' | 'in_scope' | 'out_scope'>('all') const [selected, setSelected] = useState(null) @@ -159,9 +161,19 @@ export function TargetView(): JSX.Element {
- {filtered.length === 0 ? ( -

{t('targets.empty')}

- ) : ( + {filtered.length === 0 ? (() => { + const es = emptyStateFor('targets', { captureDark: false }) + return ( + onEmptyAction?.(es.action!.target) } + : undefined} + /> + ) + })() : (
{filtered.map((tgt) => (
diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 831d3d4c..595331ef 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -8,6 +8,9 @@ import { maskEventData, fieldsWithRedactions, type RedactionSpan } from '../lib/ import { LoadingSpinner } from './Feedback' import { getLastVerifyResult, VERIFY_UPDATED_EVENT, type FullVerifyResult } from '../lib/verifyResultCache' import { resolveTimelineKey } from '../lib/timelineKeys' +import { wheelMode } from '../lib/timelineWheel' +import { activeModes, type TimelineModeState } from '../lib/timelineModes' +import { shortcutsForScope, modKey, MOD_TOKEN } from '../lib/shortcuts' const MIN_LANE_H = 36 const LABEL_W = 92 @@ -755,6 +758,23 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // added since v0.6.90 was previously invisible unless a teammate told you. // `?` opens; Escape or click-outside closes. const [showHelp, setShowHelp] = useState(false) + // T2(b): the transient wheel-mode hint. While the lane stack overflows, a + // plain wheel scrolls lanes (the one surprising gesture wheelMode() resolves); + // this flashes an inline hint on the track's right edge whenever overflow is + // live, then fades after a beat of no wheel input. `wheelHintTimer` holds the + // pending fade so a fresh wheel event resets it instead of stacking timers. + const [wheelHintOn, setWheelHintOn] = useState(false) + const wheelHintTimer = useRef(null) + // T1: the persistent interaction legend under the minimap is collapsible; the + // operator's choice is chrome, not engagement state, so it rides a plain + // global key like the session-dividers / collapse-agent prefs (not the + // per-project scoped keys, which carry review state). Default expanded. + const [legendCollapsed, setLegendCollapsed] = useState(() => { + try { return localStorage.getItem('redlog-timeline-legend-collapsed') === '1' } catch { return false } + }) + useEffect(() => { + try { localStorage.setItem('redlog-timeline-legend-collapsed', legendCollapsed ? '1' : '0') } catch { /* ignore */ } + }, [legendCollapsed]) // Layer 3 (four-layer redaction): raw text of an event's redacted spans is // hidden by default in the detail view. The reviewer opts into a per-event // reveal; each reveal appends a chained system.secret_revealed event so the @@ -1140,6 +1160,31 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { [populatedLanes, hiddenLanes] ) + // T3: the "Active:" row. activeModes() (lib/timelineModes.ts, unit-tested) is + // the single source of which non-default modes are hiding/transforming events; + // this just feeds it the current state and renders the returned chips. A "solo" + // is only claimed when exactly one lane survives AND something is actually + // hidden — a naturally single-lane engagement isn't a solo — so the chip's ✕ + // (showAllLanes) is always a real action. The soloed lane's display label is + // carried as the chip value so the row reads "solo: shell", not the raw id. + const modeChips = useMemo(() => { + const isSolo = visibleLanes.length === 1 && hiddenLanes.size > 0 + const state: TimelineModeState = { + focusActive: focusChain !== null, + anomalyOnly: anomalyFilter, + collapseAgentTurns, + soloLane: isSolo ? laneLabels[visibleLanes[0]] : null, + hiddenLaneCount: hiddenLanes.size, + filterQuery + } + return activeModes(state) + }, [focusChain, anomalyFilter, collapseAgentTurns, visibleLanes, hiddenLanes, filterQuery, laneLabels]) + + // T1/F5: the modifier symbol (⌘ on macOS, Ctrl elsewhere) resolved once from + // the preload-exposed platform, shared by the interaction legend and the `?` + // cheatsheet so both agree with the SHORTCUTS registry's MOD_TOKEN. + const mod = modKey((window as { redlog?: { platform?: string } }).redlog?.platform ?? '') + const laneH = useMemo(() => { if (visibleLanes.length === 0) return MIN_LANE_H const axisH = 28 @@ -1854,7 +1899,26 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { const el = scrollRef.current if (!el) return const onWheel = (e: WheelEvent): void => { - if (e.ctrlKey || e.metaKey) { + // T2: the once-invisible pan/scroll/zoom branch is now the pure wheelMode() + // resolver (lib/timelineWheel.ts, unit-tested). `overflow` reproduces the + // old `scrollHeight > clientHeight + 1` check on the shared vertical-scroll + // parent; the three booleans map 1:1 onto the previous inline conditions so + // behaviour is unchanged — zoom on the zoom modifier, scroll-y only while + // the stack overflows and no modifier escapes it, pan-x everywhere else. + const outer = containerRef.current + const overflow = !!outer && outer.scrollHeight > outer.clientHeight + 1 + const mode = wheelMode({ overflow, shiftKey: e.shiftKey, zoomKey: e.ctrlKey || e.metaKey }) + + // T2(b): flash the inline hint whenever overflow is live so the surprising + // scroll-y gesture explains itself as it happens. Only while overflow — the + // common (fitting) case stays clean — and it fades after a beat of no wheel. + if (overflow) { + setWheelHintOn(true) + if (wheelHintTimer.current != null) window.clearTimeout(wheelHintTimer.current) + wheelHintTimer.current = window.setTimeout(() => setWheelHintOn(false), 2500) + } + + if (mode === 'zoom') { e.preventDefault() setCluster(null) const cursorX = e.clientX - el.getBoundingClientRect().left @@ -1864,15 +1928,13 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // and felt like nothing happened. Scaling by deltaY makes pinch smooth and // still gives a mouse wheel a reasonable step. setZoom((prev) => Math.min(maxZoomRef.current, Math.max(0.25, prev * Math.exp(-e.deltaY * 0.002)))) + } else if (mode === 'scroll-y') { + // v0.9.4 P0-2: while the lane stack overflows, let the native vertical + // scroll through (do NOT preventDefault) so the lanes clipped below the + // fold are reachable by wheel. Shift is the escape hatch back to pan-x. + return } else if (e.deltaY !== 0) { - // v0.9.4 P0-2: while the lane stack overflows its container, deltaY - // has to drive the vertical axis or the newly-scrollable lanes are - // unreachable by wheel; shift+wheel keeps the horizontal scroll that - // deltaY does otherwise (dragging and the minimap also still pan). - // With no vertical overflow — the common case — behaviour is - // unchanged. - const outer = containerRef.current - if (outer && !e.shiftKey && outer.scrollHeight > outer.clientHeight + 1) return + // pan-x: the common, unsurprising case — deltaY drives the time axis. e.preventDefault() el.scrollLeft += e.deltaY } @@ -1998,6 +2060,22 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { }, []) const showAllLanes = useCallback(() => setHiddenLanes(new Set()), []) + // T3: dispatch a mode chip's ✕ to the existing clear path for that mode. The + // clearAction ids come from activeModes(); each maps to the same setter the + // header/keyboard already uses, so a chip never invents new clearing logic. + // clear-solo and clear-hidden-lanes both just restore every lane (a solo is a + // set of hidden lanes), so both route through showAllLanes. + const clearMode = useCallback((action: string) => { + switch (action) { + case 'clear-focus': setFocusAnchorId(null); break + case 'clear-anomaly': setAnomalyFilter(false); break + case 'clear-collapse-agent': setCollapseAgentTurns(false); break + case 'clear-solo': + case 'clear-hidden-lanes': showAllLanes(); break + case 'clear-filter': setFilterQuery(''); break + } + }, [showAllLanes]) + // One keydown listener for the Timeline's global single-key surface. This // replaced four separate window listeners that each re-implemented the "am I // typing?" guard and independently handled Escape — with the detail panel, @@ -2332,45 +2410,23 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { title={t('timeline.help.close')} >×
-
- {([ - ['timeline.help.group.filter', [ - ['/', 'timeline.help.slash'], - ['⌘K', 'timeline.help.palette'], - ['Alt-click', 'timeline.help.soloLane'], - ['Esc', 'timeline.help.escFilter'] - ]], - ['timeline.help.group.focus', [ - ['f', 'timeline.help.focusChain'], - ['click', 'timeline.help.selectDot'], - ['↑/↓', 'timeline.help.walk'] - ]], - ['timeline.help.group.timeline', [ - ['Right-click', 'timeline.help.dropMarker'], - ['drag minimap', 'timeline.help.zoom'], - ['click cluster', 'timeline.help.expandCluster'] - ]], - ['timeline.help.group.detail', [ - ['click ▶', 'timeline.help.expandBody'], - ['click cause chip', 'timeline.help.jumpCause'], - ['Copy full', 'timeline.help.copyFull'] - ]], - ['timeline.help.group.misc', [ - ['?', 'timeline.help.thisMenu'] - ]] - ] as Array<[string, Array<[string, string]>]>).map(([groupKey, rows]) => ( -
-
{t(groupKey)}
-
- {rows.map(([key, descKey]) => ( -
- {key} - {t(descKey)} -
- ))} + {/* F5: rendered from the shared SHORTCUTS registry (lib/shortcuts.ts) + so this cheatsheet and the Dashboard shortcuts card can no longer + drift. `shortcutsForScope('timeline')` returns the global keys plus + the timeline-only ones; the MOD_TOKEN placeholder in each combo is + swapped for the resolved modKey, and each labelKey maps to a + shortcut.* i18n string. */} +
+
+ {shortcutsForScope('timeline').map((s) => ( +
+ + {s.keys.split(MOD_TOKEN).join(mod)} + + {t(s.labelKey)}
-
- ))} + ))} +
{t('timeline.help.footer')} @@ -2729,6 +2785,41 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: {
+ {/* T3: active-modes row. Renders directly under the header, and ONLY when + at least one sticky mode is hiding/transforming events — activeModes() + returns [] at all-default, so a clean timeline shows no bar at all. It + answers "why is the track near-empty?" (DESIGN-PRINCIPLES §8) with one + dismissible chip per mode plus a clear-all. */} + {modeChips.length > 0 && ( +
+ + {t('timeline.mode.active')} + + {modeChips.map((chip) => ( + + {t(chip.labelKey, chip.value != null ? { value: chip.value } : undefined)} + + + ))} + +
+ )} + {/* Density minimap — overview of the whole engagement. Drag to zoom to a window, click to jump. The bright frame marks the current viewport. */}
+ {/* T1: persistent interaction legend. A low-weight strip under the minimap + carrying the three core gestures (pan / zoom / filter) plus a pointer to + the full ? cheatsheet — so pan, zoom and filter are never a secret. The + {mod} token resolves once via modKey(platform), shared with the F5 + registry. Collapsible; the choice persists in legendCollapsed. */} +
+ {!legendCollapsed && ( +
+ {t('timeline.legend.pan')} + {t('timeline.legend.zoom', { mod })} + + +
+ )} + {legendCollapsed && ( + + )} + +
+ {/* Timeline + event list split */} -
+
+ {/* T2(b): the transient wheel-mode hint, pinned to the track's right edge. + Shown only while the lane stack overflows (wheelHintOn is set solely on + an overflow wheel), so the common fitting case stays clean. It names the + surprising gesture — plain wheel scrolls lanes — and the ⇧ escape hatch + back to horizontal pan. pointer-events-none so it never eats a wheel. */} + {wheelHintOn && ( +
+
{t('timeline.wheelHint.scrolling')}
+
{t('timeline.wheelHint.panWith')}
+
+ )} {/* Swim lanes */} {/* v0.9.4 P0-2: scrolls vertically. The lane labels are a sibling of the track, so the overflow has to live on this shared parent — diff --git a/src/renderer/src/components/TranscriptView.tsx b/src/renderer/src/components/TranscriptView.tsx index e4258802..3041201c 100644 --- a/src/renderer/src/components/TranscriptView.tsx +++ b/src/renderer/src/components/TranscriptView.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { useI18n } from '../i18n/I18nContext' +import { EmptyState } from './Feedback' +import { emptyStateFor } from '../lib/emptyState' import { toast } from './Toast' /** @@ -217,8 +219,9 @@ function safeJson(v: unknown): string { try { return JSON.stringify(v, null, 2) } catch { return String(v) } } -export default function TranscriptView({ onOpenInTimeline }: { +export default function TranscriptView({ onOpenInTimeline, onEmptyAction }: { onOpenInTimeline?: (id: string, ts: number) => void + onEmptyAction?: (target: string) => void }): JSX.Element { const { t } = useI18n() const [events, setEvents] = useState([]) @@ -322,9 +325,19 @@ export default function TranscriptView({ onOpenInTimeline }: {
{loading &&

{t('transcript.loading')}

} - {!loading && shown.length === 0 && ( -

{t('transcript.empty')}

- )} + {!loading && shown.length === 0 && (() => { + const es = emptyStateFor('transcript', { captureDark: false }) + return ( + onEmptyAction?.(es.action!.target) } + : undefined} + /> + ) + })()} {shown.map((b) => { const open = expanded.has(b.id) const big = (b.output?.length ?? 0) > MAX_INLINE diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 6f4739f3..a09b7cbd 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -318,6 +318,8 @@ "settings.agents.selfExclusionHint": "A `.redlog-app-root` marker file in a project cwd opts that repo out of tailing — used to prevent RedLog watching its own dev sessions. Commit the marker to any repo you don't want tailed.", "settings.loading": "Loading...", "settings.saved": "Config saved to this project", + "settings.filterPlaceholder": "Filter settings…", + "settings.filterNoResults": "No settings match \"{query}\"", "settings.engagement": "Engagement", "settings.id": "ID", "settings.name": "Name", @@ -584,6 +586,34 @@ "timeline.focusChain.badge": "🔗 Focus chain ({{count}} events)", "timeline.focusChain.exit": "Exit focus mode (Esc)", "timeline.focusChain.enterHint": "Press f to focus this chain", + "timeline.wheelHint.scrolling": "⇅ scrolling lanes", + "timeline.wheelHint.panWith": "⇧-scroll to pan", + "timeline.mode.active": "Active:", + "timeline.mode.clear": "Clear this mode", + "timeline.mode.clearAll": "clear all", + "timeline.mode.focus": "◎ focus chain", + "timeline.mode.anomaly": "⚑ anomalies only", + "timeline.mode.collapseAgent": "⇘ agent turns hidden", + "timeline.mode.solo": "solo: {{value}}", + "timeline.mode.hiddenLanes": "{{value}} lanes hidden", + "timeline.mode.filter": "/ \"{{value}}\"", + "timeline.legend.pan": "⇔ drag to pan", + "timeline.legend.zoom": "{{mod}}-scroll to zoom", + "timeline.legend.filter": "/ filter", + "timeline.legend.shortcuts": "? all shortcuts", + "timeline.legend.collapse": "Collapse legend", + "timeline.legend.expand": "Show interaction legend", + "shortcut.switchView": "Switch view", + "shortcut.quickMarker": "Drop a quick marker", + "shortcut.search": "Search events", + "shortcut.pauseRecording": "Pause / resume recording", + "shortcut.filterEvents": "Focus inline filter", + "shortcut.toggleHelp": "Show this shortcut menu", + "shortcut.focusChain": "Toggle focus-chain mode", + "shortcut.commandPalette": "Fuzzy command palette", + "shortcut.navigateEvents": "Walk selection up / down", + "shortcut.soloLane": "Solo a lane (hide the others)", + "shortcut.dropMarker": "Drop a marker at this timestamp", "timeline.anomalies.chip": "⚠ Anomalies ({{count}})", "timeline.anomalies.tooltip": "Dim every event without a chain-integrity flag (clock anomaly, orphan recovery, evidence removal, anchor failure, chain break)", "timeline.brokenChain.banner": "⛓️‍💥 Chain broken at event {{brokenAtId}}… (walked {{walked}}). All events after are unverified.", @@ -622,6 +652,10 @@ "overlay.rec": "REC", "overlay.paused": "PAUSED", "screenshots.emptyDesc": "Screenshots are captured automatically on idle or manually via the capture button.", + "empty.action.setupCapture": "Set up capture", + "empty.action.captureNow": "Capture now", + "empty.action.mark": "Add a marker (⌘⇧M)", + "empty.action.learnMore": "Learn more", "project.advancedSetup": "Advanced Setup (optional)", "project.advancedSummary": "scope {scope} / safe {safe} / exposed {exposed}", "project.done": "Done", diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index e01c7be2..7ec6fbda 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -318,6 +318,8 @@ "settings.agents.selfExclusionHint": "cwd 裡放 `.redlog-app-root` 標記檔可以把該 repo 排除在 tailing 之外 — 用來避免 RedLog 監聽自己的開發 session。任何你不想被記的 repo 都可以 commit 這個檔案。", "settings.loading": "載入中...", "settings.saved": "設定已儲存至此專案", + "settings.filterPlaceholder": "過濾設定…", + "settings.filterNoResults": "沒有符合「{query}」的設定", "settings.engagement": "任務資訊", "settings.id": "ID", "settings.name": "名稱", @@ -584,6 +586,34 @@ "timeline.focusChain.badge": "🔗 聚焦鏈({{count}} 個事件)", "timeline.focusChain.exit": "退出聚焦模式(Esc)", "timeline.focusChain.enterHint": "按 f 聚焦這條鏈", + "timeline.wheelHint.scrolling": "⇅ 捲動 lane", + "timeline.wheelHint.panWith": "⇧+滾輪平移", + "timeline.mode.active": "生效中:", + "timeline.mode.clear": "清除此模式", + "timeline.mode.clearAll": "全部清除", + "timeline.mode.focus": "◎ 聚焦鏈", + "timeline.mode.anomaly": "⚑ 僅異常", + "timeline.mode.collapseAgent": "⇘ 已摺疊 agent 回合", + "timeline.mode.solo": "單獨:{{value}}", + "timeline.mode.hiddenLanes": "已隱藏 {{value}} 個 lane", + "timeline.mode.filter": "/ \"{{value}}\"", + "timeline.legend.pan": "⇔ 拖曳平移", + "timeline.legend.zoom": "{{mod}}+滾輪縮放", + "timeline.legend.filter": "/ 篩選", + "timeline.legend.shortcuts": "? 所有快捷鍵", + "timeline.legend.collapse": "收合圖例", + "timeline.legend.expand": "顯示互動圖例", + "shortcut.switchView": "切換檢視", + "shortcut.quickMarker": "快速標記", + "shortcut.search": "搜尋事件", + "shortcut.pauseRecording": "暫停/恢復錄製", + "shortcut.filterEvents": "聚焦行內篩選", + "shortcut.toggleHelp": "顯示此快捷鍵選單", + "shortcut.focusChain": "切換聚焦鏈模式", + "shortcut.commandPalette": "模糊命令面板", + "shortcut.navigateEvents": "上/下移動選取", + "shortcut.soloLane": "單獨顯示一個 lane(隱藏其他)", + "shortcut.dropMarker": "在此時間戳放置標記", "timeline.anomalies.chip": "⚠ 異常({{count}})", "timeline.anomalies.tooltip": "把沒有整合旗標的事件變暗(時鐘異常、孤兒回收、證據刪除、錨定失敗、鏈斷)", "timeline.brokenChain.banner": "⛓️‍💥 證據鏈在事件 {{brokenAtId}}… 之後斷開(走完 {{walked}} 個)。之後的所有事件皆未驗證。", @@ -622,6 +652,10 @@ "overlay.rec": "錄製", "overlay.paused": "已暫停", "screenshots.emptyDesc": "截圖會在閒置時自動擷取,或透過擷取按鈕手動觸發。", + "empty.action.setupCapture": "設定擷取", + "empty.action.captureNow": "立即擷取", + "empty.action.mark": "新增標記 (⌘⇧M)", + "empty.action.learnMore": "了解更多", "project.advancedSetup": "進階設定(選填)", "project.advancedSummary": "範圍 {scope}/白 {safe}/黑 {exposed}", "project.done": "完成", diff --git a/src/renderer/src/lib/emptyState.ts b/src/renderer/src/lib/emptyState.ts new file mode 100644 index 00000000..3c701a62 --- /dev/null +++ b/src/renderer/src/lib/emptyState.ts @@ -0,0 +1,107 @@ +// Empty-state decision seam (UX-AUDIT / UX-BACKLOG F4). +// +// `Feedback.tsx` ships a CTA-carrying `EmptyState` primitive that had zero +// usages — every view rolled its own actionless "nothing here" dead end. This +// pure function is the first half of the fix: it decides, per view and per +// capture context, WHICH copy the empty state shows and WHAT the single call to +// action is. The wiring step (separate) feeds the result into ``. +// +// Two deliberate constraints keep this seam collision-free with the live app: +// 1. titleKey / subtitleKey REUSE i18n keys that already exist in en.json. +// This function never invents an i18n key. +// 2. action.labelKey is a STABLE, non-i18n identifier (e.g. +// `empty.action.setupCapture`). The real label is added to i18n at wiring +// time; keeping it out of here means this file never touches en.json. +// +// `target` is the CTA destination identifier the wiring layer routes on: a nav +// target (`dashboard`, `screenshot`, `marker`) or a doc anchor (`doc`). + +/** The six views that have an empty state worth wiring a CTA into. */ +export type EmptyView = 'timeline' | 'screenshots' | 'targets' | 'loot' | 'marks' | 'transcript' + +export interface EmptyStateModel { + /** existing i18n key for the empty-state title */ + titleKey: string + /** existing i18n key for the empty-state subtitle */ + subtitleKey: string + /** the single call-to-action; absent when there is nothing useful to do yet */ + action?: { labelKey: string; target: string } +} + +/** Capture context that changes what the most useful next action is. */ +export interface EmptyStateCtx { + /** true when no capture source is feeding events — the timeline stays dark. */ + captureDark: boolean +} + +// Stable, non-i18n action identifiers. Resolved to real labels at wiring time. +const SETUP_CAPTURE = { labelKey: 'empty.action.setupCapture', target: 'dashboard' } as const +const CAPTURE_NOW = { labelKey: 'empty.action.captureNow', target: 'screenshot' } as const +const MARK_NOW = { labelKey: 'empty.action.mark', target: 'marker' } as const +const LEARN_MORE = { labelKey: 'empty.action.learnMore', target: 'doc' } as const + +// Per-view copy (reused i18n keys) + the action used when capture IS feeding. +// The capture-dark override is applied afterwards for the views where a dark +// timeline is the real reason they're empty (DESIGN-PRINCIPLES §1: without +// capture, nothing lands anywhere). +const BASE: Record = { + // Timeline light-capture case has no action: events simply haven't arrived yet + // and pushing to Dashboard would be noise. The dark override adds the CTA. + timeline: { titleKey: 'timeline.noEvents', subtitleKey: 'timeline.noEventsDesc' }, + screenshots: { + titleKey: 'screenshots.empty', + subtitleKey: 'screenshots.emptyDesc', + action: { ...CAPTURE_NOW } + }, + targets: { + titleKey: 'targets.empty', + subtitleKey: 'targets.subtitle', + action: { ...LEARN_MORE } + }, + loot: { + titleKey: 'loot.empty', + subtitleKey: 'loot.emptyDesc', + action: { ...SETUP_CAPTURE } + }, + marks: { + titleKey: 'marks.empty', + subtitleKey: 'marks.placeholderSub', + action: { ...MARK_NOW } + }, + // Transcript has no dedicated subtitle key; it is a second reading of the same + // event store, so it reuses the timeline's "events will appear here" copy. + transcript: { + titleKey: 'transcript.empty', + subtitleKey: 'timeline.noEventsDesc', + action: { ...LEARN_MORE } + } +} + +// When capture is dark these views cannot receive anything until a source is +// wired, so their CTA is redirected to "set up capture" regardless of their +// light-capture action. Screenshots is excluded — it IS a capture source, so +// "capture now" is still the right, self-contained action. +const DARK_REDIRECT: ReadonlySet = new Set([ + 'timeline', + 'loot', + 'targets', + 'transcript', + 'marks' +]) + +/** + * Decide the empty-state model for a view under the current capture context. + * Pure — no i18n resolution, no DOM, no side effects. + */ +export function emptyStateFor(view: EmptyView, ctx: EmptyStateCtx): EmptyStateModel { + const base = BASE[view] + if (ctx.captureDark && DARK_REDIRECT.has(view)) { + return { titleKey: base.titleKey, subtitleKey: base.subtitleKey, action: { ...SETUP_CAPTURE } } + } + // Clone the action so callers can't mutate the shared BASE table. + return { + titleKey: base.titleKey, + subtitleKey: base.subtitleKey, + action: base.action ? { ...base.action } : undefined + } +} diff --git a/src/renderer/src/lib/settingsSearch.ts b/src/renderer/src/lib/settingsSearch.ts new file mode 100644 index 00000000..d7616bc0 --- /dev/null +++ b/src/renderer/src/lib/settingsSearch.ts @@ -0,0 +1,34 @@ +// Pure matcher behind the Settings filter box (UX-BACKLOG F3 / DESIGN-PRINCIPLES +// §9). Settings is 8 tabs of FieldGroups and the only way to find a setting is +// remembering its tab; this seam lets the header input filter groups by title +// and field-label text so the operator can "find it" instead of re-tabbing. +// +// The index is static presentation data — one entry per FieldGroup, carrying its +// owning tab, a stable id, its resolved (localized) title, and the resolved +// labels of the fields inside it. Keeping the match rule out of the component +// keeps the behaviour tested and the component thin. + +export interface SettingsGroupIndex { + tab: string + groupId: string + title: string + labels: string[] +} + +/** + * Filter a settings group index by a free-text query. + * + * An empty (or whitespace-only) query is "no filter" and returns the index + * unchanged, so the caller can fall back to its normal tabbed view. Otherwise a + * group matches when its title OR any of its labels contains the query as a + * case-insensitive substring. Matching groups are returned in their original + * index order; no match returns []. + */ +export function matchGroups(query: string, index: SettingsGroupIndex[]): SettingsGroupIndex[] { + const needle = query.trim().toLowerCase() + if (needle === '') return index + return index.filter((group) => { + if (group.title.toLowerCase().includes(needle)) return true + return group.labels.some((label) => label.toLowerCase().includes(needle)) + }) +} diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts new file mode 100644 index 00000000..b10cc945 --- /dev/null +++ b/src/renderer/src/lib/shortcuts.ts @@ -0,0 +1,70 @@ +// Single source of truth for RedLog's keyboard/pointer shortcuts, so the two +// places that surface them — the Dashboard "shortcuts" card (App.tsx) and the +// Timeline `?` cheatsheet (Timeline.tsx) — render from ONE list instead of two +// hand-maintained arrays that drifted apart (UX backlog F5: the card and the +// cheatsheet disagreed on which keys existed and what they did). +// +// This module is intentionally presentation-only: it does NOT wire up handlers +// (App.tsx and lib/timelineKeys.ts still own the actual keydown logic). It just +// describes what to show. Keeping it a plain data table means the two views +// can't drift, and the list is trivially testable. + +export type ShortcutScope = 'global' | 'timeline' + +export interface Shortcut { + /** Stable machine identifier — never shown to the user, safe to key React on. */ + id: string + /** Human-readable key combo. The modifier is the literal placeholder token + * `Mod` (see MOD_TOKEN); the render side swaps it for modKey(platform) so the + * registry stays platform-agnostic. Pointer affordances read literally + * (e.g. 'Alt+click', 'Right-click'). */ + keys: string + /** Stable label identifier for the render side to map to display text. This is + * NOT an i18n key — callers already have their own translated strings for + * these actions; this only has to be a consistent, collision-free token. */ + labelKey: string + scope: ShortcutScope +} + +/** Placeholder the registry uses in place of a real modifier symbol. The render + * side replaces it with modKey(process.platform). */ +export const MOD_TOKEN = 'Mod' + +// Enumerated from the live handlers: +// - global keys: App.tsx onKeyDown (⌘1..9 view nav, ⌘/ search, ⌘. pause) and +// the main-process globalShortcut for ⌘⇧M quick marker. +// - timeline keys: lib/timelineKeys.ts resolver (`/`, `?`, `f`, ↑/↓) plus the +// ⌘K palette handler and the pointer affordances (Alt-click solo lane, +// right-click drop marker) rendered in Timeline.tsx's cheatsheet. +export const SHORTCUTS: Shortcut[] = [ + // --- global (available everywhere) --- + { id: 'switch-view', keys: `${MOD_TOKEN}+1..9`, labelKey: 'shortcut.switchView', scope: 'global' }, + { id: 'quick-marker', keys: `${MOD_TOKEN}+Shift+M`, labelKey: 'shortcut.quickMarker', scope: 'global' }, + { id: 'search', keys: `${MOD_TOKEN}+/`, labelKey: 'shortcut.search', scope: 'global' }, + { id: 'pause-recording', keys: `${MOD_TOKEN}+.`, labelKey: 'shortcut.pauseRecording', scope: 'global' }, + + // --- timeline (only meaningful in the Timeline view) --- + { id: 'filter-events', keys: '/', labelKey: 'shortcut.filterEvents', scope: 'timeline' }, + { id: 'toggle-help', keys: '?', labelKey: 'shortcut.toggleHelp', scope: 'timeline' }, + { id: 'focus-chain', keys: 'f', labelKey: 'shortcut.focusChain', scope: 'timeline' }, + { id: 'command-palette', keys: `${MOD_TOKEN}+K`, labelKey: 'shortcut.commandPalette', scope: 'timeline' }, + { id: 'navigate-events', keys: '↑/↓', labelKey: 'shortcut.navigateEvents', scope: 'timeline' }, + { id: 'solo-lane', keys: 'Alt+click', labelKey: 'shortcut.soloLane', scope: 'timeline' }, + { id: 'drop-marker', keys: 'Right-click', labelKey: 'shortcut.dropMarker', scope: 'timeline' } +] + +/** Shortcuts relevant to a scope. 'global' returns only the always-on keys; + * 'timeline' returns those global keys PLUS the timeline-only ones, since the + * Timeline view inherits every global shortcut. Order is preserved (globals + * first) so the render side gets a stable, sensible reading order. */ +export function shortcutsForScope(scope: ShortcutScope): Shortcut[] { + if (scope === 'global') return SHORTCUTS.filter((s) => s.scope === 'global') + return SHORTCUTS.filter((s) => s.scope === 'global' || s.scope === scope) +} + +/** The modifier symbol to substitute for MOD_TOKEN. macOS shows ⌘; everything + * else shows Ctrl. Takes the platform string (e.g. process.platform) so it + * stays a pure function and is trivially testable. */ +export function modKey(platform: string): string { + return platform === 'darwin' ? '⌘' : 'Ctrl' +} diff --git a/src/renderer/src/lib/timelineModes.ts b/src/renderer/src/lib/timelineModes.ts new file mode 100644 index 00000000..25f409ef --- /dev/null +++ b/src/renderer/src/lib/timelineModes.ts @@ -0,0 +1,91 @@ +// One pure derivation for the Timeline's "Active:" row (DESIGN-TIMELINE- +// INTERACTION §T3). The track carries several sticky, non-default modes that each +// HIDE or TRANSFORM events — focus chain, anomaly filter, agent-turn collapse, +// lane solo, hidden lanes, text filter. Left on, they leave the operator staring +// at a near-empty track with no explanation (DESIGN-PRINCIPLES §8: an empty track +// must explain itself). This function turns the current mode state into the exact +// set of dismissible chips the row renders — one per non-default mode, in a fixed +// order — and returns [] when everything is default so the UI drops the row +// entirely instead of drawing an empty bar. +// +// Scope note: follow mode is deliberately absent. It auto-scrolls but hides no +// data, so per the design's open-question #3 it stays a header toggle, not a chip +// — and keeping it off TimelineModeState keeps it out of this seam by construction. + +export interface TimelineModeState { + /** focus-chain mode is active (isolates one causal chain) */ + focusActive: boolean + /** anomaly filter is on (only flagged events shown) */ + anomalyOnly: boolean + /** agent turns are collapsed (multi-event turns folded to one) */ + collapseAgentTurns: boolean + /** a single lane is soloed (null = no solo); every other lane is hidden */ + soloLane: string | null + /** how many lanes are hidden by the operator (0 = none) */ + hiddenLaneCount: number + /** the '/' text filter query ('' = no filter) */ + filterQuery: string +} + +export interface ModeChip { + /** stable identity for the mode, drives keying and the chip's icon */ + id: string + /** stable i18n identifier — resolved by the caller, never registered here */ + labelKey: string + /** action id the chip's ✕ dispatches to clear just this mode */ + clearAction: string + /** optional dynamic payload (lane name, hidden count, query text) */ + value?: string +} + +export function activeModes(state: TimelineModeState): ModeChip[] { + const chips: ModeChip[] = [] + + // Fixed order: focus → anomaly → collapse-agent → solo/hidden → filter. The + // order is part of the contract so the row never reshuffles between renders. + if (state.focusActive) { + chips.push({ id: 'focus', labelKey: 'timeline.mode.focus', clearAction: 'clear-focus' }) + } + if (state.anomalyOnly) { + chips.push({ id: 'anomaly', labelKey: 'timeline.mode.anomaly', clearAction: 'clear-anomaly' }) + } + if (state.collapseAgentTurns) { + chips.push({ + id: 'collapse-agent', + labelKey: 'timeline.mode.collapseAgent', + clearAction: 'clear-collapse-agent' + }) + } + + // Solo and hidden-lanes are mutually exclusive in the row: a soloed lane + // already hides every other lane, so the hidden count is redundant noise while + // solo is on. Show the sharper fact (which lane) and suppress the count. + if (state.soloLane !== null) { + chips.push({ + id: 'solo', + labelKey: 'timeline.mode.solo', + clearAction: 'clear-solo', + value: state.soloLane + }) + } else if (state.hiddenLaneCount > 0) { + chips.push({ + id: 'hidden-lanes', + labelKey: 'timeline.mode.hiddenLanes', + clearAction: 'clear-hidden-lanes', + value: String(state.hiddenLaneCount) + }) + } + + // Emptiness is strictly '' — a query of only spaces is an odd but real filter, + // so it still earns a chip and its text is carried verbatim. + if (state.filterQuery !== '') { + chips.push({ + id: 'filter', + labelKey: 'timeline.mode.filter', + clearAction: 'clear-filter', + value: state.filterQuery + }) + } + + return chips +} diff --git a/src/renderer/src/lib/timelineWheel.ts b/src/renderer/src/lib/timelineWheel.ts new file mode 100644 index 00000000..24a16f5d --- /dev/null +++ b/src/renderer/src/lib/timelineWheel.ts @@ -0,0 +1,46 @@ +// One pure decision for what a wheel event does over the Timeline track. It +// replaces the invisible branch in Timeline.tsx's wheel handler where a plain +// wheel silently flipped between horizontal pan and vertical scroll depending +// on whether the lane stack overflowed the viewport — the surprising behaviour +// nobody could see or reason about. Lifting the decision into a pure function of +// (overflow, shiftKey, zoomKey) makes the precedence written down, testable, and +// impossible to get into an ambiguous state; it is also exactly the table that +// T2(b) annotates on-screen for the one row that surprises operators. +// +// Style note: mirrors lib/timelineKeys.ts — a single pure resolver, precedence +// commented row by row, no DOM or event coupling. Callers compute the three +// booleans (overflow = laneStack.scrollHeight > clientHeight + 1) and act on the +// returned mode. + +export interface WheelContext { + /** the lane stack is taller than its viewport: scrollHeight > clientHeight + 1 */ + overflow: boolean + /** the Shift key is held — overrides scroll-y back to horizontal pan */ + shiftKey: boolean + /** the zoom modifier is held: ctrlKey || metaKey (trackpad pinch reports ctrl) */ + zoomKey: boolean +} + +export type WheelMode = + | 'zoom' // cursor-anchored zoom (zoom modifier wins over everything) + | 'pan-x' // horizontal pan of the time axis (the common, unsurprising case) + | 'scroll-y' // vertical scroll to reach lanes clipped below the fold + +export function wheelMode(ctx: WheelContext): WheelMode { + // The zoom modifier wins unconditionally — shift and overflow are irrelevant + // once the operator is asking to zoom. This is the top row of the T2 matrix. + if (ctx.zoomKey) return 'zoom' + + // With no overflow the stack already fits, so vertical scroll is meaningless; + // a plain wheel always pans the time axis. This is the common case, unchanged. + if (!ctx.overflow) return 'pan-x' + + // Overflow from here down. Shift is the explicit escape hatch: hold it to keep + // panning the time axis instead of scrolling the clipped lanes into view. + if (ctx.shiftKey) return 'pan-x' + + // Overflow, no modifier: the one surprising row. A plain wheel scrolls the lane + // stack so the operator can reach lanes clipped below the fold — and T2(b)'s + // inline hint appears precisely here to explain the gesture as it happens. + return 'scroll-y' +} diff --git a/test/empty-state.test.ts b/test/empty-state.test.ts new file mode 100644 index 00000000..dcf3ea01 --- /dev/null +++ b/test/empty-state.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import en from '../src/renderer/src/i18n/en.json' +import { emptyStateFor } from '../src/renderer/src/lib/emptyState' +import type { EmptyView } from '../src/renderer/src/lib/emptyState' + +// emptyStateFor is the pure decision seam behind UX-AUDIT F4: given a view and +// the capture context, it returns which existing i18n keys describe the empty +// state and what the single call-to-action should be. It reuses i18n keys that +// already ship (asserted below against en.json) and emits stable, non-i18n +// action identifiers that the wiring step resolves to labels later. + +// Every title/subtitle key this seam returns must already exist in en.json — the +// seam is forbidden from inventing new i18n keys (they would collide with the +// live i18n file). Action labelKeys are deliberately NOT in en.json yet. +const hasKey = (k: string): boolean => Object.prototype.hasOwnProperty.call(en, k) + +const ALL_VIEWS: EmptyView[] = ['timeline', 'screenshots', 'targets', 'loot', 'marks', 'transcript'] + +describe('emptyStateFor — reuses existing i18n keys', () => { + for (const captureDark of [false, true]) { + for (const view of ALL_VIEWS) { + it(`${view} (captureDark=${captureDark}) titleKey + subtitleKey exist in en.json`, () => { + const m = emptyStateFor(view, { captureDark }) + expect(hasKey(m.titleKey), `titleKey ${m.titleKey} missing from en.json`).toBe(true) + expect(hasKey(m.subtitleKey), `subtitleKey ${m.subtitleKey} missing from en.json`).toBe(true) + }) + } + } + + // Post-wiring (Wave 2-B): the action labelKeys now exist in en.json — the + // views render them via t(). This guards i18n completeness for every CTA the + // seam can emit, so a new action target can't ship without its copy. + it('every action labelKey resolves to an i18n key', () => { + for (const view of ALL_VIEWS) { + for (const captureDark of [false, true]) { + const a = emptyStateFor(view, { captureDark }).action + if (a) expect(hasKey(a.labelKey), `${a.labelKey} missing from en.json`).toBe(true) + } + } + }) +}) + +describe('emptyStateFor — per-view mapping (light capture)', () => { + const ctx = { captureDark: false } + + it('timeline: reuses noEvents/noEventsDesc and has NO action until events arrive', () => { + const m = emptyStateFor('timeline', ctx) + expect(m.titleKey).toBe('timeline.noEvents') + expect(m.subtitleKey).toBe('timeline.noEventsDesc') + expect(m.action).toBeUndefined() + }) + + it('screenshots: reuses empty/emptyDesc and CTAs to capture now', () => { + const m = emptyStateFor('screenshots', ctx) + expect(m.titleKey).toBe('screenshots.empty') + expect(m.subtitleKey).toBe('screenshots.emptyDesc') + expect(m.action).toEqual({ labelKey: 'empty.action.captureNow', target: 'screenshot' }) + }) + + it('targets: reuses empty/subtitle and CTAs to the doc', () => { + const m = emptyStateFor('targets', ctx) + expect(m.titleKey).toBe('targets.empty') + expect(m.subtitleKey).toBe('targets.subtitle') + expect(m.action).toEqual({ labelKey: 'empty.action.learnMore', target: 'doc' }) + }) + + it('loot: reuses empty/emptyDesc and CTAs to set up capture', () => { + const m = emptyStateFor('loot', ctx) + expect(m.titleKey).toBe('loot.empty') + expect(m.subtitleKey).toBe('loot.emptyDesc') + expect(m.action).toEqual({ labelKey: 'empty.action.setupCapture', target: 'dashboard' }) + }) + + it('marks: reuses empty/placeholderSub and CTAs to the mark shortcut', () => { + const m = emptyStateFor('marks', ctx) + expect(m.titleKey).toBe('marks.empty') + expect(m.subtitleKey).toBe('marks.placeholderSub') + expect(m.action).toEqual({ labelKey: 'empty.action.mark', target: 'marker' }) + }) + + it('transcript: reuses transcript.empty + a shared events subtitle and CTAs to the doc', () => { + const m = emptyStateFor('transcript', ctx) + expect(m.titleKey).toBe('transcript.empty') + expect(m.subtitleKey).toBe('timeline.noEventsDesc') + expect(m.action).toEqual({ labelKey: 'empty.action.learnMore', target: 'doc' }) + }) +}) + +describe('emptyStateFor — capture-dark variant redirects to set up capture', () => { + const ctx = { captureDark: true } + + it('timeline: dark timeline now has a set-up-capture CTA (nothing will arrive otherwise)', () => { + const m = emptyStateFor('timeline', ctx) + expect(m.titleKey).toBe('timeline.noEvents') + expect(m.action).toEqual({ labelKey: 'empty.action.setupCapture', target: 'dashboard' }) + }) + + it('loot/targets/transcript/marks all redirect to dashboard when capture is dark', () => { + for (const view of ['loot', 'targets', 'transcript', 'marks'] as EmptyView[]) { + const m = emptyStateFor(view, ctx) + expect(m.action, `${view} should route to setup capture when dark`).toEqual({ + labelKey: 'empty.action.setupCapture', + target: 'dashboard' + }) + } + }) + + it('screenshots keeps its capture-now CTA even when dark (it IS a capture source)', () => { + const m = emptyStateFor('screenshots', ctx) + expect(m.action).toEqual({ labelKey: 'empty.action.captureNow', target: 'screenshot' }) + }) + + it('titleKey/subtitleKey are unaffected by captureDark', () => { + for (const view of ALL_VIEWS) { + const light = emptyStateFor(view, { captureDark: false }) + const dark = emptyStateFor(view, { captureDark: true }) + expect(dark.titleKey).toBe(light.titleKey) + expect(dark.subtitleKey).toBe(light.subtitleKey) + } + }) +}) diff --git a/test/settings-search.test.ts b/test/settings-search.test.ts new file mode 100644 index 00000000..b6fc8af0 --- /dev/null +++ b/test/settings-search.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { matchGroups } from '../src/renderer/src/lib/settingsSearch' +import type { SettingsGroupIndex } from '../src/renderer/src/lib/settingsSearch' + +// Settings is 8 tabs / 34 FieldGroups / 234 i18n keys with no way to find a +// setting except remembering its tab (UX-BACKLOG F3). matchGroups is the pure +// seam behind the filter box: given the operator's query and a static index of +// every group (tab + title + the labels inside it), it returns the groups whose +// title OR any label contains the query, case-insensitively, in original order. +// An empty query means "no filter" — the whole index passes through untouched so +// the component can fall back to its normal tabbed view. Keeping the matcher +// pure means the "typing 'exclude' finds the scope exclusion list" behaviour is +// written down and tested, not buried in a 2,681-line component. + +const INDEX: SettingsGroupIndex[] = [ + { tab: 'general', groupId: 'engagement', title: 'Engagement', labels: ['ID', 'Name'] }, + { tab: 'scope', groupId: 'excluded', title: 'Excluded Targets', labels: ['Out-of-scope host'] }, + { tab: 'network', groupId: 'polling', title: 'Polling', labels: ['Check interval', 'IP mode'] } +] + +describe('matchGroups', () => { + it('returns the whole index when the query is empty (no filter)', () => { + expect(matchGroups('', INDEX)).toEqual(INDEX) + }) + + it('treats a whitespace-only query as empty and returns everything', () => { + expect(matchGroups(' ', INDEX)).toEqual(INDEX) + }) + + it('matches on the group title', () => { + const hits = matchGroups('polling', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['polling']) + }) + + it('matches on a field label inside the group', () => { + const hits = matchGroups('interval', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['polling']) + }) + + it('is case-insensitive over both title and labels', () => { + expect(matchGroups('ENGAGEMENT', INDEX).map((g) => g.groupId)).toEqual(['engagement']) + expect(matchGroups('ip MODE', INDEX).map((g) => g.groupId)).toEqual(['polling']) + }) + + it('surfaces the scope exclusion group regardless of tab (the F3 acceptance case)', () => { + const hits = matchGroups('exclude', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['excluded']) + expect(hits[0].tab).toBe('scope') + }) + + it('returns [] when nothing matches', () => { + expect(matchGroups('nonexistent-xyz', INDEX)).toEqual([]) + }) + + it('preserves the original order of the index for multi-hit queries', () => { + // "e" appears in Engagement (title), Excluded (title) and Polling + // (label "Check interval") — all three, so order is observable. + const hits = matchGroups('e', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['engagement', 'excluded', 'polling']) + }) +}) diff --git a/test/shortcuts.test.ts b/test/shortcuts.test.ts new file mode 100644 index 00000000..4b77edd0 --- /dev/null +++ b/test/shortcuts.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import { SHORTCUTS, shortcutsForScope, modKey, type Shortcut } from '../src/renderer/src/lib/shortcuts' + +describe('shortcuts registry', () => { + it('every entry has id/keys/labelKey/scope', () => { + for (const s of SHORTCUTS) { + expect(typeof s.id).toBe('string') + expect(s.id.length).toBeGreaterThan(0) + expect(typeof s.keys).toBe('string') + expect(s.keys.length).toBeGreaterThan(0) + expect(typeof s.labelKey).toBe('string') + expect(s.labelKey.length).toBeGreaterThan(0) + expect(['global', 'timeline']).toContain(s.scope) + } + }) + + it('has no duplicate ids', () => { + const ids = SHORTCUTS.map((s) => s.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('covers the real global + timeline shortcuts', () => { + const byId = (id: string): Shortcut | undefined => SHORTCUTS.find((s) => s.id === id) + // global + expect(byId('switch-view')?.scope).toBe('global') + expect(byId('quick-marker')?.scope).toBe('global') + expect(byId('search')?.scope).toBe('global') + expect(byId('pause-recording')?.scope).toBe('global') + // timeline + expect(byId('filter-events')?.scope).toBe('timeline') + expect(byId('toggle-help')?.scope).toBe('timeline') + expect(byId('focus-chain')?.scope).toBe('timeline') + expect(byId('command-palette')?.scope).toBe('timeline') + expect(byId('navigate-events')?.scope).toBe('timeline') + expect(byId('solo-lane')?.scope).toBe('timeline') + expect(byId('drop-marker')?.scope).toBe('timeline') + }) + + it('uses the literal Mod placeholder token, never a resolved symbol', () => { + for (const s of SHORTCUTS) { + expect(s.keys).not.toContain('⌘') + expect(s.keys).not.toContain('Ctrl') + } + expect(byKeys('switch-view')).toBe('Mod+1..9') + expect(byKeys('quick-marker')).toBe('Mod+Shift+M') + expect(byKeys('search')).toBe('Mod+/') + expect(byKeys('pause-recording')).toBe('Mod+.') + expect(byKeys('command-palette')).toBe('Mod+K') + expect(byKeys('filter-events')).toBe('/') + expect(byKeys('toggle-help')).toBe('?') + expect(byKeys('focus-chain')).toBe('f') + expect(byKeys('navigate-events')).toBe('↑/↓') + expect(byKeys('solo-lane')).toBe('Alt+click') + expect(byKeys('drop-marker')).toBe('Right-click') + }) + + function byKeys(id: string): string | undefined { + return SHORTCUTS.find((s) => s.id === id)?.keys + } +}) + +describe('shortcutsForScope', () => { + it("'global' returns only global-scope shortcuts", () => { + const list = shortcutsForScope('global') + expect(list.length).toBeGreaterThan(0) + expect(list.every((s) => s.scope === 'global')).toBe(true) + // no timeline-scope entry leaks in + expect(list.some((s) => s.scope === 'timeline')).toBe(false) + }) + + it("'timeline' returns global + timeline shortcuts", () => { + const list = shortcutsForScope('timeline') + expect(list.some((s) => s.scope === 'global')).toBe(true) + expect(list.some((s) => s.scope === 'timeline')).toBe(true) + // it is a superset of the global scope + expect(list.length).toBe(shortcutsForScope('global').length + SHORTCUTS.filter((s) => s.scope === 'timeline').length) + }) +}) + +describe('modKey', () => { + it("returns ⌘ on darwin", () => { + expect(modKey('darwin')).toBe('⌘') + }) + + it('returns Ctrl on every other platform', () => { + expect(modKey('win32')).toBe('Ctrl') + expect(modKey('linux')).toBe('Ctrl') + expect(modKey('')).toBe('Ctrl') + }) +}) diff --git a/test/timeline-modes.test.ts b/test/timeline-modes.test.ts new file mode 100644 index 00000000..ffd9d1f8 --- /dev/null +++ b/test/timeline-modes.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest' +import { activeModes } from '../src/renderer/src/lib/timelineModes' +import type { TimelineModeState } from '../src/renderer/src/lib/timelineModes' + +// The Timeline carries several sticky, non-default modes that each HIDE or +// TRANSFORM events (focus chain, anomaly filter, agent-turn collapse, lane solo, +// hidden lanes, text filter). Left on, they turn the track near-empty and the +// operator — back from a break — has no idea WHY. activeModes derives the +// "Active:" row (DESIGN-TIMELINE-INTERACTION §T3): one dismissible chip per +// non-default mode, in a fixed order, and NOTHING when everything is default so +// the UI can drop the row entirely. Keeping it pure means the exact chip set is +// written down and testable, not tangled into the header render. + +const S = (over: Partial = {}): TimelineModeState => ({ + focusActive: false, + anomalyOnly: false, + collapseAgentTurns: false, + soloLane: null, + hiddenLaneCount: 0, + filterQuery: '', + ...over +}) + +describe('activeModes', () => { + it('returns [] when every mode is default (no row is drawn)', () => { + expect(activeModes(S())).toEqual([]) + }) + + it('emits a focus chip when the focus chain is on', () => { + const chips = activeModes(S({ focusActive: true })) + expect(chips).toHaveLength(1) + expect(chips[0].id).toBe('focus') + expect(chips[0].labelKey).toBe('timeline.mode.focus') + expect(chips[0].clearAction).toBe('clear-focus') + }) + + it('emits an anomaly chip when the anomaly filter is on', () => { + const chips = activeModes(S({ anomalyOnly: true })) + expect(chips.map((c) => c.id)).toEqual(['anomaly']) + expect(chips[0].labelKey).toBe('timeline.mode.anomaly') + expect(chips[0].clearAction).toBe('clear-anomaly') + }) + + it('emits a collapse-agent chip when agent turns are collapsed', () => { + const chips = activeModes(S({ collapseAgentTurns: true })) + expect(chips.map((c) => c.id)).toEqual(['collapse-agent']) + expect(chips[0].labelKey).toBe('timeline.mode.collapseAgent') + expect(chips[0].clearAction).toBe('clear-collapse-agent') + }) + + it('emits a solo chip carrying the lane name when a lane is soloed', () => { + const chips = activeModes(S({ soloLane: 'shell' })) + expect(chips.map((c) => c.id)).toEqual(['solo']) + expect(chips[0].value).toBe('shell') + expect(chips[0].labelKey).toBe('timeline.mode.solo') + expect(chips[0].clearAction).toBe('clear-solo') + }) + + it('emits a hidden-lanes chip carrying the count when lanes are hidden', () => { + const chips = activeModes(S({ hiddenLaneCount: 4 })) + expect(chips.map((c) => c.id)).toEqual(['hidden-lanes']) + expect(chips[0].value).toBe('4') + expect(chips[0].labelKey).toBe('timeline.mode.hiddenLanes') + expect(chips[0].clearAction).toBe('clear-hidden-lanes') + }) + + it('emits a filter chip carrying the query when the text filter is set', () => { + const chips = activeModes(S({ filterQuery: 'GET /admin' })) + expect(chips.map((c) => c.id)).toEqual(['filter']) + expect(chips[0].value).toBe('GET /admin') + expect(chips[0].labelKey).toBe('timeline.mode.filter') + expect(chips[0].clearAction).toBe('clear-filter') + }) + + it('solo and hidden-lanes are mutually exclusive — solo wins when both apply', () => { + // A soloed lane logically hides every other lane, so the hiddenLaneCount is + // redundant noise while solo is on; the chip row shows the sharper fact. + const chips = activeModes(S({ soloLane: 'http', hiddenLaneCount: 12 })) + expect(chips.map((c) => c.id)).toEqual(['solo']) + expect(chips[0].value).toBe('http') + }) + + it('falls back to hidden-lanes only when no lane is soloed', () => { + const chips = activeModes(S({ soloLane: null, hiddenLaneCount: 3 })) + expect(chips.map((c) => c.id)).toEqual(['hidden-lanes']) + }) + + it('follow mode is never a chip — it does not hide data (open-question resolution)', () => { + // DESIGN-TIMELINE-INTERACTION open question #3: "following" is auto-scroll, + // not a mode that hides events, so it stays a header toggle. There is no + // follow field on TimelineModeState precisely to keep it out of this seam. + expect(activeModes(S())).toEqual([]) + }) + + it('holds a fixed order: focus → anomaly → collapse-agent → solo → hidden → filter', () => { + const chips = activeModes( + S({ + focusActive: true, + anomalyOnly: true, + collapseAgentTurns: true, + soloLane: 'agent', + hiddenLaneCount: 9, // suppressed by solo + filterQuery: 'sqlmap' + }) + ) + expect(chips.map((c) => c.id)).toEqual([ + 'focus', + 'anomaly', + 'collapse-agent', + 'solo', + 'filter' + ]) + }) + + it('preserves that order when solo is absent so hidden-lanes takes the slot', () => { + const chips = activeModes( + S({ focusActive: true, hiddenLaneCount: 2, filterQuery: 'x' }) + ) + expect(chips.map((c) => c.id)).toEqual(['focus', 'hidden-lanes', 'filter']) + }) + + it('ignores a whitespace-free empty filter but keeps a real query verbatim', () => { + expect(activeModes(S({ filterQuery: '' }))).toEqual([]) + const chips = activeModes(S({ filterQuery: ' ' })) + // A query of only spaces still filters nothing meaningfully, but the seam + // treats emptiness as strictly '' — spaces are a real (if odd) query. + expect(chips.map((c) => c.id)).toEqual(['filter']) + expect(chips[0].value).toBe(' ') + }) +}) diff --git a/test/timeline-wheel.test.ts b/test/timeline-wheel.test.ts new file mode 100644 index 00000000..d2bb60d1 --- /dev/null +++ b/test/timeline-wheel.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { wheelMode, type WheelContext } from '../src/renderer/src/lib/timelineWheel' + +// Helper: build a WheelContext with sane defaults, override what a case cares about. +const ctx = (over: Partial = {}): WheelContext => ({ + overflow: false, + shiftKey: false, + zoomKey: false, + ...over +}) + +describe('wheelMode — the 4-row decision matrix (DESIGN-TIMELINE-INTERACTION T2)', () => { + // Row 1: zoomKey=T, shift=—, overflow=— → 'zoom' + it('zoomKey → zoom (overflow=false, shift=false)', () => { + expect(wheelMode(ctx({ zoomKey: true, overflow: false, shiftKey: false }))).toBe('zoom') + }) + + // Row 2: zoomKey=F, shift=—, overflow=F → 'pan-x' (common case) + it('no zoomKey, no overflow → pan-x (common case)', () => { + expect(wheelMode(ctx({ zoomKey: false, overflow: false, shiftKey: false }))).toBe('pan-x') + }) + + // Row 3: zoomKey=F, shift=T, overflow=T → 'pan-x' (shift overrides scroll) + it('no zoomKey, shift held, overflow → pan-x (shift overrides scroll-y)', () => { + expect(wheelMode(ctx({ zoomKey: false, shiftKey: true, overflow: true }))).toBe('pan-x') + }) + + // Row 4: zoomKey=F, shift=F, overflow=T → 'scroll-y' (the surprising row) + it('no zoomKey, no shift, overflow → scroll-y (the surprising row)', () => { + expect(wheelMode(ctx({ zoomKey: false, shiftKey: false, overflow: true }))).toBe('scroll-y') + }) +}) + +describe('wheelMode — zoom precedence (zoom wins over shift & overflow)', () => { + it('zoomKey beats shift', () => { + expect(wheelMode(ctx({ zoomKey: true, shiftKey: true }))).toBe('zoom') + }) + + it('zoomKey beats overflow', () => { + expect(wheelMode(ctx({ zoomKey: true, overflow: true }))).toBe('zoom') + }) + + it('zoomKey beats shift AND overflow together', () => { + expect(wheelMode(ctx({ zoomKey: true, shiftKey: true, overflow: true }))).toBe('zoom') + }) +}) + +describe('wheelMode — shift only matters while overflow is true', () => { + it('shift with no overflow is still the common pan-x path', () => { + expect(wheelMode(ctx({ zoomKey: false, shiftKey: true, overflow: false }))).toBe('pan-x') + }) +}) From d8494c2247082ba6fe67218c2112dd8c134a82f8 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 19:53:42 +0800 Subject: [PATCH 03/47] =?UTF-8?q?feat(timeline):=20Phase=20B=20=E2=80=94?= =?UTF-8?q?=20phase/target=20two-tier=20model=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, unit-tested foundation for the timeline reconstruction axis (DESIGN- PRINCIPLES §3/§8/§9/§11/§12). Not yet wired — these are the seams Phase C's target/phase axis refactor builds on. - phaseSegments — authoritative phase segmentation from operator phase-markers (§11 "乙": facts, on-chain, attributable). phaseMarkersFromEvents extracts them. - phaseInference — the suggestion layer (§11 "甲"/§3): event-type→phase heuristic emitted as inferred, confidence-scored, sourceEventId-anchored suggestions, never authoritative. Adjacent-same-phase runs collapse to boundaries. - targetGrouping — groups events by targetId with an explicit "untargeted" bucket that always sorts last (§12: never orphan, never guess). Verification: 40 tests green (11 + 18 + 11) across the three seam test files. Renderer-side, pure, no DB/i18n/component dependency. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/lib/phaseInference.ts | 130 +++++++++++++++++++ src/renderer/src/lib/phaseSegments.ts | 97 ++++++++++++++ src/renderer/src/lib/targetGrouping.ts | 77 +++++++++++ test/phase-inference.test.ts | 170 +++++++++++++++++++++++++ test/phase-segments.test.ts | 136 ++++++++++++++++++++ test/target-grouping.test.ts | 105 +++++++++++++++ 6 files changed, 715 insertions(+) create mode 100644 src/renderer/src/lib/phaseInference.ts create mode 100644 src/renderer/src/lib/phaseSegments.ts create mode 100644 src/renderer/src/lib/targetGrouping.ts create mode 100644 test/phase-inference.test.ts create mode 100644 test/phase-segments.test.ts create mode 100644 test/target-grouping.test.ts diff --git a/src/renderer/src/lib/phaseInference.ts b/src/renderer/src/lib/phaseInference.ts new file mode 100644 index 00000000..99756b90 --- /dev/null +++ b/src/renderer/src/lib/phaseInference.ts @@ -0,0 +1,130 @@ +// Phase-inference seam — DESIGN-PRINCIPLES §3, the SUGGESTION layer. +// +// This module is NEVER authoritative. §3 draws a hard line: RedLog records +// facts (an operator marker, or a command that primary capture proves ran), and +// treats every *interpretation* — including "which engagement phase is this?" — +// as a suggestion. So everything this function emits is `inferred`: it is +// confidence-scored, carries the id of the event that triggered it, and is meant +// to be promoted by the operator into a real `marker` with one click. It must +// never mutate an event or be shown as ground truth (§3: dashed segment, not +// solid). Downgrade, don't assert. +// +// It is deliberately NOT an opinionated ATT&CK engine. It is a light, defensible +// "event type → phase signal" heuristic that stays conservative on purpose: +// where a mapping is weak (an untagged transfer, a page load), the confidence is +// `low`, and unrecognised event types produce nothing at all. The seam marks +// phase *boundaries* — where a phase begins — not every event inside a phase. + +export type Confidence = 'low' | 'medium' | 'high' + +export interface EventLike { + id: string + timestamp: number + agentType: string + data?: { subtype?: string; direction?: string } +} + +export interface InferredPhase { + ts: number + phase: string + confidence: Confidence + reason: string + /** id of the event that triggered this suggestion — the promotion anchor. */ + sourceEventId: string +} + +// The signal a single event carries about the current phase. Kept separate from +// InferredPhase because ts/sourceEventId belong to the event, not the mapping. +interface PhaseSignal { + phase: string + confidence: Confidence + reason: string +} + +// Map one event to a phase signal, or null if the event type carries no signal. +// Conservative by construction: a value only appears here if it is defensible in +// a report, and ambiguous cases resolve to the lower-confidence reading. +function signalFor(ev: EventLike): PhaseSignal | null { + switch (ev.agentType) { + case 'pivot': + // A tunnel/pivot is an unambiguous lateral-movement act — high confidence. + return { phase: 'lateral-movement', confidence: 'high', reason: 'pivot/tunnel detected' } + + case 'file_transfer': { + // Direction decides the phase. Treat an explicit exfil direction OR an + // exfil-flavoured subtype as data leaving; everything else (ingress, or an + // untagged transfer) is conservatively read as delivery. + const dir = ev.data?.direction ?? '' + const sub = ev.data?.subtype ?? '' + const looksExfil = dir.includes('exfil') || sub.includes('exfil') + if (looksExfil) { + return { phase: 'exfil', confidence: 'medium', reason: 'outbound file transfer' } + } + return { phase: 'delivery', confidence: 'low', reason: 'inbound file transfer' } + } + + case 'cleanup': + // Anti-forensics is a distinct, high-signal act (NIST SP 800-86). + return { phase: 'anti-forensics', confidence: 'high', reason: 'cleanup/anti-forensics action' } + + case 'credential_use': + case 'loot': + return { + phase: 'credential-access', + confidence: 'medium', + reason: 'credential use or loot detected' + } + + case 'scanner': + case 'dns': + case 'http_navigation': + // Weak, common signals — recon at low confidence only. + return { phase: 'recon', confidence: 'low', reason: 'scan/dns/navigation activity' } + + default: + return null + } +} + +/** + * Derive phase-boundary suggestions from a list of events (§3 suggestion layer). + * + * Processes events in ascending ts order (the input is sorted first, so callers + * need not pre-sort). Collapses adjacent runs of the same phase to a single + * boundary — we mark where a phase *starts*, not every event inside it — so a + * suggestion's ts and sourceEventId point at the FIRST event of its run. A phase + * that recurs after a different phase intervenes is a new, kept boundary. + * + * Empty input → []. Nothing here is authoritative; see the file header. + */ +export function inferPhaseSuggestions(events: EventLike[]): InferredPhase[] { + if (events.length === 0) return [] + + // Sort a copy — never mutate the caller's array. Stable enough for ties: we + // only need monotonic ts ordering to find boundaries. + const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp) + + const out: InferredPhase[] = [] + let prevPhase: string | null = null + + for (const ev of sorted) { + const signal = signalFor(ev) + if (signal === null) continue // unmapped event type: no suggestion + + // Adjacent de-dup: only the first event of a run of the same phase becomes a + // boundary. An unmapped event between two same-phase events does not break + // the run — prevPhase is untouched when we `continue` above. + if (signal.phase === prevPhase) continue + + out.push({ + ts: ev.timestamp, + phase: signal.phase, + confidence: signal.confidence, + reason: signal.reason, + sourceEventId: ev.id + }) + prevPhase = signal.phase + } + + return out +} diff --git a/src/renderer/src/lib/phaseSegments.ts b/src/renderer/src/lib/phaseSegments.ts new file mode 100644 index 00000000..a2be25c3 --- /dev/null +++ b/src/renderer/src/lib/phaseSegments.ts @@ -0,0 +1,97 @@ +// The AUTHORITATIVE half of phase reconstruction. DESIGN-PRINCIPLES §3 says RedLog +// records facts and never writes interpretation as fact: authoritative assertions +// come from exactly two places — the operator (a marker) or primary capture. §8/§11 +// make phase an operator axis of the review timeline, and split its derivation in two. +// This module is the "乙" half: phase segments derived *purely* from the operator's +// own phase-change markers. Each such marker is one deliberate, on-chain, attributable +// claim about where a phase begins — so this seam is authoritative and drawn solid. +// The other half (auto-inferred phase, rendered as dashed suggestions) is a separate +// seam and deliberately lives nowhere near this file. +// +// Everything here is a pure value → value transform: no clock, no event store, no +// React. That keeps the authoritative segmentation fully testable and impossible to +// contaminate with live-monitor state (§8: the timeline is a reconstruction surface). + +/** One operator phase-change claim: at `ts`, the engagement entered `phase`. */ +export interface PhaseMarker { + ts: number + phase: string + /** id of the originating on-chain marker event — this is what makes the claim attributable. */ + markerId: string +} + +/** A contiguous stretch of the timeline the operator has assigned to one phase. */ +export interface PhaseSegment { + phase: string + start: number + /** where the phase ends; `null` means still in progress (no later marker, no known domain end). */ + end: number | null + /** the marker that opened this segment — carried through so the UI can attribute/link it. */ + markerId: string +} + +// Pure segmentation. Markers are sorted by ts (callers often hand them to us in +// event-store order, but a segmentation is a statement about time). Each marker opens +// a segment whose end is the NEXT marker's ts, so the result is contiguous and +// non-overlapping. The final segment ends at `domainEnd` when the caller knows the +// engagement's right edge (e.g. the last event's ts), otherwise it is left open (null). +// +// Adjacent markers naming the same phase are intentionally NOT merged: re-asserting a +// phase is a distinct operator act with its own markerId and its own chain event, and +// collapsing them would erase that the operator drew the boundary. Two claims, two +// segments — even if the phase string is identical. +export function phaseSegments(markers: PhaseMarker[], domainEnd?: number): PhaseSegment[] { + if (markers.length === 0) return [] + + // Copy before sorting — never mutate the caller's array. + const sorted = [...markers].sort((a, b) => a.ts - b.ts) + + return sorted.map((m, i) => { + const next = sorted[i + 1] + // Interior segments close at the next claim's ts; the last one closes at the known + // domain end, or stays open (null) when the end is unknown. + const end = next ? next.ts : domainEnd ?? null + return { phase: m.phase, start: m.ts, end, markerId: m.markerId } + }) +} + +/** The shape of a raw event this seam needs — a structural subset of the full event row. */ +export interface MarkerEventLike { + id: string + timestamp: number + agentType: string + data?: { + subtype?: string + phase?: string + category?: string + title?: string + } +} + +// Extract the phase-change markers from a stream of raw events — the input side of the +// authoritative seam. We keep only `marker` events (operator assertions, §3) whose data +// signals a phase change, and drop everything else: shell commands, plain finding-note +// markers, inferred detections. A phase change is signalled any of three equivalent ways +// so callers/importers aren't forced into one field: an explicit `subtype: 'phase'`, a +// populated `phase`, or `category: 'phase'`. +// +// The phase name prefers the canonical `data.phase`; a phase marker created through the +// generic marker UI may only carry a `title`, so title is the fallback. Output is sorted +// by ts so it can be fed straight into `phaseSegments`. +export function phaseMarkersFromEvents(events: MarkerEventLike[]): PhaseMarker[] { + return events + .filter((e) => { + if (e.agentType !== 'marker') return false + const d = e.data + if (!d) return false + return d.subtype === 'phase' || d.phase !== undefined || d.category === 'phase' + }) + .map((e) => ({ + ts: e.timestamp, + // `?? ''` is a defensive floor: a phase marker with neither field is malformed, + // but we still return a (empty-named) marker rather than throwing on bad data. + phase: e.data?.phase ?? e.data?.title ?? '', + markerId: e.id + })) + .sort((a, b) => a.ts - b.ts) +} diff --git a/src/renderer/src/lib/targetGrouping.ts b/src/renderer/src/lib/targetGrouping.ts new file mode 100644 index 00000000..5566a6f3 --- /dev/null +++ b/src/renderer/src/lib/targetGrouping.ts @@ -0,0 +1,77 @@ +// The target axis for the reconstruction timeline (DESIGN-PRINCIPLES §8/§9): review +// asks "what happened to *this target*", so the timeline organises by target instead +// of by source-type lane. This pure seam is that axis's data source — it turns a flat +// event list into per-target groups, ordered the way the axis draws them. +// +// §12: EXPLICIT untargeted bucket — never orphan, never guess. Attribution here is +// conservative: an event only joins a target group when it carries a concrete targetId. +// Anything ambiguous — targetId null, undefined, or '' — is NOT guessed onto a nearby +// target; it drops into one explicit `target: null` "untargeted" bucket. That bucket +// is a real, addressable group (so nothing is orphaned) and always sorts LAST, so it +// reads as the residual pile rather than as just another target. + +export interface EventLike { + id: string + timestamp: number + targetId?: string | null +} + +export interface TargetGroup { + /** The concrete targetId, or null for the explicit untargeted bucket (§12). */ + target: string | null + /** Member event ids, ascending by timestamp. */ + eventIds: string[] + /** Timestamp of the group's earliest event. */ + firstTs: number + /** Timestamp of the group's latest event. */ + lastTs: number +} + +const UNTARGETED = null + +// null/undefined/'' all mean "no concrete target" → the untargeted bucket. Anything +// else is taken as an authoritative target id. +function targetKeyOf(event: EventLike): string | null { + const t = event.targetId + if (t === null || t === undefined || t === '') return UNTARGETED + return t +} + +/** + * Group events by targetId. Events without a concrete target collapse into a single + * explicit `target: null` bucket. Targeted groups are ordered by their earliest event + * timestamp (ascending); the untargeted bucket, if present, always comes last. Within + * every group eventIds are sorted ascending by timestamp, and firstTs/lastTs bound the + * group's time range. Empty input yields []. + */ +export function groupByTarget(events: EventLike[]): TargetGroup[] { + // Bucket events by target while preserving nothing but membership; sorting happens + // once at the end so unsorted input still produces correct group and member order. + const buckets = new Map() + for (const event of events) { + const key = targetKeyOf(event) + const bucket = buckets.get(key) + if (bucket) bucket.push(event) + else buckets.set(key, [event]) + } + + const groups: TargetGroup[] = [] + for (const [target, members] of buckets) { + const sorted = [...members].sort((a, b) => a.timestamp - b.timestamp) + groups.push({ + target, + eventIds: sorted.map((e) => e.id), + firstTs: sorted[0].timestamp, + lastTs: sorted[sorted.length - 1].timestamp + }) + } + + // Group order: untargeted bucket always last (§12); targeted groups by firstTs asc. + groups.sort((a, b) => { + if (a.target === UNTARGETED) return 1 + if (b.target === UNTARGETED) return -1 + return a.firstTs - b.firstTs + }) + + return groups +} diff --git a/test/phase-inference.test.ts b/test/phase-inference.test.ts new file mode 100644 index 00000000..d3592628 --- /dev/null +++ b/test/phase-inference.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { + inferPhaseSuggestions, + type EventLike, + type InferredPhase +} from '../src/renderer/src/lib/phaseInference' + +// This is the DESIGN-PRINCIPLES §3 SUGGESTION layer — never authoritative. +// Every property asserted here is a promise the seam makes to the operator: +// the output is `inferred`, confidence-scored, and carries a sourceEventId so a +// single click can promote a suggestion into an authoritative marker. These +// tests pin the conservative event-type → phase mapping and the boundary logic +// (adjacent de-dup, sort-before-process), not any ATT&CK opinion. + +// Tiny builder so each case reads as "an event of this type, at this ts". +let seq = 0 +function ev(agentType: string, ts: number, data?: EventLike['data']): EventLike { + return { id: `e${seq++}`, timestamp: ts, agentType, data } +} + +describe('inferPhaseSuggestions — mapping table', () => { + it('maps pivot → lateral-movement, high', () => { + const [s] = inferPhaseSuggestions([ev('pivot', 10)]) + expect(s.phase).toBe('lateral-movement') + expect(s.confidence).toBe('high') + expect(s.reason).toBe('pivot/tunnel detected') + }) + + it('maps file_transfer with exfil direction → exfil, medium', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10, { direction: 'exfil' })]) + expect(s.phase).toBe('exfil') + expect(s.confidence).toBe('medium') + }) + + it('maps file_transfer with exfil subtype → exfil, medium', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10, { subtype: 'exfil' })]) + expect(s.phase).toBe('exfil') + expect(s.confidence).toBe('medium') + }) + + it('maps ingress file_transfer → delivery, low', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10, { direction: 'ingress' })]) + expect(s.phase).toBe('delivery') + expect(s.confidence).toBe('low') + }) + + it('treats a file_transfer with no direction as delivery (conservative default)', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10)]) + expect(s.phase).toBe('delivery') + expect(s.confidence).toBe('low') + }) + + it('maps cleanup → anti-forensics, high', () => { + const [s] = inferPhaseSuggestions([ev('cleanup', 10)]) + expect(s.phase).toBe('anti-forensics') + expect(s.confidence).toBe('high') + }) + + it('maps credential_use → credential-access, medium', () => { + const [s] = inferPhaseSuggestions([ev('credential_use', 10)]) + expect(s.phase).toBe('credential-access') + expect(s.confidence).toBe('medium') + }) + + it('maps loot → credential-access, medium', () => { + const [s] = inferPhaseSuggestions([ev('loot', 10)]) + expect(s.phase).toBe('credential-access') + expect(s.confidence).toBe('medium') + }) + + it('maps scanner / dns / http_navigation → recon, low', () => { + for (const t of ['scanner', 'dns', 'http_navigation']) { + const [s] = inferPhaseSuggestions([ev(t, 10)]) + expect(s.phase).toBe('recon') + expect(s.confidence).toBe('low') + } + }) + + it('produces no suggestion for unmapped event types', () => { + expect(inferPhaseSuggestions([ev('marker', 10)])).toEqual([]) + expect(inferPhaseSuggestions([ev('screenshot', 10)])).toEqual([]) + expect(inferPhaseSuggestions([ev('system', 10)])).toEqual([]) + }) +}) + +describe('inferPhaseSuggestions — boundary logic', () => { + it('returns [] for empty input', () => { + expect(inferPhaseSuggestions([])).toEqual([]) + }) + + it('collapses adjacent duplicate phases to the first transition point only', () => { + // Three recon-ish events in a row are one recon boundary, not three. We mark + // where the phase STARTS, not every event inside it. + const out = inferPhaseSuggestions([ + ev('dns', 10), + ev('scanner', 20), + ev('http_navigation', 30), + ev('pivot', 40) + ]) + expect(out.map((s) => s.phase)).toEqual(['recon', 'lateral-movement']) + // the recon boundary is the first (dns) event, at ts 10 + expect(out[0].ts).toBe(10) + }) + + it('re-enters a phase after leaving it (non-adjacent repeats are kept)', () => { + const out = inferPhaseSuggestions([ + ev('dns', 10), // recon + ev('pivot', 20), // lateral-movement + ev('scanner', 30) // recon again — a new boundary, not a duplicate + ]) + expect(out.map((s) => s.phase)).toEqual(['recon', 'lateral-movement', 'recon']) + }) + + it('sorts unsorted input by ts before processing', () => { + const out = inferPhaseSuggestions([ + ev('pivot', 40), + ev('dns', 10), + ev('scanner', 20), + ev('cleanup', 50) + ]) + expect(out.map((s) => s.phase)).toEqual(['recon', 'lateral-movement', 'anti-forensics']) + expect(out.map((s) => s.ts)).toEqual([10, 40, 50]) + }) + + it('orders a multi-phase engagement by ts', () => { + const out = inferPhaseSuggestions([ + ev('dns', 10), + ev('credential_use', 20), + ev('pivot', 30), + ev('file_transfer', 40, { direction: 'exfil' }), + ev('cleanup', 50) + ]) + expect(out.map((s) => s.phase)).toEqual([ + 'recon', + 'credential-access', + 'lateral-movement', + 'exfil', + 'anti-forensics' + ]) + }) +}) + +describe('inferPhaseSuggestions — suggestion-layer contract', () => { + it('carries the triggering event id as sourceEventId', () => { + const dns = ev('dns', 10) + const pivot = ev('pivot', 20) + const out = inferPhaseSuggestions([dns, pivot]) + expect(out[0].sourceEventId).toBe(dns.id) + expect(out[1].sourceEventId).toBe(pivot.id) + }) + + it('sourceEventId of a collapsed run points at the first event of the run', () => { + const first = ev('dns', 10) + const second = ev('scanner', 20) + const out = inferPhaseSuggestions([first, second]) + expect(out).toHaveLength(1) + expect(out[0].sourceEventId).toBe(first.id) + }) + + it('every suggestion is confidence-scored with a valid tier', () => { + const out: InferredPhase[] = inferPhaseSuggestions([ + ev('pivot', 10), + ev('loot', 20), + ev('dns', 30) + ]) + for (const s of out) { + expect(['low', 'medium', 'high']).toContain(s.confidence) + } + }) +}) diff --git a/test/phase-segments.test.ts b/test/phase-segments.test.ts new file mode 100644 index 00000000..6be40038 --- /dev/null +++ b/test/phase-segments.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { phaseSegments, phaseMarkersFromEvents } from '../src/renderer/src/lib/phaseSegments' +import type { PhaseMarker, MarkerEventLike } from '../src/renderer/src/lib/phaseSegments' + +// DESIGN-PRINCIPLES §3 (two-tier attributes) draws the line: an operator marker is +// an AUTHORITATIVE assertion, an inferred detection is only a suggestion. §11 splits +// phase reconstruction along that same line — this seam is the "乙" half, the +// authoritative one: phase segmentation derived *purely* from the operator's own +// phase-change markers (each marker is one on-chain, attributable claim). The +// auto-inference half (dashed suggestions) is a different seam and is not tested here. +// +// Two pure functions, so the whole derivation is a value → value transform with no +// clock, no store and no React: phaseMarkersFromEvents (raw events → the phase-change +// markers) and phaseSegments (markers → contiguous, non-overlapping segments). + +const M = (ts: number, phase: string, markerId: string): PhaseMarker => ({ ts, phase, markerId }) + +describe('phaseSegments', () => { + it('returns nothing for no markers — no operator claim, no authoritative phase', () => { + // The whole point of this half is that phase is only asserted by the operator. + // With zero markers there is simply no authoritative segmentation to draw. + expect(phaseSegments([])).toEqual([]) + }) + + it('a single marker opens one open-ended segment when no domain end is known', () => { + // One claim, still in force: it runs from its own ts to "now / unknown", which we + // model as end=null (an in-progress phase) rather than inventing an end. + expect(phaseSegments([M(100, 'recon', 'a')])).toEqual([ + { phase: 'recon', start: 100, end: null, markerId: 'a' } + ]) + }) + + it('a single marker is closed at domainEnd when the domain end is provided', () => { + // Given the engagement's known end (e.g. last event ts), the final phase is bounded + // there instead of dangling — this is what lets the rendered band have a right edge. + expect(phaseSegments([M(100, 'recon', 'a')], 500)).toEqual([ + { phase: 'recon', start: 100, end: 500, markerId: 'a' } + ]) + }) + + it('multiple markers cut back-to-back segments, last one open-ended', () => { + // Each marker opens a segment; its end is the NEXT marker's ts, so the segments are + // contiguous and non-overlapping. The last one is left open (end=null) with no domainEnd. + expect(phaseSegments([M(0, 'recon', 'a'), M(10, 'exploit', 'b'), M(25, 'exfil', 'c')])).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'exploit', start: 10, end: 25, markerId: 'b' }, + { phase: 'exfil', start: 25, end: null, markerId: 'c' } + ]) + }) + + it('closes the final segment at domainEnd when provided', () => { + expect(phaseSegments([M(0, 'recon', 'a'), M(10, 'exploit', 'b')], 40)).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'exploit', start: 10, end: 40, markerId: 'b' } + ]) + }) + + it('sorts unordered marker input by ts before segmenting', () => { + // Callers may hand us markers in event-store order, not time order. Segmentation is + // a statement about time, so we sort first; the output is always chronological. + expect(phaseSegments([M(25, 'exfil', 'c'), M(0, 'recon', 'a'), M(10, 'exploit', 'b')])).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'exploit', start: 10, end: 25, markerId: 'b' }, + { phase: 'exfil', start: 25, end: null, markerId: 'c' } + ]) + }) + + it('does NOT merge adjacent markers that name the same phase', () => { + // Each marker is a distinct operator act — re-asserting "recon" is a separate, + // separately-attributable claim (its own markerId, its own on-chain event). Merging + // them would erase that the operator marked the boundary twice, so we keep both. + expect(phaseSegments([M(0, 'recon', 'a'), M(10, 'recon', 'b')])).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'recon', start: 10, end: null, markerId: 'b' } + ]) + }) +}) + +describe('phaseMarkersFromEvents', () => { + const ev = ( + id: string, + timestamp: number, + agentType: string, + data?: MarkerEventLike['data'] + ): MarkerEventLike => ({ id, timestamp, agentType, data }) + + it('picks only marker events whose data signals a phase change', () => { + // Three independent ways an event says "this is a phase change": an explicit + // subtype, a phase field, or a category. Any one qualifies. + const events: MarkerEventLike[] = [ + ev('1', 10, 'marker', { subtype: 'phase', title: 'recon' }), + ev('2', 20, 'marker', { phase: 'exploit' }), + ev('3', 30, 'marker', { category: 'phase', title: 'exfil' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([ + { ts: 10, phase: 'recon', markerId: '1' }, + { ts: 20, phase: 'exploit', markerId: '2' }, + { ts: 30, phase: 'exfil', markerId: '3' } + ]) + }) + + it('ignores non-marker events and markers that are not phase changes', () => { + // A shell command, and a plain finding-note marker (no phase signal) must not + // become phase boundaries — only deliberate phase markers segment the timeline. + const events: MarkerEventLike[] = [ + ev('s', 5, 'shell', { subtype: 'command_start' }), + ev('note', 15, 'marker', { subtype: 'finding', title: 'Found an IDOR' }), + ev('p', 25, 'marker', { subtype: 'phase', title: 'recon' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([{ ts: 25, phase: 'recon', markerId: 'p' }]) + }) + + it('prefers data.phase for the phase string and falls back to data.title', () => { + // data.phase is the canonical field; a phase marker created through the generic + // marker UI may only carry a title, so title is the fallback name. + const events: MarkerEventLike[] = [ + ev('a', 10, 'marker', { subtype: 'phase', phase: 'exploit', title: 'ignore me' }), + ev('b', 20, 'marker', { subtype: 'phase', title: 'lateral-movement' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([ + { ts: 10, phase: 'exploit', markerId: 'a' }, + { ts: 20, phase: 'lateral-movement', markerId: 'b' } + ]) + }) + + it('returns markers sorted by ts even when events arrive out of order', () => { + const events: MarkerEventLike[] = [ + ev('late', 30, 'marker', { phase: 'exfil' }), + ev('early', 10, 'marker', { phase: 'recon' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([ + { ts: 10, phase: 'recon', markerId: 'early' }, + { ts: 30, phase: 'exfil', markerId: 'late' } + ]) + }) +}) diff --git a/test/target-grouping.test.ts b/test/target-grouping.test.ts new file mode 100644 index 00000000..bd64be2f --- /dev/null +++ b/test/target-grouping.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { groupByTarget } from '../src/renderer/src/lib/targetGrouping' +import type { EventLike } from '../src/renderer/src/lib/targetGrouping' + +// DESIGN-PRINCIPLES §8/§9 reorganise the timeline around a target axis (source-type +// demotes to a filter, TargetView folds in as the target grouping). This seam is the +// data source for that axis: it groups events by targetId. §12 sets the honesty rule — +// conservative attribution, never guess: any event whose target is null/undefined/'' +// goes into ONE explicit "untargeted" bucket (target: null), never orphaned, never +// guessed onto some other target. That bucket always sorts LAST so it reads as the +// residual pile, not as another real target. + +const E = (id: string, timestamp: number, targetId?: string | null): EventLike => ({ + id, + timestamp, + targetId +}) + +describe('groupByTarget', () => { + it('empty input → []', () => { + expect(groupByTarget([])).toEqual([]) + }) + + it('all events share one target → a single group', () => { + const result = groupByTarget([E('a', 10, 'host1'), E('b', 20, 'host1')]) + expect(result).toEqual([ + { target: 'host1', eventIds: ['a', 'b'], firstTs: 10, lastTs: 20 } + ]) + }) + + it('all events targeted → no untargeted group appears', () => { + const result = groupByTarget([E('a', 10, 'host1'), E('b', 20, 'host2')]) + expect(result.some((g) => g.target === null)).toBe(false) + }) + + it('all events untargeted → exactly one target:null group', () => { + const result = groupByTarget([E('a', 10), E('b', 20, null), E('c', 30, '')]) + expect(result).toEqual([ + { target: null, eventIds: ['a', 'b', 'c'], firstTs: 10, lastTs: 30 } + ]) + }) + + it('multiple targets are ordered by first-event ts (ascending)', () => { + // host2 fires first (ts 5), host1 later (ts 10) → host2 must come first. + const result = groupByTarget([E('a', 10, 'host1'), E('b', 5, 'host2'), E('c', 30, 'host1')]) + expect(result.map((g) => g.target)).toEqual(['host2', 'host1']) + }) + + it('untargeted bucket always sorts last, even when its first event is earliest', () => { + // The untargeted event (ts 1) is the earliest of all, yet the bucket must still + // be last: it is the residual pile, never a real target position. + const result = groupByTarget([E('u', 1), E('a', 10, 'host1'), E('b', 20, 'host2')]) + expect(result.map((g) => g.target)).toEqual(['host1', 'host2', null]) + }) + + it('mixes targeted and untargeted events, untargeted collected into the one bucket', () => { + const result = groupByTarget([ + E('a', 10, 'host1'), + E('u1', 15), + E('b', 20, 'host2'), + E('u2', 25, ''), + E('c', 30, 'host1') + ]) + expect(result).toEqual([ + { target: 'host1', eventIds: ['a', 'c'], firstTs: 10, lastTs: 30 }, + { target: 'host2', eventIds: ['b'], firstTs: 20, lastTs: 20 }, + { target: null, eventIds: ['u1', 'u2'], firstTs: 15, lastTs: 25 } + ]) + }) + + it('empty-string target counts as untargeted, not as a distinct target', () => { + const result = groupByTarget([E('a', 10, ''), E('b', 20, 'host1')]) + expect(result.map((g) => g.target)).toEqual(['host1', null]) + expect(result.find((g) => g.target === null)?.eventIds).toEqual(['a']) + }) + + it('unsorted input: both group order and within-group order come out sorted', () => { + const result = groupByTarget([ + E('c', 30, 'host1'), + E('b', 20, 'host2'), + E('a', 10, 'host1') + ]) + // host1 first (firstTs 10) then host2 (firstTs 20); host1's ids in ts order. + expect(result.map((g) => g.target)).toEqual(['host1', 'host2']) + expect(result[0].eventIds).toEqual(['a', 'c']) + }) + + it('same target scattered across time stays one group (not split by gaps)', () => { + const result = groupByTarget([ + E('a', 10, 'host1'), + E('b', 20, 'host2'), + E('c', 100, 'host1') + ]) + const host1 = result.find((g) => g.target === 'host1') + expect(host1?.eventIds).toEqual(['a', 'c']) + expect(host1).toEqual({ target: 'host1', eventIds: ['a', 'c'], firstTs: 10, lastTs: 100 }) + }) + + it('firstTs/lastTs bound the group even for unsorted input', () => { + const result = groupByTarget([E('c', 30, 'h'), E('a', 5, 'h'), E('b', 50, 'h')]) + expect(result[0].firstTs).toBe(5) + expect(result[0].lastTs).toBe(50) + expect(result[0].eventIds).toEqual(['a', 'c', 'b']) + }) +}) From 20ae4ce4608b0ee704a27ec85e9565942feeda8f Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 19:55:22 +0800 Subject: [PATCH 04/47] =?UTF-8?q?docs:=20Phase=20C=20spec=20=E2=80=94=20ti?= =?UTF-8?q?meline=20reconstruction=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executable spec for the flagship timeline refactor (DESIGN-PRINCIPLES §8/§9): re-axis from source-type lanes to a target lane axis + a phase ribbon overlay, built on the Phase B seams. Covers the toggle-behind incremental strategy (source-default, never big-bang), the T5 seams to extract first, the new timelineAxis/phaseRibbon seams, acceptance criteria, regression guards, risks, and three open questions (phase-as-ribbon-vs-lane, default axis, TargetView fate). Co-Authored-By: Claude Opus 4.8 --- docs/README.md | 1 + docs/SPEC-TIMELINE-AXIS.md | 162 +++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 docs/SPEC-TIMELINE-AXIS.md diff --git a/docs/README.md b/docs/README.md index 6967dd1d..e934b645 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links - **[Timeline UX deep-dive 2026-08-10](UX-TIMELINE-2026-08.md)** — the F2 deep-dive: why the timeline feels unintuitive (invisible, overloaded, context-dependent gestures), what it is *not* (lanes/data are fine), and the T1–T6 simplification plan. - **[UX backlog — ticket specs](UX-BACKLOG-TICKETS.md)** — every finding (F1–F7, T1–T6) broken out into an independently implementable ticket: problem, proposed solution, acceptance criteria, pure test seam, effort, priority order. - **[Timeline interaction redesign draft](DESIGN-TIMELINE-INTERACTION.md)** — concrete UI proposal with ASCII wireframes for the legend (T1), the wheel-mode decision matrix (T2), and the active-modes row (T3), plus the build order. +- **[Timeline axis spec (Phase C)](SPEC-TIMELINE-AXIS.md)** — executable spec for the flagship refactor: re-axis the timeline to target/phase (per DESIGN-PRINCIPLES §8/§9), the safe toggle-behind incremental strategy, the seams to build on the Phase B foundation, acceptance criteria, regression guards, and three open questions. - **[Dev requirements — capture onboarding](DEV-REQUIREMENTS-capture-onboarding.md)** — spec + acceptance criteria for the Capture Readiness onboarding, and the red→green→integrate→cover TDD process every UX change should copy. - **[Timeline I/O visibility](timeline-io-visibility.md)** — design note (proposed). Which sources capture input/output today, where the gaps are, and the `io_ref` sidecar + transcript-view proposal that closes them. diff --git a/docs/SPEC-TIMELINE-AXIS.md b/docs/SPEC-TIMELINE-AXIS.md new file mode 100644 index 00000000..1d9612cd --- /dev/null +++ b/docs/SPEC-TIMELINE-AXIS.md @@ -0,0 +1,162 @@ +# Spec — Timeline Reconstruction Axis (Phase C) + +Written 2026-08-11. Executable spec for the flagship timeline refactor from +`DESIGN-PRINCIPLES.md` §8/§9: re-organise the timeline from a live source-type +firehose into a **reconstruction surface organised by target and phase**. This +is the highest-value and highest-risk item in the UX plan — it touches the +4,100-line `Timeline.tsx` lane model — so it is specced before a line is wired. + +Foundation already shipped (Phase B, commit `d8494c2`): `phaseSegments`, +`phaseInference`, `targetGrouping`. This spec is how they reach the screen. + +## Goal + +A reviewer reconstructing an engagement asks *"what happened to this target, in +what order?"* and *"reconstruct this phase (recon → exploit → pivot → exfil)."* +The current 18 source-type lanes answer *"which streams are active"* — a live +question that now belongs to the HUD (§8). Phase C makes the timeline answer the +reconstruction questions. + +## Non-goals (unchanged from the principles) + +- No opinionated ATT&CK/correlation output. Phase inference stays a **suggestion** + (§3) — dashed, promotable, never authoritative. +- Timeline stays the **event map**; it does not grow inline I/O content (that is + the transcript/exchange reader, §8 / Q10). +- No new capture. This is a presentation refactor over existing events + the + Phase B seams. + +## The axis model (recommended design) + +Two orthogonal controls, not one three-way switch: + +1. **Lane axis** — the swim-lane rows. Toggles between: + - `source` *(current, default at ship)* — the 18 source-type lanes, unchanged. + - `target` *(new)* — one lane per target from `groupByTarget(events)`, plus the + explicit **untargeted** lane always last (§12). Each event's dot keeps its + source-type colour/shape, so source is still legible as an encoding, just + not as the row axis. +2. **Phase ribbon** — a thin horizontal band across the top of the time axis, + independent of the lane axis, rendered from `phaseSegments(...)`. Operator + phase-markers draw **solid** bands; `inferPhaseSuggestions(...)` draws + **dashed/ghost** bands with a "suggest" affordance that promotes to an operator + marker on click (§11). Toggleable; off by default until the operator has + phases. + +> **Why a ribbon, not a third lane mode:** phase is inherently *temporal* +> (segments over time), so it maps naturally to time-bands, not rows. Making it a +> ribbon lets target-lanes and phase coexist ("show me, per target, which phase +> each action fell in") instead of forcing a choice. **Open question O1** records +> the alternative. + +### Data flow + +``` +events ──┬─ groupByTarget() → target lanes (axis='target') + ├─ toLane() [existing] → source lanes (axis='source') + ├─ phaseMarkersFromEvents → phaseSegments() → solid phase bands + └─ inferPhaseSuggestions() → dashed phase bands +event dot colour/shape ← source-type (unchanged encoding, both axes) +``` + +## Safe refactor strategy — incremental, behind a toggle + +The rule: **never a big-bang replacement of the lane model.** Order: + +1. **Extract T5 seams first** (make the current behaviour testable before + changing it): + - `lib/timelineCluster.ts` — the 14px same-lane cluster bucketing (currently + inline). Pure `clusterEvents(events, pxPerMs, laneOf)`. + - `lib/laneVisibility.ts` — populated / visible / hidden / solo resolution + (currently inline; pairs with the T3 modes seam already shipped). + Each ships with unit tests and is imported back into Timeline.tsx with **zero + behaviour change** (renderer-smoke + timeline-keys stay green). This buys the + first real interaction-test coverage on the file before we touch its axis. +2. **Introduce `laneAxis` state** (`'source' | 'target'`), default `'source'`. + Add a `lib/timelineAxis.ts` seam: `lanesForAxis(axis, events, ...)` returning + the ordered lane descriptors — for `'source'` it returns today's lanes + (delegating to the existing logic), for `'target'` it wraps `groupByTarget`. + The renderer maps events to rows through this one function regardless of axis. + Ship with `'source'` as default so nothing changes until the operator opts in. +3. **Wire the target axis** behind a header control (part of the T4 "View + options" menu). Verify empty/untargeted handling, cluster + visibility still + work per-lane, and the T3 modes row reflects target-lane solo/hide. +4. **Add the phase ribbon** (solid from markers, dashed from inference, promote + action). Independent toggle. +5. **Fold TargetView in** — once the target axis is solid, TargetView becomes the + timeline with `laneAxis='target'`; deprecate the separate view or make it a + deep-link into that axis. (Sequenced last; not required for the axis to ship.) + +Each step is independently shippable and independently revertible. The default +stays `source` until `target` is proven, so no operator loses the current view on +upgrade. + +## Seams to build (all pure, TDD, renderer lib) + +| Seam | Purpose | Ships in step | +|---|---|---| +| `timelineCluster.ts` | cluster bucketing, testable | 1 | +| `laneVisibility.ts` | populated/visible/hidden/solo | 1 | +| `timelineAxis.ts` | `lanesForAxis(axis, events, …)` → ordered lanes | 2 | +| `phaseRibbon.ts` | merge `phaseSegments` (solid) + `inferPhaseSuggestions` (dashed) into render-ready bands, with promote targets | 4 | + +Phase B seams (`phaseSegments`, `phaseInference`, `targetGrouping`) are consumed, +not rebuilt. + +## Acceptance criteria + +- **A1 — no-regression default:** with `laneAxis='source'` and the phase ribbon + off, the timeline is byte-for-byte the current behaviour. `renderer-smoke` and + `timeline-keys` stay green; the T5 extractions change nothing observable. +- **A2 — target axis:** `laneAxis='target'` renders one lane per target ordered by + first activity, untargeted last; every event appears in exactly one lane; dots + keep source-type encoding. +- **A3 — untargeted honesty:** events with no target land in the explicit + untargeted lane, never dropped, never guessed into a target (§12). +- **A4 — phase ribbon:** operator phase-markers render as solid bands spanning + their segment; inferred suggestions render dashed and are visually distinct; + clicking a suggestion's promote affordance creates an operator marker (an + authoritative, attributable, on-chain fact — §3/§11). +- **A5 — cross-feature integrity:** cluster popovers, lane solo/hide (T3 row), + the `/` filter, zoom/pan, and the T2 wheel behaviour all work under both axes. +- **A6 — I/O boundary held:** the timeline still surfaces I/O only as a glyph; + drill-down still opens the transcript (§8 / Q10). No inline I/O content added. + +## Regression guards (hard gates each step) + +- `npx vitest run` on: the new seam tests, `renderer-smoke`, `timeline-keys`, + `timeline-modes`, `timeline-wheel`, plus (steps 1) the extracted-seam tests. +- `npm run build` green. +- i18n en/zh parity for any new axis/ribbon copy. +- Manual smoke on a transcript-heavy project before folding TargetView in (step 5). + +## Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Big-bang breaks the fragile 4,100-line file | Toggle-behind + `source` default; each step revertible; T5 seams give test coverage *before* the axis change | +| Target-lane count explodes on a wide engagement | Lane virtualization (T6) — sequence it alongside/after; until then cap + "N more targets" like the cluster cap | +| Phase inference reads as authoritative | Enforced by design: dashed rendering + promote-to-marker; inference never writes to the chain (§3) | +| Concurrent edits to Timeline.tsx (has happened) | Single-owner sequential passes; check `git status`/mtime before each | + +## Open questions (resolve before step 2/4) + +- **O1 — phase representation:** ribbon overlay (recommended) vs. a third + `laneAxis='phase'` mode. Recommendation: ribbon, because phase is temporal and + ribbon lets target×phase coexist. Needs a yes/no. +- **O2 — default axis at ship:** keep `source` default (safest, recommended) or + make `target` the default once proven? Recommendation: ship `source`-default, + revisit after dogfooding. +- **O3 — TargetView fate (step 5):** deprecate/remove vs. keep as a deep-link + into `laneAxis='target'`. Recommendation: keep as a deep-link first, remove only + after the axis fully subsumes it. + +## Sequencing summary + +Step 1 (T5 seams, no behaviour change) → Step 2 (`timelineAxis` seam, source +default) → Step 3 (wire target axis behind View options) → Step 4 (phase ribbon + +promote) → Step 5 (fold TargetView). Steps 1–2 are low-risk and can start +immediately; 3–5 gate on O1/O2/O3. + +Cross-references: `DESIGN-PRINCIPLES.md` §3/§8/§9/§11/§12 · `UX-TIMELINE-2026-08.md` +(T-series) · `UX-BACKLOG-TICKETS.md` (T4/T5/T6) · Phase B commit `d8494c2`. From 7c9857945be1408f017d3ea9c925f61ec0455ddd Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 20:06:47 +0800 Subject: [PATCH 05/47] =?UTF-8?q?refactor(timeline):=20Phase=20C=20step=20?= =?UTF-8?q?1=20=E2=80=94=20extract=20cluster=20+=20lane-visibility=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls two pieces of inline Timeline.tsx logic into pure, unit-tested seams so the axis refactor (SPEC-TIMELINE-AXIS) has coverage before it changes the lane model. Faithful extractions — zero behaviour change. - lib/timelineCluster.ts (clusterEvents) — the 14px consecutive-same-bin bucketing with mean-x representative + `${lane}-${firstId}` key. Tested (9); wired during the cluster refactor (it returns ids, not event objects). - lib/laneVisibility.ts (populatedLanes/visibleLanes/soloLaneOf) — populated set, canonical-order visible filter, and the derived "solo" (one lane visible AND something hidden). Tested (10) and wired back into Timeline.tsx as a 1:1 swap. Verification: 59 tests green (incl. renderer-smoke/timeline-keys/timeline-modes), electron-vite build clean. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/components/Timeline.tsx | 25 ++++--- src/renderer/src/lib/laneVisibility.ts | 48 ++++++++++++ src/renderer/src/lib/timelineCluster.ts | 89 ++++++++++++++++++++++ test/lane-visibility.test.ts | 66 ++++++++++++++++ test/timeline-cluster.test.ts | 95 ++++++++++++++++++++++++ 5 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/lib/laneVisibility.ts create mode 100644 src/renderer/src/lib/timelineCluster.ts create mode 100644 test/lane-visibility.test.ts create mode 100644 test/timeline-cluster.test.ts diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 595331ef..1c05d4c9 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -10,6 +10,7 @@ import { getLastVerifyResult, VERIFY_UPDATED_EVENT, type FullVerifyResult } from import { resolveTimelineKey } from '../lib/timelineKeys' import { wheelMode } from '../lib/timelineWheel' import { activeModes, type TimelineModeState } from '../lib/timelineModes' +import { populatedLanes as computePopulatedLanes, visibleLanes as computeVisibleLanes, soloLaneOf } from '../lib/laneVisibility' import { shortcutsForScope, modKey, MOD_TOKEN } from '../lib/shortcuts' const MIN_LANE_H = 36 @@ -1145,18 +1146,18 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // A lane with no events is dead vertical space — most engagements only ever // touch three or four of them. Keep the filter chip so the operator can see // the lane exists, but don't give it a row until something lands in it. - const populatedLanes = useMemo(() => { - const seen = new Set() - for (const e of events) seen.add(toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes)) - return seen - // v0.6.95 P1-12: pluginTypes was missing from deps — a plugin-registered - // event lane wouldn't appear on the filter chip until an unrelated state - // change re-ran the memo. Same fix applied to `laneEvents` and - // `recentEvents` below. - }, [events, pluginTypes]) + // Phase C step 1: the populated/visible resolution lives in lib/laneVisibility + // (unit-tested) so the axis refactor can lean on it; this is a zero-behaviour + // swap of the former inline `new Set(...)` / `LANES.filter(...)` memos. + // v0.6.95 P1-12: pluginTypes must stay in deps — a plugin-registered event + // lane wouldn't appear on the filter chip until an unrelated re-render. + const populatedLanes = useMemo( + () => computePopulatedLanes(events, (e) => toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes)), + [events, pluginTypes] + ) const visibleLanes = useMemo( - () => LANES.filter((l) => populatedLanes.has(l) && !hiddenLanes.has(l)), + () => computeVisibleLanes(LANES, populatedLanes, hiddenLanes), [populatedLanes, hiddenLanes] ) @@ -1168,12 +1169,12 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // (showAllLanes) is always a real action. The soloed lane's display label is // carried as the chip value so the row reads "solo: shell", not the raw id. const modeChips = useMemo(() => { - const isSolo = visibleLanes.length === 1 && hiddenLanes.size > 0 + const solo = soloLaneOf(visibleLanes, hiddenLanes) const state: TimelineModeState = { focusActive: focusChain !== null, anomalyOnly: anomalyFilter, collapseAgentTurns, - soloLane: isSolo ? laneLabels[visibleLanes[0]] : null, + soloLane: solo ? laneLabels[solo] : null, hiddenLaneCount: hiddenLanes.size, filterQuery } diff --git a/src/renderer/src/lib/laneVisibility.ts b/src/renderer/src/lib/laneVisibility.ts new file mode 100644 index 00000000..a307da78 --- /dev/null +++ b/src/renderer/src/lib/laneVisibility.ts @@ -0,0 +1,48 @@ +// Extracted verbatim (Phase C step 1) from Timeline.tsx's inline `populatedLanes` +// / `visibleLanes` memos + the solo derivation in the T3 modes memo, so the lane +// visibility rules have test coverage BEFORE the axis refactor changes the lane +// model. Zero behaviour change is the point. +// +// The mirrored inline code: +// populatedLanes = new Set(events.map(e => toLane(e.agentType, e.data?.subtype, pluginTypes))) +// visibleLanes = LANES.filter(l => populatedLanes.has(l) && !hiddenLanes.has(l)) +// isSolo = visibleLanes.length === 1 && hiddenLanes.size > 0 +// +// Kept pure by taking `laneOf` and the ordered lane list as parameters rather +// than importing Timeline's LANES/toLane — the seam must not depend on the +// component it will be wired back into. + +export type LaneId = string + +/** + * The set of lanes that have at least one event. Order-free (a Set), exactly + * like the inline memo; `visibleLanes` reimposes the canonical order. + */ +export function populatedLanes(events: readonly E[], laneOf: (e: E) => LaneId): Set { + const seen = new Set() + for (const e of events) seen.add(laneOf(e)) + return seen +} + +/** + * The lanes that get a row: populated AND not hidden, in the canonical `allLanes` + * order (not event/insertion order). Mirrors `LANES.filter(...)`. + */ +export function visibleLanes( + allLanes: readonly LaneId[], + populated: ReadonlySet, + hidden: ReadonlySet +): LaneId[] { + return allLanes.filter((l) => populated.has(l) && !hidden.has(l)) +} + +/** + * The soloed lane, or null. "Solo" is not stored directly — it is the derived + * state of exactly one lane surviving while others are explicitly hidden. Returns + * the lane id; the caller maps it to a label (the inline code does + * `laneLabels[visibleLanes[0]]`). Mirrors `isSolo` exactly: + * visible.length === 1 && hidden.size > 0 + */ +export function soloLaneOf(visible: readonly LaneId[], hidden: ReadonlySet): LaneId | null { + return visible.length === 1 && hidden.size > 0 ? visible[0] : null +} diff --git a/src/renderer/src/lib/timelineCluster.ts b/src/renderer/src/lib/timelineCluster.ts new file mode 100644 index 00000000..ecf6a9e6 --- /dev/null +++ b/src/renderer/src/lib/timelineCluster.ts @@ -0,0 +1,89 @@ +// The Timeline collapses events that fall within ~14px of each other on the same +// lane into a single clickable marker (with a count) so dense bursts stay +// legible. Zooming in widens the track, so clusters naturally split apart into +// individual dots. This is the pure core of that bucketing, extracted verbatim +// from Timeline.tsx's `clusters` memo so it can be pinned by tests before the +// timeline axis refactor (SPEC-TIMELINE-AXIS Step 1). Zero behaviour change is +// the whole point: same inputs must yield the same clusters as the inline code. +// +// Faithfulness notes on the mirrored semantics: +// * Grid bucketing, NOT pairwise distance. Each item lands in bin +// `Math.floor(x / mergePx)`; only *consecutive* items sharing a bin index +// merge. Two items 2px apart still split if they straddle a bin boundary +// (e.g. x=13 and x=15 → bins 0 and 1). This mirrors the inline code exactly. +// * Input order is preserved and never re-sorted. The inline code relies on +// `laneEvents[lane]` already being time-sorted; this function makes the same +// assumption. Callers must supply each lane's items in the same (sorted) +// order the Timeline already flattens them in. +// * Lane grouping is stable by first appearance, matching the inline code's +// per-lane `visibleLanes.forEach` walk where the caller flattens visible +// lanes in row order and each lane's events are contiguous. + +export interface ClusterInput { + id: string + /** the lane the event belongs to (source-type row today) */ + lane: string + /** the event's pixel x on the track — i.e. the inline code's `toX(displayTs(e))` */ + x: number +} + +export interface Cluster { + /** stable key: `${lane}-${firstId}`, verbatim from the inline `out.push` */ + key: string + lane: string + /** representative x: the arithmetic mean of the member pixel xs */ + x: number + /** member event ids, in input order */ + ids: string[] + /** member count (== ids.length); drives the dot's count badge */ + count: number +} + +// Default merge distance in pixels — the inline `CLUSTER_PX = 14`. +const CLUSTER_PX = 14 + +// Collapse same-lane, near-adjacent events into counted clusters. +// +// The returned li/y row geometry the inline memo also computes +// (`y = li * laneH + laneH / 2`) is intentionally NOT produced here: it is a +// rendering transform over the lane's row index, not part of the clustering, and +// the consumer recomputes it from the row position. Everything that is purely a +// function of the bucketing — key, lane, mean x, ids, count — lives here. +export function clusterEvents(items: ClusterInput[], mergePx: number = CLUSTER_PX): Cluster[] { + // Group by lane, stable in first-seen order, preserving per-lane input order. + // Mirrors the inline `visibleLanes.forEach` where each lane is visited once in + // row order and its events are already contiguous and time-sorted. + const byLane = new Map() + for (const item of items) { + const laneItems = byLane.get(item.lane) + if (laneItems) laneItems.push(item) + else byLane.set(item.lane, [item]) + } + + const out: Cluster[] = [] + for (const [lane, evs] of byLane) { + if (!evs.length) continue + let bucket: ClusterInput[] = [] + let curBi = NaN + const flush = (): void => { + if (!bucket.length) return + const x = bucket.reduce((a, e) => a + e.x, 0) / bucket.length + out.push({ + key: `${lane}-${bucket[0].id}`, + lane, + x, + ids: bucket.map((e) => e.id), + count: bucket.length + }) + bucket = [] + } + for (const e of evs) { + const bi = Math.floor(e.x / mergePx) + if (bucket.length && bi !== curBi) flush() + curBi = bi + bucket.push(e) + } + flush() + } + return out +} diff --git a/test/lane-visibility.test.ts b/test/lane-visibility.test.ts new file mode 100644 index 00000000..4239321b --- /dev/null +++ b/test/lane-visibility.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { populatedLanes, visibleLanes, soloLaneOf } from '../src/renderer/src/lib/laneVisibility' + +// Pins the lane-visibility rules extracted from Timeline.tsx so the axis refactor +// can lean on them without a behaviour change. The canonical order used in the +// component is the LANES tuple; here a small representative slice stands in. +const LANES = ['shell', 'agent', 'scanner', 'dns', 'pivot', 'loot', 'system'] as const +type Ev = { agentType: string } +const laneOf = (e: Ev): string => e.agentType + +describe('populatedLanes', () => { + it('is empty for no events', () => { + expect(populatedLanes([], laneOf).size).toBe(0) + }) + + it('collects the distinct lanes that have events', () => { + const p = populatedLanes([{ agentType: 'shell' }, { agentType: 'shell' }, { agentType: 'dns' }], laneOf) + expect([...p].sort()).toEqual(['dns', 'shell']) + }) +}) + +describe('visibleLanes', () => { + it('keeps only populated, non-hidden lanes in canonical order', () => { + // populated out of canonical order on purpose — output must follow LANES. + const populated = new Set(['loot', 'shell', 'dns']) + const hidden = new Set() + expect(visibleLanes(LANES, populated, hidden)).toEqual(['shell', 'dns', 'loot']) + }) + + it('drops hidden lanes', () => { + const populated = new Set(['shell', 'dns', 'loot']) + const hidden = new Set(['dns']) + expect(visibleLanes(LANES, populated, hidden)).toEqual(['shell', 'loot']) + }) + + it('excludes lanes that are populated but not in the canonical list', () => { + const populated = new Set(['shell', 'ghost-lane']) + expect(visibleLanes(LANES, populated, new Set())).toEqual(['shell']) + }) + + it('is empty when everything populated is hidden', () => { + const populated = new Set(['shell', 'dns']) + const hidden = new Set(['shell', 'dns']) + expect(visibleLanes(LANES, populated, hidden)).toEqual([]) + }) +}) + +describe('soloLaneOf', () => { + it('is the lone visible lane only when others are explicitly hidden', () => { + expect(soloLaneOf(['shell'], new Set(['dns', 'loot']))).toBe('shell') + }) + + it('is null when one lane is visible but nothing was hidden (naturally single-lane)', () => { + // The key distinction: a single-lane engagement is NOT a solo — hidden must + // be non-empty. Mirrors `visibleLanes.length === 1 && hiddenLanes.size > 0`. + expect(soloLaneOf(['shell'], new Set())).toBeNull() + }) + + it('is null when more than one lane is visible', () => { + expect(soloLaneOf(['shell', 'dns'], new Set(['loot']))).toBeNull() + }) + + it('is null when no lane is visible', () => { + expect(soloLaneOf([], new Set(['shell']))).toBeNull() + }) +}) diff --git a/test/timeline-cluster.test.ts b/test/timeline-cluster.test.ts new file mode 100644 index 00000000..a9f193ea --- /dev/null +++ b/test/timeline-cluster.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { clusterEvents } from '../src/renderer/src/lib/timelineCluster' +import type { ClusterInput } from '../src/renderer/src/lib/timelineCluster' + +// Pins the behaviour of Timeline.tsx's inline `clusters` memo (the 14px same-lane +// bucketing) before the axis refactor (SPEC-TIMELINE-AXIS Step 1). These are +// characterisation tests: any drift here is a change to what the operator sees on +// the track, so they mirror the inline grid-bucketing semantics exactly — +// consecutive same-bin merge, mean-x representative, first-id key, input order. + +const ev = (id: string, lane: string, x: number): ClusterInput => ({ id, lane, x }) + +describe('clusterEvents', () => { + it('returns [] for empty input', () => { + expect(clusterEvents([])).toEqual([]) + }) + + it('makes a single-element cluster for one event', () => { + expect(clusterEvents([ev('a', 'shell', 5)])).toEqual([ + { key: 'shell-a', lane: 'shell', x: 5, ids: ['a'], count: 1 } + ]) + }) + + it('merges same-lane events inside one 14px bin into one counted cluster', () => { + // x=2 and x=10 → bin 0 for both (Math.floor(x/14) === 0). + const out = clusterEvents([ev('a', 'shell', 2), ev('b', 'shell', 10)]) + expect(out).toHaveLength(1) + expect(out[0]).toEqual({ + key: 'shell-a', + lane: 'shell', + x: 6, // mean of 2 and 10 + ids: ['a', 'b'], + count: 2 + }) + }) + + it('does not merge events on different lanes even at the same x', () => { + const out = clusterEvents([ev('a', 'shell', 3), ev('b', 'http', 3)]) + expect(out).toHaveLength(2) + expect(out.map((c) => c.lane)).toEqual(['shell', 'http']) + expect(out.every((c) => c.count === 1)).toBe(true) + }) + + it('splits events into separate clusters once they cross a bin boundary', () => { + // x=13 → bin 0, x=15 → bin 1: only 2px apart but on opposite sides of the + // 14px grid line, so the inline code keeps them separate. Mirrored here. + const out = clusterEvents([ev('a', 'shell', 13), ev('b', 'shell', 15)]) + expect(out).toHaveLength(2) + expect(out.map((c) => c.ids)).toEqual([['a'], ['b']]) + }) + + it('treats an exact multiple of mergePx as the start of the next bin', () => { + // x=13 → bin 0, x=14 → bin 1 (14/14 === 1). Boundary is exclusive-below. + const out = clusterEvents([ev('a', 'shell', 13), ev('b', 'shell', 14)]) + expect(out).toHaveLength(2) + }) + + it('counts a dense burst and averages the member xs', () => { + // Four events all inside bin 0 (0..13). + const out = clusterEvents([ + ev('a', 'shell', 0), + ev('b', 'shell', 4), + ev('c', 'shell', 8), + ev('d', 'shell', 12) + ]) + expect(out).toHaveLength(1) + expect(out[0].count).toBe(4) + expect(out[0].ids).toEqual(['a', 'b', 'c', 'd']) + expect(out[0].x).toBe(6) // (0+4+8+12)/4 + expect(out[0].key).toBe('shell-a') // key uses the first member id + }) + + it('emits clusters per lane in first-seen lane order, events in input order', () => { + // Two lanes interleaved in the input; grouping is stable by first appearance. + const out = clusterEvents([ + ev('a', 'http', 2), + ev('b', 'shell', 2), + ev('c', 'http', 6), + ev('d', 'shell', 40) + ]) + // http seen first → its clusters come first. a+c share bin 0 → one cluster. + // shell: b in bin 0, d in bin 2 → two clusters. + expect(out.map((c) => c.lane)).toEqual(['http', 'shell', 'shell']) + expect(out[0]).toMatchObject({ lane: 'http', ids: ['a', 'c'], count: 2 }) + expect(out[1]).toMatchObject({ lane: 'shell', ids: ['b'], count: 1 }) + expect(out[2]).toMatchObject({ lane: 'shell', ids: ['d'], count: 1 }) + }) + + it('honours a custom mergePx threshold', () => { + // With mergePx=100, x=2 and x=90 fall in the same bin 0 and merge, whereas + // the default 14 would split them (bins 0 and 6). + expect(clusterEvents([ev('a', 'shell', 2), ev('b', 'shell', 90)], 100)).toHaveLength(1) + expect(clusterEvents([ev('a', 'shell', 2), ev('b', 'shell', 90)])).toHaveLength(2) + }) +}) From dfceb4779f01942092eea9a3e138257c3dfbde76 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 20:13:26 +0800 Subject: [PATCH 06/47] =?UTF-8?q?feat(timeline):=20Phase=20C=20step=202=20?= =?UTF-8?q?=E2=80=94=20timelineAxis=20seam=20(source|target)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single place the "which lanes, and which lane does an event go in" decision lives, so the renderer maps events to rows the same way on either axis (SPEC-TIMELINE-AXIS step 2). - lanesForAxis('source', …) is a pass-through of the caller's existing lanes — what makes shipping source-default a byte-for-byte no-op. - lanesForAxis('target', …) wraps Phase B groupByTarget: one lane per target, untargeted last (§12), labelled by target id. - laneOfEvent agrees with lanesForAxis on ids so every event lands in exactly one rendered lane; null/undefined/'' target → UNTARGETED_LANE. Pure, not yet wired (Step 3 introduces the axis state + target rendering, gated on open questions O1-O3). 9 tests green. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/lib/timelineAxis.ts | 69 +++++++++++++++++++++++++ test/timeline-axis.test.ts | 77 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/renderer/src/lib/timelineAxis.ts create mode 100644 test/timeline-axis.test.ts diff --git a/src/renderer/src/lib/timelineAxis.ts b/src/renderer/src/lib/timelineAxis.ts new file mode 100644 index 00000000..fce3c64b --- /dev/null +++ b/src/renderer/src/lib/timelineAxis.ts @@ -0,0 +1,69 @@ +import { groupByTarget } from './targetGrouping' + +// Phase C step 2 (SPEC-TIMELINE-AXIS): the timeline's swim-lane rows can come +// from either the current source-type lanes OR a target grouping. This seam is +// the single place that decision lives, so the renderer maps events → rows the +// same way regardless of axis. +// +// The 'source' axis is a deliberate pass-through: it returns the lanes the +// caller already computes (populated, canonical order) and defers lane-of to the +// caller's existing toLane. That is what makes shipping 'source' as the default a +// byte-for-byte no-op — nothing changes until an operator opts into 'target'. +// +// The 'target' axis wraps the Phase B groupByTarget (untargeted bucket always +// last, §12: never orphan, never guess). laneOfEvent MUST agree with +// lanesForAxis on ids, so every event lands in exactly one rendered lane. + +export type LaneAxis = 'source' | 'target' + +export interface AxisLane { + id: string + label: string +} + +// Minimal event shape both axes need. Target axis reads id/timestamp/targetId +// (via groupByTarget); source axis reads whatever the caller's sourceLaneOf +// needs (e.g. agentType/subtype) — hence the open index signature. +export interface AxisEvent { + id: string + timestamp: number + targetId?: string | null + [k: string]: unknown +} + +/** Stable id for the catch-all lane holding events with no target (§12). */ +export const UNTARGETED_LANE = '__untargeted__' + +/** + * The ordered lane descriptors for an axis. + * source → the caller's lanes, unchanged (the zero-change default). + * target → one lane per target from groupByTarget, untargeted last, labelled + * with the target id (or `untargetedLabel` for the null bucket). + */ +export function lanesForAxis( + axis: LaneAxis, + events: readonly AxisEvent[], + sourceLanes: readonly AxisLane[], + untargetedLabel: string +): AxisLane[] { + if (axis === 'source') return [...sourceLanes] + return groupByTarget(events).map((g) => ({ + id: g.target ?? UNTARGETED_LANE, + label: g.target ?? untargetedLabel + })) +} + +/** + * Which lane a single event belongs to under an axis. Agrees with lanesForAxis: + * source → the caller's sourceLaneOf (existing toLane). + * target → the event's target id, or UNTARGETED_LANE for null/undefined/''. + */ +export function laneOfEvent( + axis: LaneAxis, + event: AxisEvent, + sourceLaneOf: (e: AxisEvent) => string +): string { + if (axis === 'source') return sourceLaneOf(event) + const t = event.targetId + return t == null || t === '' ? UNTARGETED_LANE : t +} diff --git a/test/timeline-axis.test.ts b/test/timeline-axis.test.ts new file mode 100644 index 00000000..1caa4249 --- /dev/null +++ b/test/timeline-axis.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { lanesForAxis, laneOfEvent, UNTARGETED_LANE } from '../src/renderer/src/lib/timelineAxis' +import type { AxisEvent, AxisLane } from '../src/renderer/src/lib/timelineAxis' + +// timelineAxis is the Phase C seam that lets the timeline's swim-lane rows come +// from either the current source-type lanes or a target grouping, WITHOUT the +// renderer caring which. 'source' delegates to the caller's existing lanes so +// the default is a byte-for-byte no-op; 'target' wraps the Phase B groupByTarget +// (untargeted last, §12). laneOfEvent must agree with lanesForAxis on lane ids. + +const SOURCE_LANES: AxisLane[] = [ + { id: 'shell', label: 'Shell' }, + { id: 'dns', label: 'DNS' } +] +const sourceLaneOf = (e: AxisEvent): string => (e as { agentType?: string }).agentType ?? 'system' + +const ev = (id: string, timestamp: number, targetId?: string | null, agentType?: string): AxisEvent => + ({ id, timestamp, targetId, ...(agentType ? { agentType } : {}) } as AxisEvent) + +describe('lanesForAxis — source axis (the zero-change default)', () => { + it('returns the caller-supplied source lanes unchanged', () => { + const lanes = lanesForAxis('source', [ev('a', 1, 'x')], SOURCE_LANES, 'Untargeted') + expect(lanes).toEqual(SOURCE_LANES) + }) + + it('does not consult events for the source axis', () => { + // Even with target data present, source axis ignores it. + const lanes = lanesForAxis('source', [], SOURCE_LANES, 'Untargeted') + expect(lanes).toEqual(SOURCE_LANES) + }) +}) + +describe('lanesForAxis — target axis', () => { + it('builds one lane per target, ordered by first activity, untargeted last', () => { + const events = [ + ev('a', 30, 'b.com'), + ev('b', 10, 'a.com'), + ev('c', 20, null), + ev('d', 40, 'a.com') + ] + const lanes = lanesForAxis('target', events, SOURCE_LANES, 'Untargeted') + // a.com first (ts 10), b.com next (ts 30), untargeted last regardless of ts + expect(lanes.map((l) => l.id)).toEqual(['a.com', 'b.com', UNTARGETED_LANE]) + expect(lanes.map((l) => l.label)).toEqual(['a.com', 'b.com', 'Untargeted']) + }) + + it('uses the untargeted lane label for the null-target bucket only', () => { + const lanes = lanesForAxis('target', [ev('a', 1, null)], SOURCE_LANES, 'No target') + expect(lanes).toEqual([{ id: UNTARGETED_LANE, label: 'No target' }]) + }) + + it('is empty for no events', () => { + expect(lanesForAxis('target', [], SOURCE_LANES, 'Untargeted')).toEqual([]) + }) +}) + +describe('laneOfEvent', () => { + it('uses the source lane function on the source axis', () => { + expect(laneOfEvent('source', ev('a', 1, 'x', 'scanner'), sourceLaneOf)).toBe('scanner') + }) + + it('maps a targeted event to its target id on the target axis', () => { + expect(laneOfEvent('target', ev('a', 1, 'a.com'), sourceLaneOf)).toBe('a.com') + }) + + it('maps null / undefined / empty target to the untargeted lane', () => { + expect(laneOfEvent('target', ev('a', 1, null), sourceLaneOf)).toBe(UNTARGETED_LANE) + expect(laneOfEvent('target', ev('b', 1, undefined), sourceLaneOf)).toBe(UNTARGETED_LANE) + expect(laneOfEvent('target', ev('c', 1, ''), sourceLaneOf)).toBe(UNTARGETED_LANE) + }) + + it('agrees with lanesForAxis: every event lands in a real lane (target axis)', () => { + const events = [ev('a', 1, 'a.com'), ev('b', 2, null), ev('c', 3, 'b.com')] + const laneIds = new Set(lanesForAxis('target', events, SOURCE_LANES, 'U').map((l) => l.id)) + for (const e of events) expect(laneIds.has(laneOfEvent('target', e, sourceLaneOf))).toBe(true) + }) +}) From c7ec54f1c5033c9a6383782ab3d4f28e5367ef9b Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 20:20:29 +0800 Subject: [PATCH 07/47] docs: resolve Phase C open questions O1-O3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O1 phase → ribbon overlay (coexists with target lanes). O2 → keep source axis as the ship default (target opt-in). O3 → remove TargetView; the target lane axis subsumes it (step 5 deletes the view). Steps 3-5 unblocked. Co-Authored-By: Claude Opus 4.8 --- docs/SPEC-TIMELINE-AXIS.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/SPEC-TIMELINE-AXIS.md b/docs/SPEC-TIMELINE-AXIS.md index 1d9612cd..9689fbe2 100644 --- a/docs/SPEC-TIMELINE-AXIS.md +++ b/docs/SPEC-TIMELINE-AXIS.md @@ -139,24 +139,25 @@ not rebuilt. | Phase inference reads as authoritative | Enforced by design: dashed rendering + promote-to-marker; inference never writes to the chain (§3) | | Concurrent edits to Timeline.tsx (has happened) | Single-owner sequential passes; check `git status`/mtime before each | -## Open questions (resolve before step 2/4) - -- **O1 — phase representation:** ribbon overlay (recommended) vs. a third - `laneAxis='phase'` mode. Recommendation: ribbon, because phase is temporal and - ribbon lets target×phase coexist. Needs a yes/no. -- **O2 — default axis at ship:** keep `source` default (safest, recommended) or - make `target` the default once proven? Recommendation: ship `source`-default, - revisit after dogfooding. -- **O3 — TargetView fate (step 5):** deprecate/remove vs. keep as a deep-link - into `laneAxis='target'`. Recommendation: keep as a deep-link first, remove only - after the axis fully subsumes it. +## Open questions — RESOLVED 2026-08-11 + +- **O1 — phase representation → RIBBON overlay.** Phase renders as a horizontal + time-band at the top of the time axis, independent of the lane axis, so target + lanes and phase coexist. (Not a third `laneAxis='phase'` mode.) +- **O2 — default axis at ship → keep `source`.** `target` is opt-in; nothing + changes on upgrade until the operator switches. Revisit the default after + dogfooding. +- **O3 — TargetView → REMOVE.** The `target` lane axis fully subsumes TargetView; + step 5 deletes the separate view rather than keeping it as a deep-link. (More + decisive than the original recommendation — the axis is the one place targets + are reviewed.) ## Sequencing summary Step 1 (T5 seams, no behaviour change) → Step 2 (`timelineAxis` seam, source default) → Step 3 (wire target axis behind View options) → Step 4 (phase ribbon + -promote) → Step 5 (fold TargetView). Steps 1–2 are low-risk and can start -immediately; 3–5 gate on O1/O2/O3. +promote) → Step 5 (**remove** TargetView, per O3). Steps 1–2 shipped (commits +7c98579, dfceb47); O1–O3 resolved, so 3–5 are unblocked. Cross-references: `DESIGN-PRINCIPLES.md` §3/§8/§9/§11/§12 · `UX-TIMELINE-2026-08.md` (T-series) · `UX-BACKLOG-TICKETS.md` (T4/T5/T6) · Phase B commit `d8494c2`. From 97628694a9ddfc232175d768e084856f6a706f0f Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 20:26:53 +0800 Subject: [PATCH 08/47] =?UTF-8?q?feat(timeline):=20Phase=20C=20step=203=20?= =?UTF-8?q?data=20=E2=80=94=20buildLaneModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the axis data layer: buildLaneModel(axis, events, sourceLanes, sourceLaneOf, untargetedLabel) returns the ordered lanes + event→lane mapping + events grouped by lane (every lane seeded), for either axis in one pass. `source` reproduces the inline Object.fromEntries(LANES...)/toLane grouping exactly; `target` groups by target with the untargeted lane last. This is the tested foundation the in-component wiring (laneEvents/visibleLanes through the model) + the axis toggle + the first-cut target render build on — that render step is app-in-the-loop (dot-colour on the target axis is a visual concern pinned to one render site). 12 tests green. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/lib/timelineAxis.ts | 43 ++++++++++++++++++++++++++-- test/timeline-axis.test.ts | 26 ++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/timelineAxis.ts b/src/renderer/src/lib/timelineAxis.ts index fce3c64b..0e85dd55 100644 --- a/src/renderer/src/lib/timelineAxis.ts +++ b/src/renderer/src/lib/timelineAxis.ts @@ -58,12 +58,49 @@ export function lanesForAxis( * source → the caller's sourceLaneOf (existing toLane). * target → the event's target id, or UNTARGETED_LANE for null/undefined/''. */ -export function laneOfEvent( +export function laneOfEvent( axis: LaneAxis, - event: AxisEvent, - sourceLaneOf: (e: AxisEvent) => string + event: E, + sourceLaneOf: (e: E) => string ): string { if (axis === 'source') return sourceLaneOf(event) const t = event.targetId return t == null || t === '' ? UNTARGETED_LANE : t } + +export interface LaneModel { + /** ordered lane descriptors for the axis */ + lanes: AxisLane[] + /** which lane a given event belongs to (agrees with `lanes`) */ + laneOf: (e: E) => string + /** events grouped by lane id; every lane in `lanes` has an entry (possibly []), + * mirroring the inline `Object.fromEntries(LANES.map(...))` seeding so callers + * can index `laneEvents[lane]` for any lane without an undefined check */ + laneEvents: Record +} + +/** + * The complete lane model for an axis in one pass: the ordered lanes, the + * event→lane mapping, and events grouped by lane (seeded so every lane has an + * array). `source` reproduces the inline behaviour exactly (lanes = sourceLanes, + * grouped by sourceLaneOf); `target` groups by target with the untargeted lane + * last. Pure — the renderer supplies sourceLanes / sourceLaneOf. + */ +export function buildLaneModel( + axis: LaneAxis, + events: readonly E[], + sourceLanes: readonly AxisLane[], + sourceLaneOf: (e: E) => string, + untargetedLabel: string +): LaneModel { + const lanes = lanesForAxis(axis, events, sourceLanes, untargetedLabel) + const laneOf = (e: E): string => laneOfEvent(axis, e, sourceLaneOf) + const laneEvents: Record = {} + for (const l of lanes) laneEvents[l.id] = [] + for (const e of events) { + const id = laneOf(e) + // target ids are data-derived, so a lane not pre-seeded can appear; guard. + ;(laneEvents[id] ??= []).push(e) + } + return { lanes, laneOf, laneEvents } +} diff --git a/test/timeline-axis.test.ts b/test/timeline-axis.test.ts index 1caa4249..5e29a04b 100644 --- a/test/timeline-axis.test.ts +++ b/test/timeline-axis.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { lanesForAxis, laneOfEvent, UNTARGETED_LANE } from '../src/renderer/src/lib/timelineAxis' +import { lanesForAxis, laneOfEvent, buildLaneModel, UNTARGETED_LANE } from '../src/renderer/src/lib/timelineAxis' import type { AxisEvent, AxisLane } from '../src/renderer/src/lib/timelineAxis' // timelineAxis is the Phase C seam that lets the timeline's swim-lane rows come @@ -75,3 +75,27 @@ describe('laneOfEvent', () => { for (const e of events) expect(laneIds.has(laneOfEvent('target', e, sourceLaneOf))).toBe(true) }) }) + +describe('buildLaneModel', () => { + it('source: lanes are the source lanes, grouped by sourceLaneOf, every lane seeded', () => { + const events = [ev('a', 1, null, 'shell'), ev('b', 2, null, 'dns'), ev('c', 3, null, 'shell')] + const m = buildLaneModel('source', events, SOURCE_LANES, sourceLaneOf, 'Untargeted') + expect(m.lanes).toEqual(SOURCE_LANES) + expect(m.laneEvents['shell'].map((e) => e.id)).toEqual(['a', 'c']) + expect(m.laneEvents['dns'].map((e) => e.id)).toEqual(['b']) + expect(m.laneOf(events[0])).toBe('shell') + }) + + it('target: lanes from grouping, events bucketed by target, untargeted seeded', () => { + const events = [ev('a', 10, 'x.com'), ev('b', 20, null), ev('c', 30, 'x.com')] + const m = buildLaneModel('target', events, SOURCE_LANES, sourceLaneOf, 'Untargeted') + expect(m.lanes.map((l) => l.id)).toEqual(['x.com', UNTARGETED_LANE]) + expect(m.laneEvents['x.com'].map((e) => e.id)).toEqual(['a', 'c']) + expect(m.laneEvents[UNTARGETED_LANE].map((e) => e.id)).toEqual(['b']) + }) + + it('every lane in `lanes` has an entry even when empty (source seeding)', () => { + const m = buildLaneModel('source', [ev('a', 1, null, 'shell')], SOURCE_LANES, sourceLaneOf, 'U') + expect(m.laneEvents['dns']).toEqual([]) // seeded empty, safe to index + }) +}) From 0d423cd375f3b3669afcfb79365936015b6fce1b Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 20:33:27 +0800 Subject: [PATCH 09/47] fix(timeline): restore dropped onSidebarOrderChanged import (crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e6f0395 dropped onSidebarOrderChanged from the sidebarOrder import while adding the F4/F5 imports, but DashboardView still calls it — so opening the app threw a runtime ReferenceError and the Timeline view rendered its crash boundary. esbuild doesn't typecheck and the smoke test's effect error slipped through; only launching the built app (Playwright _electron) surfaced it. Branch-only regression — v0.11.7 had the import. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b5147630..8d644ccc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -22,7 +22,7 @@ import { computeCaptureReadiness } from './lib/captureReadiness' import { emptyStateFor } from './lib/emptyState' import { shortcutsForScope, MOD_TOKEN } from './lib/shortcuts' import { useI18n } from './i18n' -import { loadSidebarOrder, type SidebarViewId } from './lib/sidebarOrder' +import { loadSidebarOrder, onSidebarOrderChanged, type SidebarViewId } from './lib/sidebarOrder' import logoUrl from './assets/logo.svg' type View = SidebarViewId | 'settings' | 'search' From 1672aaf23462726437681ac5a71d71f17009dec7 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 20:52:16 +0800 Subject: [PATCH 10/47] =?UTF-8?q?feat(timeline):=20Phase=20C=20step=203=20?= =?UTF-8?q?=E2=80=94=20target=20lane=20axis=20(behind=20a=20toggle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline can now group swim-lane rows by target instead of source type, the reconstruction view DESIGN-PRINCIPLES §8/§9 calls for. A header toggle (⊞ By source / By target) flips laneAxis; source stays the default and is byte-for-byte unchanged. - Lane model routed through buildLaneModel: laneEvents/populatedLanes/ visibleLanes are axis-driven; row labels via laneLabelOf, row marker via laneRowColor, and the untargeted lane last (§12). - Dot colour decoupled to source-type (LANE_COLORS[srcLaneOf(rep)]) so target lanes keep meaningful per-event colours where c.lane is a target id. - maxZoom iterates the model's lane arrays, not a fixed LANES walk (that walk indexed a target-keyed map and crashed — caught by the new e2e). - Lane filter chips are source-only for now (v1: target lanes are data-derived, no palette). i18n: axis toggle + untargeted, en/zh parity (873). Verified in the real app (Playwright _electron): source unchanged, target shows one lane per target + untargeted, toggle round-trips, no crash. e2e/timeline- axis.spec.ts guards it — build + renderer-smoke both passed the two crashes this found, so the regression test launches the app. Co-Authored-By: Claude Opus 4.8 --- e2e/timeline-axis.spec.ts | 69 +++++++++++++++++ src/renderer/src/components/Timeline.tsx | 96 ++++++++++++++++++------ src/renderer/src/i18n/en.json | 4 + src/renderer/src/i18n/zh-TW.json | 4 + 4 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 e2e/timeline-axis.spec.ts diff --git a/e2e/timeline-axis.spec.ts b/e2e/timeline-axis.spec.ts new file mode 100644 index 00000000..2aefcaad --- /dev/null +++ b/e2e/timeline-axis.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' +import { launchWithTempHome, openTestProject } from './helpers' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +// Phase C lane-axis: the timeline can group rows by source type (default) or by +// target. This drives the real app because the two crashes we hit building it — +// a dropped import and a `for (const lane of LANES)` that indexed a target-keyed +// map — both passed `npm run build` and the renderer-smoke unit test; only +// launching the app surfaced them. So the regression guard has to launch too. + +let app: ElectronApplication +let page: Page +let tmpHome = '' + +const shellLane = (): ReturnType => page.getByText('Shell').first() + +test.describe.serial('timeline lane axis', () => { + test.beforeAll(async () => { + ;({ app, page, tmpHome } = await launchWithTempHome()) + await openTestProject(page, 'axis-e2e') + + const token = readFileSync(join(tmpHome, '.redlog', 'api-token'), 'utf8').trim() + const port = readFileSync(join(tmpHome, '.redlog', 'api-port'), 'utf8').trim() + const post = (body: unknown): Promise => + fetch(`http://127.0.0.1:${port}/api/events`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + // Two targets + one untargeted, across source types: source shows + // Shell/Scanner/DNS; target shows two hosts + the untargeted lane. + for (const e of [ + { agent_type: 'shell', target_id: '10.0.0.5', data: { subtype: 'command_start', command: 'nmap 10.0.0.5' } }, + { agent_type: 'scanner', target_id: '10.0.0.5', data: { subtype: 'http_request', url: 'http://10.0.0.5/' } }, + { agent_type: 'dns', target_id: 'app.example.com', data: { subtype: 'dns_query', query: 'app.example.com' } }, + { agent_type: 'shell', data: { subtype: 'command_start', command: 'whoami' } } + ]) await post(e) + + // Normalise the persisted axis to source so the suite is deterministic + // regardless of a prior session's toggle (localStorage survives across runs). + await page.evaluate(() => localStorage.setItem('redlog-timeline-lane-axis', 'source')) + await page.reload() + await page.waitForLoadState('domcontentloaded') + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+2' : 'Control+2') + await expect(page.locator('[data-testid="view-root"]')).toHaveAttribute('data-view', 'timeline') + await expect(shellLane()).toBeVisible({ timeout: 10_000 }) + }) + + test.afterAll(async () => { await app?.close() }) + + test('source axis renders source lanes, no crash', async () => { + await expect(page.getByText('timeline crashed')).toHaveCount(0) + await expect(shellLane()).toBeVisible() + }) + + test('target axis renders target lanes + untargeted, no crash', async () => { + await page.getByRole('button').filter({ hasText: 'By source' }).first().click({ timeout: 5000 }) + await expect(page.getByText('timeline crashed')).toHaveCount(0) + await expect(page.getByText('10.0.0.5').first()).toBeVisible() + await expect(page.getByText('Untargeted').first()).toBeVisible() + }) + + test('toggling back to source restores source lanes', async () => { + await page.getByRole('button').filter({ hasText: 'By target' }).first().click({ timeout: 5000 }) + await expect(page.getByText('timeline crashed')).toHaveCount(0) + await expect(shellLane()).toBeVisible() + }) +}) diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 1c05d4c9..ad9ccadf 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -11,6 +11,7 @@ import { resolveTimelineKey } from '../lib/timelineKeys' import { wheelMode } from '../lib/timelineWheel' import { activeModes, type TimelineModeState } from '../lib/timelineModes' import { populatedLanes as computePopulatedLanes, visibleLanes as computeVisibleLanes, soloLaneOf } from '../lib/laneVisibility' +import { buildLaneModel } from '../lib/timelineAxis' import { shortcutsForScope, modKey, MOD_TOKEN } from '../lib/shortcuts' const MIN_LANE_H = 36 @@ -1146,19 +1147,52 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // A lane with no events is dead vertical space — most engagements only ever // touch three or four of them. Keep the filter chip so the operator can see // the lane exists, but don't give it a row until something lands in it. - // Phase C step 1: the populated/visible resolution lives in lib/laneVisibility - // (unit-tested) so the axis refactor can lean on it; this is a zero-behaviour - // swap of the former inline `new Set(...)` / `LANES.filter(...)` memos. + // Phase C: the lane model is axis-driven (lib/timelineAxis + laneVisibility, + // both unit-tested). 'source' reproduces the former inline behaviour exactly + // (lanes = LANES, grouped by toLane); 'target' groups events by target with an + // untargeted lane last (§9/§12). Default 'source', persisted, so upgrade is a + // no-op until the operator opts in. + const [laneAxis, setLaneAxis] = useState<'source' | 'target'>(() => { + try { return localStorage.getItem('redlog-timeline-lane-axis') === 'target' ? 'target' : 'source' } catch { return 'source' } + }) + useEffect(() => { + try { localStorage.setItem('redlog-timeline-lane-axis', laneAxis) } catch { /* private mode */ } + }, [laneAxis]) + // v0.6.95 P1-12: pluginTypes must stay in deps — a plugin-registered event - // lane wouldn't appear on the filter chip until an unrelated re-render. - const populatedLanes = useMemo( - () => computePopulatedLanes(events, (e) => toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes)), - [events, pluginTypes] + // lane wouldn't appear until an unrelated re-render. + const srcLaneOf = useCallback( + (e: RedLogEvent): LaneId => toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes), + [pluginTypes] ) + const laneModel = useMemo( + () => buildLaneModel( + laneAxis, + events, + LANES.map((id) => ({ id, label: laneLabels[id] })), + srcLaneOf, + t('timeline.untargeted') + ), + [laneAxis, events, laneLabels, srcLaneOf, t] + ) + const laneEvents = laneModel.laneEvents as Record + const axisLanes = laneModel.lanes + // Label + row-marker colour for a lane id, axis-aware. Target lanes have no + // source-type colour, so the row marker falls back to neutral zinc; dots on + // the track stay coloured by source-type (see `dotColor`). + const laneLabelOf = useCallback( + (id: string): string => axisLanes.find((l) => l.id === id)?.label ?? laneLabels[id] ?? id, + [axisLanes, laneLabels] + ) + const laneRowColor = (id: string): string => LANE_COLORS[id] ?? '#71717a' + const populatedLanes = useMemo( + () => computePopulatedLanes(events, laneModel.laneOf), + [events, laneModel] + ) const visibleLanes = useMemo( - () => computeVisibleLanes(LANES, populatedLanes, hiddenLanes), - [populatedLanes, hiddenLanes] + () => computeVisibleLanes(axisLanes.map((l) => l.id), populatedLanes, hiddenLanes), + [axisLanes, populatedLanes, hiddenLanes] ) // T3: the "Active:" row. activeModes() (lib/timelineModes.ts, unit-tested) is @@ -1449,11 +1483,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { const fromX = useCallback((px: number) => timeMap.fromX(px), [timeMap]) const totalH = visibleLanes.length * laneH - const laneEvents = useMemo(() => { - const map = Object.fromEntries(LANES.map((l) => [l, [] as RedLogEvent[]])) as Record - for (const e of events) map[toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes)].push(e) - return map - }, [events, pluginTypes]) + // laneEvents now comes from `laneModel` above (axis-driven, seeded per lane). // 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 @@ -1635,8 +1665,10 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // DOM, which is what makes this affordable. const maxZoom = useMemo(() => { let tightest = Infinity - for (const lane of LANES) { - const evs = laneEvents[lane] + // Iterate the model's lane arrays directly — keyed by source lane or target + // depending on the axis, so a fixed `LANES` walk would miss target lanes and + // index undefined. + for (const evs of Object.values(laneEvents)) { for (let i = 1; i < evs.length; i++) { const d = displayTs(evs[i]) - displayTs(evs[i - 1]) if (d > 0 && d < tightest) tightest = d @@ -2507,6 +2539,17 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { agent subtypes are dropped from the render pipeline — the hidden count is shown so the empty agent lane doesn't look like a bug. Same visual weight as the other filter chips. */} + {/* Phase C: lane axis — group rows by source type (recording view) or + by target (reconstruction view). Source is the default. */} + + )} - {LANES.map((id) => { + {/* Lane filter chips are source-type specific (colour + label); on the + target axis they're hidden for now (v1 — target lanes are data- + derived and use no palette). */} + {laneAxis === 'source' && LANES.map((id) => { const empty = !populatedLanes.has(id) const hidden = hiddenLanes.has(id) const off = empty || hidden @@ -2914,8 +2960,8 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { className="flex items-center gap-1.5 px-2 border-b border-zinc-800/30 font-mono text-[11px]" style={{ height: laneH }} > - - {laneLabels[id]} + + {laneLabelOf(id)}
))}
@@ -3116,6 +3162,10 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // node, and ↑/↓ walks from there — the same model the // existing keyboard handler already implements. const isTabStop = single && (sel || (!selectedEvent && c === visibleClusters[0])) + // Dot colour is always the SOURCE-type colour, even on the + // target axis where c.lane is a target id (no palette entry). + // Multi-event clusters take their first event's source type. + const dotColor = LANE_COLORS[srcLaneOf(c.events[0])] ?? laneRowColor(c.lane) return ( + {/* Phase C step 4: phase ribbon toggle. */} + +
{/* Lanes area */} diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 1507347c..ff42c0df 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -115,6 +115,9 @@ "timeline.axisSource": "By source", "timeline.axisTarget": "By target", "timeline.axisHint": "Group lanes by source type (recording view) or by target (reconstruction view)", + "timeline.phaseRibbon": "Phases", + "timeline.phaseRibbonHint": "Show the phase ribbon — operator phase markers (solid) and inferred suggestions (dashed)", + "timeline.phasePromote": "Inferred phase: {{phase}} — click to add a marker", "timeline.shellEvent": "Shell event", "timeline.scanner": "Scanner", "timeline.laneExternalOnly": "{lane} — no built-in producer; populated only by external agents posting via /api/events", diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 3c1bc93a..0553e724 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -115,6 +115,9 @@ "timeline.axisSource": "依來源", "timeline.axisTarget": "依目標", "timeline.axisHint": "lane 依來源類型分(記錄視圖)或依目標分(重建視圖)", + "timeline.phaseRibbon": "階段", + "timeline.phaseRibbonHint": "顯示階段帶 — operator 階段標記(實線)與推論建議(虛線)", + "timeline.phasePromote": "推論階段:{{phase}} — 點擊新增標記", "timeline.shellEvent": "Shell 事件", "timeline.scanner": "掃描", "timeline.laneExternalOnly": "{lane} — 沒有內建擷取來源;只有外部 agent 透過 /api/events 送才會出現", From 8370824bb6b6ae4ff44e0c0a255a7d19cd6a4f72 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 22:09:47 +0800 Subject: [PATCH 12/47] =?UTF-8?q?feat(timeline):=20Phase=20C=20step=205=20?= =?UTF-8?q?=E2=80=94=20remove=20TargetView,=20deep-link=20Targets=20to=20a?= =?UTF-8?q?xis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O3 resolution: the target lane axis subsumes the standalone TargetView, so the component is deleted and the "Targets" sidebar entry now deep-links into the Timeline with the target axis on (persist + a redlog-timeline-set-axis event for the already-mounted case). goTo() routes both sidebar clicks and the number shortcut through this. Keeps one-click target access (the O2+O3 gap) without a separate view. - App: goTo() nav helper; TargetView import + render removed. - Timeline: listens for redlog-timeline-set-axis. - renderer-smoke drops the TargetView case; src/.../TargetView.tsx deleted. Verified in the app: clicking Targets lands on the timeline target axis (target + untargeted lanes). e2e gains a 5th guard. Completes Phase C. Co-Authored-By: Claude Opus 4.8 --- e2e/timeline-axis.spec.ts | 13 + src/renderer/src/App.tsx | 23 +- src/renderer/src/components/TargetView.tsx | 270 --------------------- src/renderer/src/components/Timeline.tsx | 10 + test/renderer-smoke.test.tsx | 2 - 5 files changed, 42 insertions(+), 276 deletions(-) delete mode 100644 src/renderer/src/components/TargetView.tsx diff --git a/e2e/timeline-axis.spec.ts b/e2e/timeline-axis.spec.ts index 5a9b01f5..e06eeab3 100644 --- a/e2e/timeline-axis.spec.ts +++ b/e2e/timeline-axis.spec.ts @@ -74,4 +74,17 @@ test.describe.serial('timeline lane axis', () => { await expect(page.getByText('timeline crashed')).toHaveCount(0) await expect(shellLane()).toBeVisible() }) + + test('the Targets sidebar entry deep-links into the timeline target axis', async () => { + // Step 5 (O3): TargetView was removed; "Targets" now opens the timeline with + // the target axis on. Start from source (a prior test may have left target). + await page.evaluate(() => localStorage.setItem('redlog-timeline-lane-axis', 'source')) + await page.reload() + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+2' : 'Control+2') + await expect(shellLane()).toBeVisible({ timeout: 10_000 }) + await page.getByText('Targets', { exact: true }).click() + await expect(page.locator('[data-testid="view-root"]')).toHaveAttribute('data-view', 'timeline') + await expect(page.getByText('10.0.0.5').first()).toBeVisible() + await expect(page.getByText('Untargeted').first()).toBeVisible() + }) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8d644ccc..76b1bb0f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,7 +7,6 @@ import TimelinePanel from './components/Timeline' import EventMarker from './components/EventMarker' import Settings from './components/Settings' import ProjectPicker from './components/ProjectPicker' -import { TargetView } from './components/TargetView' import { ScopeStatus } from './components/ScopeStatus' import { LootPanel } from './components/LootPanel' import { SearchPanel } from './components/SearchPanel' @@ -76,6 +75,21 @@ export default function App(): JSX.Element { else if (target === 'screenshot') window.redlog.screenshot.capture().catch(() => {}) } + // Navigation. Phase C step 5 (O3): the standalone TargetView is gone — the + // timeline's target lane axis subsumes it — so the "Targets" entry deep-links + // into the Timeline with the target axis switched on (persisted + a live event + // in case the panel is already mounted). + const goTo = (v: View): void => { + setFocusEvent(null) + if (v === 'targets') { + try { localStorage.setItem('redlog-timeline-lane-axis', 'target') } catch { /* private mode */ } + window.dispatchEvent(new CustomEvent('redlog-timeline-set-axis', { detail: 'target' })) + setView('timeline') + return + } + setView(v) + } + useEffect(() => { window.redlog.project.active().then((p) => { if (p) setProject(p) @@ -163,7 +177,7 @@ export default function App(): JSX.Element { const target = viewForShortcut(num) if (target) { e.preventDefault() - setView(target) + goTo(target) } } } @@ -216,7 +230,7 @@ export default function App(): JSX.Element { {/* Body */}
- { setFocusEvent(null); setView(v as View) }} /> + goTo(v as View)} />
@@ -239,7 +253,8 @@ export default function App(): JSX.Element { /> )} {view === 'screenshots' && } - {view === 'targets' && } + {/* Phase C step 5 (O3): TargetView removed — the "Targets" sidebar + entry deep-links into the Timeline's target axis via goTo(). */} {view === 'scope' && { setFocusEvent({ id: '', ts }); setView('timeline') }} />} {view === 'loot' && { setFocusEvent({ id, ts }); setView('timeline') }} onEmptyAction={handleEmptyAction} />} {view === 'marks' && { setFocusEvent({ id: '', ts }); setView('timeline') }} onEmptyAction={handleEmptyAction} />} diff --git a/src/renderer/src/components/TargetView.tsx b/src/renderer/src/components/TargetView.tsx deleted file mode 100644 index 1ac42722..00000000 --- a/src/renderer/src/components/TargetView.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { useI18n } from '../i18n' -import { EmptyState } from './Feedback' -import { emptyStateFor } from '../lib/emptyState' - -interface TargetEntry { - target: string - commands: string[] - firstSeen: number - lastSeen: number - inScope: boolean | null - eventCount: number -} - -// Match a target string against a scope pattern (subset of scope-monitor's rules -// — enough for UI classification, not enforcement). Handles wildcard subdomains -// (`*.example.com`) and CIDR (`10.0.0.0/24`). Anything else is exact-match. -function ipToLong(ip: string): number { - return ip.split('.').reduce((acc, o) => (acc << 8) + parseInt(o), 0) >>> 0 -} -function matchesScope(target: string, pattern: string): boolean { - if (pattern.startsWith('*.')) { - const bare = pattern.slice(2) - return target === bare || target.endsWith('.' + bare) - } - if (pattern.includes('/')) { - const [net, bits] = pattern.split('/') - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(target)) return false - const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 - return (ipToLong(target) & mask) === (ipToLong(net) & mask) - } - return target === pattern -} - -export function TargetView({ onEmptyAction }: { onEmptyAction?: (target: string) => void } = {}): JSX.Element { - const [targets, setTargets] = useState([]) - const [filter, setFilter] = useState<'all' | 'in_scope' | 'out_scope'>('all') - const [selected, setSelected] = useState(null) - const [evidence, setEvidence] = useState([]) - // Scope target list from project config — used to compute the inScope column - // on each target. Empty when config isn't set (in which case every target - // shows as "in-scope" since there's no rule to violate). - const [scopeTargets, setScopeTargets] = useState([]) - const { t } = useI18n() - - useEffect(() => { - // Refresh both on mount and whenever the operator saves settings — the - // auto-save (v0.6.21) doesn't broadcast, but any nav back to this view - // will re-mount and pick up the current config. - window.redlog.config.get().then((c) => { - const cfg = c as { scope?: { targets?: string[] } } | null - setScopeTargets(cfg?.scope?.targets ?? []) - }).catch(() => {}) - loadTargets() - const unsub = window.redlog.events.onNew((evt) => { - if (evt.data?.detectedTarget) loadTargets() - }) - return unsub - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Reclassify existing targets whenever scope config changes (e.g. operator - // added a scope entry after seeing an out-of-scope hit). - useEffect(() => { if (targets.length > 0) loadTargets() }, [scopeTargets]) - - async function loadTargets(): Promise { - // Include every event type that carries detectedTarget in data — not only - // shell (audit finding #34). Loot / http_navigation / screenshots can all - // reference targets and were invisible here before. - const events = await window.redlog.events.query({ limit: 1000 }) - const map = new Map() - for (const evt of events) { - const tgt = evt.data?.detectedTarget as string | undefined - if (!tgt) continue - const existing = map.get(tgt) - if (existing) { - existing.commands.push(evt.data.command as string) - existing.lastSeen = Math.max(existing.lastSeen, evt.timestamp) - existing.firstSeen = Math.min(existing.firstSeen, evt.timestamp) - existing.eventCount++ - } else { - map.set(tgt, { - target: tgt, - commands: [evt.data.command as string], - firstSeen: evt.timestamp, - lastSeen: evt.timestamp, - inScope: null, - eventCount: 1 - }) - } - } - // Classify each target as in-scope / out-of-scope based on the current - // scope config (audit finding #33 — before this the field was set to null - // and both filter chips returned empty). If scope is unset, treat every - // target as in-scope (no rule to violate). - const list = Array.from(map.values()).sort((a, b) => b.lastSeen - a.lastSeen) - for (const entry of list) { - if (scopeTargets.length === 0) { entry.inScope = true; continue } - entry.inScope = scopeTargets.some((p) => matchesScope(entry.target, p)) - } - setTargets(list) - } - - const loadEvidence = useCallback(async (target: string) => { - if (selected === target) { - setSelected(null) - setEvidence([]) - return - } - setSelected(target) - const allEvents = await window.redlog.events.query({ limit: 500 }) - const filtered = allEvents.filter((e) => { - if (e.targetId === target) return true - if (e.data?.detectedTarget === target) return true - // Scope violations are agent_type='system' with subtype='scope_violation'; - // the prior code checked agent_type='scope_violation' which never matched - // (audit finding P0 #4), so this target's scope hits were invisible. - if (e.agentType === 'system' && e.data?.subtype === 'scope_violation' && e.data?.target === target) return true - return false - }) - setEvidence(filtered.sort((a, b) => b.timestamp - a.timestamp)) - }, [selected]) - - const filtered = targets.filter((tgt) => { - if (filter === 'in_scope') return tgt.inScope === true - if (filter === 'out_scope') return tgt.inScope === false - return true - }) - - const agentIcon: Record = { - shell: 'T', screenshot: 'S', clipboard: 'C', - file_transfer: 'F', marker: 'M', loot: 'L', system: '!' - } - - const agentColor: Record = { - shell: 'text-green-400', screenshot: 'text-blue-400', clipboard: 'text-yellow-400', - file_transfer: 'text-purple-400', marker: 'text-red-400', loot: 'text-orange-400', system: 'text-zinc-400' - } - - return ( -
-
-
-

{t('targets.title', { count: targets.length })}

-

{t('targets.subtitle')}

-
-
- {(['all', 'in_scope', 'out_scope'] as const).map((f) => ( - - ))} -
-
- - {filtered.length === 0 ? (() => { - const es = emptyStateFor('targets', { captureDark: false }) - return ( - onEmptyAction?.(es.action!.target) } - : undefined} - /> - ) - })() : ( -
- {filtered.map((tgt) => ( -
-
loadEvidence(tgt.target)} - className={`bg-zinc-900 border rounded-lg p-3 cursor-pointer transition-colors ${ - selected === tgt.target ? 'border-red-600' : 'border-zinc-800 hover:border-zinc-700' - }`} - > -
- {tgt.target} -
- {t('targets.cmds', { count: tgt.eventCount })} - {tgt.inScope === false && ( - <> - {t('targets.out')} - - - )} - {selected === tgt.target ? '▾' : '▸'} -
-
-
- {t('targets.first', { time: new Date(tgt.firstSeen).toLocaleTimeString() })} · {t('targets.last', { time: new Date(tgt.lastSeen).toLocaleTimeString() })} -
-
- - {selected === tgt.target && ( -
- {evidence.length === 0 ? ( -

{t('targets.noEvidence')}

- ) : ( - <> -
- {Object.entries( - evidence.reduce>((acc, e) => { - acc[e.agentType] = (acc[e.agentType] || 0) + 1 - return acc - }, {}) - ).map(([type, count]) => ( - - {type}: {count} - - ))} -
- {evidence.slice(0, 20).map((e) => ( -
- - {agentIcon[e.agentType] || '?'} - - - {new Date(e.timestamp).toLocaleTimeString()} - - - {e.agentType === 'shell' && (e.data.command as string)} - {e.agentType === 'screenshot' && `Screenshot: ${e.data.filename as string}`} - {e.agentType === 'clipboard' && `Clipboard: ${(e.data.content as string)?.slice(0, 60) || ''}`} - {e.agentType === 'file_transfer' && `${e.data.direction}: ${e.data.filename || e.data.localPath || e.data.remotePath}`} - {e.agentType === 'marker' && `[${e.data.severity}] ${e.data.title}`} - {e.agentType === 'loot' && (() => { const m = (e.data.matches as Array<{ type: string; confidence: string }>)?.[0]; return m ? `Loot: ${m.type.replace(/_/g, ' ')} (${m.confidence})` : `Loot: ${e.data.count ?? 0} detected` })()} - {!['shell', 'screenshot', 'clipboard', 'file_transfer', 'marker', 'loot'].includes(e.agentType) && JSON.stringify(e.data).slice(0, 80)} - -
- ))} - {evidence.length > 20 && ( -

{t('targets.andMore', { count: evidence.length - 20 })}

- )} - - )} -
- )} -
- ))} -
- )} -
- ) -} diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index e0803914..6c29bf8e 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -1174,6 +1174,16 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { useEffect(() => { try { localStorage.setItem('redlog-timeline-lane-axis', laneAxis) } catch { /* private mode */ } }, [laneAxis]) + // Phase C step 5: the "Targets" sidebar entry deep-links here by dispatching + // this event (TargetView was removed — the target axis subsumes it). + useEffect(() => { + const onSetAxis = (e: Event): void => { + const detail = (e as CustomEvent).detail + if (detail === 'source' || detail === 'target') setLaneAxis(detail) + } + window.addEventListener('redlog-timeline-set-axis', onSetAxis) + return () => window.removeEventListener('redlog-timeline-set-axis', onSetAxis) + }, []) // Phase C step 4: the phase ribbon. Off by default until the operator wants it. const [phaseRibbon, setPhaseRibbon] = useState(() => { diff --git a/test/renderer-smoke.test.tsx b/test/renderer-smoke.test.tsx index 3f7a252f..69df51ad 100644 --- a/test/renderer-smoke.test.tsx +++ b/test/renderer-smoke.test.tsx @@ -19,7 +19,6 @@ import ProjectPicker from '../src/renderer/src/components/ProjectPicker' import Sidebar from '../src/renderer/src/components/Sidebar' import StatusBar from '../src/renderer/src/components/StatusBar' import IPStatusCard from '../src/renderer/src/components/IPStatusCard' -import { TargetView } from '../src/renderer/src/components/TargetView' import { ScopeStatus } from '../src/renderer/src/components/ScopeStatus' import { LootPanel } from '../src/renderer/src/components/LootPanel' import { SearchPanel } from '../src/renderer/src/components/SearchPanel' @@ -214,7 +213,6 @@ describe('renderer views render without throwing', () => { ['Sidebar', () => {}} />], ['StatusBar', () => ], ['IPStatusCard', () => ], - ['TargetView', () => ], ['ScopeStatus', () => ], ['LootPanel', () => ], ['SearchPanel', () => ], From b986441c4853c3e652cf07ec19ff87e4b86175ea Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 22:35:09 +0800 Subject: [PATCH 13/47] feat(capture): show mitmproxy request bodies + real end-to-end mitm test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthens the HTTP capture path and makes request/response actually reviewable. - ScannerDetail rendered the response body but NOT the request body, even though the addon captures request_body_preview — so POST/PUT payloads were invisible. Now rendered alongside params (i18n timeline.detail.httpRequestBody). - mitmproxy addon default body cap raised 2 KB → 16 KB (REDLOG_MAX_BODY), so a typical JSON/form body is reviewable without truncation. Bodies are still inline (no io_ref sidecar yet — that's the larger follow-up for full bodies). - New e2e/mitmproxy-capture.spec.ts: a REAL end-to-end test — curl → mitmdump (hooks/mitmproxy-addon.py) → local echo server, then asserts RedLog stored the http_request_start + http_response scanner events with the request body ("s3cr3t-token") and response body ("echoedBody"), paired via flow_id. Skips when mitmdump isn't installed (REDLOG_MITMDUMP / PATH). Verified passing with mitmproxy 12.2.3. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + e2e/mitmproxy-capture.spec.ts | 125 +++++++++++++++++++++++ hooks/mitmproxy-addon.py | 7 +- src/renderer/src/components/Timeline.tsx | 12 +++ src/renderer/src/i18n/en.json | 1 + src/renderer/src/i18n/zh-TW.json | 1 + 6 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 e2e/mitmproxy-capture.spec.ts diff --git a/.gitignore b/.gitignore index 0ac88c02..f6ae8d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ # Runtime data (screenshots, DB, cast files) .redlog/ test-results/ +__pycache__/ diff --git a/e2e/mitmproxy-capture.spec.ts b/e2e/mitmproxy-capture.spec.ts new file mode 100644 index 00000000..b3f33a67 --- /dev/null +++ b/e2e/mitmproxy-capture.spec.ts @@ -0,0 +1,125 @@ +// End-to-end mitmproxy capture. Real flow: +// curl → mitmdump (hooks/mitmproxy-addon.py) → local echo server +// then assert RedLog captured the http_request_start + http_response scanner +// events, INCLUDING the request AND response bodies. Isolated temp HOME. +// +// Skips unless mitmdump is available: set REDLOG_MITMDUMP=/path/to/mitmdump (or +// have `mitmdump` on PATH). `pipx install mitmproxy` / a venv both work. +import { test, expect } from '@playwright/test' +import { launchWithTempHome, openTestProject } from './helpers' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { spawn, spawnSync } from 'node:child_process' +import http from 'node:http' +import net from 'node:net' +import type { AddressInfo } from 'node:net' + +const MITMDUMP = process.env.REDLOG_MITMDUMP ?? 'mitmdump' +function mitmdumpAvailable(): boolean { + try { return spawnSync(MITMDUMP, ['--version'], { timeout: 10_000 }).status === 0 } catch { return false } +} + +// Resolves once something is accepting TCP on the port, or throws after timeout. +async function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + const ok = await new Promise((resolve) => { + const s = net.connect(port, '127.0.0.1') + s.once('connect', () => { s.destroy(); resolve(true) }) + s.once('error', () => resolve(false)) + }) + if (ok) return + if (Date.now() > deadline) throw new Error(`port ${port} never came up`) + await new Promise((r) => setTimeout(r, 300)) + } +} + +const PROXY_PORT = 8899 + +test('mitmproxy addon captures request + response bodies end to end', async () => { + test.skip(!mitmdumpAvailable(), 'mitmdump not installed (set REDLOG_MITMDUMP or add to PATH)') + test.setTimeout(60_000) + + // 1) A local echo server that reflects the request body in a JSON response. + const echo = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', () => { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ echoedBody: body, method: req.method, path: req.url })) + }) + }) + await new Promise((r) => echo.listen(0, '127.0.0.1', () => r())) + const echoPort = (echo.address() as AddressInfo).port + + // 2) RedLog up (API server + token/port on disk under the temp HOME). + const { app, page, tmpHome } = await launchWithTempHome() + await openTestProject(page, 'mitm-verify') + const apiPort = readFileSync(join(tmpHome, '.redlog', 'api-port'), 'utf8').trim() + const token = readFileSync(join(tmpHome, '.redlog', 'api-token'), 'utf8').trim() + + // 3) mitmdump with the addon. HOME=tmpHome so the addon reads THIS RedLog's + // api-port/token. block_global=false allows proxying to loopback. + const mitm = spawn( + MITMDUMP, + ['-s', join(process.cwd(), 'hooks', 'mitmproxy-addon.py'), + '--listen-port', String(PROXY_PORT), '--set', 'block_global=false'], + { env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_VERBOSE: 'true' }, stdio: 'pipe' } + ) + let mitmLog = '' + mitm.stdout?.on('data', (b: Buffer) => (mitmLog += b.toString())) + mitm.stderr?.on('data', (b: Buffer) => (mitmLog += b.toString())) + await waitForPort(PROXY_PORT, 25_000) // wait until the proxy is actually up + await new Promise((r) => setTimeout(r, 500)) + + // 4) A POST with a JSON body, through the proxy, to the echo server. Async + // (not execFileSync) — the echo server lives in THIS node event loop, so a + // synchronous curl would block it and deadlock (proxy → echo can't answer). + const curlOut = await new Promise((resolve, reject) => { + const c = spawn('curl', [ + '-s', '-x', `http://127.0.0.1:${PROXY_PORT}`, + '-X', 'POST', `http://127.0.0.1:${echoPort}/api/login`, + '-H', 'content-type: application/json', + '-d', '{"user":"admin","pass":"s3cr3t-token"}' + ]) + let out = '' + c.stdout.on('data', (b: Buffer) => (out += b.toString())) + c.on('close', () => resolve(out)) + c.on('error', reject) + setTimeout(() => { c.kill(); reject(new Error('curl timed out')) }, 15_000) + }) + console.log('[mitm] curl got:', curlOut.slice(0, 120)) + await new Promise((r) => setTimeout(r, 2500)) // addon posts to RedLog async + + // 5) Read back the scanner events RedLog stored. + const res = await fetch(`http://127.0.0.1:${apiPort}/api/events?agent_type=scanner&limit=50`, { + headers: { Authorization: `Bearer ${token}` } + }) + const payload = await res.json() as unknown + const raw = Array.isArray(payload) ? payload : ((payload as { events?: unknown[] }).events ?? []) + const events = (raw as Array<{ data: unknown }>).map((e) => ({ + data: (typeof e.data === 'string' ? JSON.parse(e.data) : e.data) as Record + })) + const req = events.find((e) => e.data?.subtype === 'http_request_start') + const rsp = events.find((e) => e.data?.subtype === 'http_response') + + console.log('[mitm] scanner events:', events.length, + 'req?', !!req, 'rsp?', !!rsp, + '\n[mitm] mitmdump log tail:', mitmLog.split('\n').slice(-6).join(' | ')) + + mitm.kill('SIGTERM') + echo.close() + + // Assertions: both directions captured, with bodies. + expect(req, 'http_request_start event').toBeTruthy() + expect(rsp, 'http_response event').toBeTruthy() + expect(String(req?.data?.method)).toBe('POST') + expect(String(req?.data?.request_body_preview)).toContain('s3cr3t-token') + expect(String(rsp?.data?.status)).toBe('200') + expect(String(rsp?.data?.response_preview)).toContain('echoedBody') + // the flow is paired via flow_id → _causes on the response + expect(req?.data?.flow_id).toBeTruthy() + expect(rsp?.data?.flow_id).toBe(req?.data?.flow_id) + + await app.close() +}) diff --git a/hooks/mitmproxy-addon.py b/hooks/mitmproxy-addon.py index a5f7cfa1..46001e87 100644 --- a/hooks/mitmproxy-addon.py +++ b/hooks/mitmproxy-addon.py @@ -28,7 +28,7 @@ - DNS response: response_code, answers, duration_ms, _causes ← query event Environment variables: - REDLOG_MAX_BODY Max request/response body bytes to capture (default 2048) + REDLOG_MAX_BODY Max request/response body bytes to capture (default 16384) REDLOG_SKIP_STATIC Skip static assets like .css/.js/.png/.woff (default true) REDLOG_VERBOSE Log every request/DNS message to stderr (default false) """ @@ -51,7 +51,10 @@ REDLOG_PORT_FILE = Path.home() / ".redlog" / "api-port" REDLOG_TOKEN_FILE = Path.home() / ".redlog" / "api-token" -MAX_BODY = int(os.environ.get("REDLOG_MAX_BODY", "2048")) +# 16 KB by default (was 2 KB) — enough to review a typical JSON/form request or +# response body without truncation. Bodies still live inline on the event (no +# sidecar yet), so this trades a little DB size for actually-reviewable payloads. +MAX_BODY = int(os.environ.get("REDLOG_MAX_BODY", "16384")) SKIP_STATIC = os.environ.get("REDLOG_SKIP_STATIC", "true").lower() in ("true", "1", "yes") VERBOSE = os.environ.get("REDLOG_VERBOSE", "false").lower() in ("true", "1", "yes") diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 6c29bf8e..bdbdb49e 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -3984,6 +3984,18 @@ function ScannerDetail({ data }: { data: Record }): JSX.Element startOpen /> )} + {/* The request body the mitmproxy addon captures (request_body_preview) was + collected but never shown — only params were. Render it like the + response body so POST/PUT payloads are actually reviewable. */} + {!isResponse && typeof data.request_body_preview === 'string' && (data.request_body_preview as string).length > 0 && ( + + )} {isResponse && preview.length > 0 && ( Date: Wed, 12 Aug 2026 23:53:27 +0800 Subject: [PATCH 14/47] chore(typecheck): wire a renderer typecheck gate; fix bugs it surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from the mitm work: build (esbuild) never typechecks, which is how a dropped import shipped a crashing timeline. Added `npm run typecheck` (tsc --noEmit -p tsconfig.web.json) and made it meaningful: - env.d.ts: augment `Window.redlog` at top level (was inside `declare global` in a non-module file, so it never applied) — cleared ~200 phantom errors. - tsconfig.web.json: add `target: ES2022` (kills downlevelIteration noise) + `resolveJsonModule`. Bugs the gate caught and this fixes: - App.tsx: `onNavigate` is undefined in the transcript "open in timeline" handler — a real latent ReferenceError (same class as the onSidebarOrderChanged crash). Now `setView('timeline')`. - Timeline.tsx: `accent="cyan"` isn't a valid CollapsibleStream accent (shipped in the prior commit) → `zinc`; axis label/colour lookups indexed a LaneId-keyed record with a string; laneEvents cast removed now that buildLaneModel infers. - lib: groupByTarget/timelineAxis accept `readonly` events; dropped the AxisEvent index signature in favour of generics. All renderer lib/Phase-C code is now type-clean. ~38 pre-existing bridge/config type-drift errors (env.d.ts RedLogAPI + config types long out of sync) remain as a tracked cleanup toward a green CI gate. Co-Authored-By: Claude Opus 4.8 --- package.json | 3 ++- src/renderer/src/App.tsx | 2 +- src/renderer/src/components/Timeline.tsx | 10 +++++----- src/renderer/src/env.d.ts | 6 ++---- src/renderer/src/lib/targetGrouping.ts | 2 +- src/renderer/src/lib/timelineAxis.ts | 7 +++---- tsconfig.web.json | 2 ++ 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index f0d14f66..cd670cb1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "pree2e": "electron-rebuild -f -o better-sqlite3", "e2e": "playwright test", "pree2e:ui": "electron-rebuild -f -o better-sqlite3", - "e2e:ui": "playwright test --ui" + "e2e:ui": "playwright test --ui", + "typecheck": "tsc --noEmit -p tsconfig.web.json" }, "dependencies": { "@xterm/addon-fit": "^0.10.0", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 76b1bb0f..2d53cfa6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -248,7 +248,7 @@ export default function App(): JSX.Element { {view === 'transcript' && ( { setFocusEvent({ id, ts }); onNavigate('timeline') }} + onOpenInTimeline={(id, ts) => { setFocusEvent({ id, ts }); setView('timeline') }} onEmptyAction={handleEmptyAction} /> )} diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index bdbdb49e..cd91274b 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -1209,16 +1209,16 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { ), [laneAxis, events, laneLabels, srcLaneOf, t] ) - const laneEvents = laneModel.laneEvents as Record + const laneEvents = laneModel.laneEvents const axisLanes = laneModel.lanes // Label + row-marker colour for a lane id, axis-aware. Target lanes have no // source-type colour, so the row marker falls back to neutral zinc; dots on // the track stay coloured by source-type (see `dotColor`). const laneLabelOf = useCallback( - (id: string): string => axisLanes.find((l) => l.id === id)?.label ?? laneLabels[id] ?? id, + (id: string): string => axisLanes.find((l) => l.id === id)?.label ?? (laneLabels as Record)[id] ?? id, [axisLanes, laneLabels] ) - const laneRowColor = (id: string): string => LANE_COLORS[id] ?? '#71717a' + const laneRowColor = (id: string): string => (LANE_COLORS as Record)[id] ?? '#71717a' const populatedLanes = useMemo( () => computePopulatedLanes(events, laneModel.laneOf), @@ -1242,7 +1242,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { focusActive: focusChain !== null, anomalyOnly: anomalyFilter, collapseAgentTurns, - soloLane: solo ? laneLabels[solo] : null, + soloLane: solo ? laneLabelOf(solo) : null, hiddenLaneCount: hiddenLanes.size, filterQuery } @@ -3992,7 +3992,7 @@ function ScannerDetail({ data }: { data: Record }): JSX.Element label={t('timeline.detail.httpRequestBody')} content={data.request_body_preview as string} bytes={(data.request_body_preview as string).length} - accent="cyan" + accent="zinc" startOpen /> )} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index b45ab3fa..460022c9 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -298,8 +298,6 @@ interface RedLogConfigPartial { screenshot?: { quality?: number } } -declare global { - interface Window { - redlog: RedLogAPI - } +interface Window { + redlog: RedLogAPI } diff --git a/src/renderer/src/lib/targetGrouping.ts b/src/renderer/src/lib/targetGrouping.ts index 5566a6f3..81f5ceb2 100644 --- a/src/renderer/src/lib/targetGrouping.ts +++ b/src/renderer/src/lib/targetGrouping.ts @@ -44,7 +44,7 @@ function targetKeyOf(event: EventLike): string | null { * every group eventIds are sorted ascending by timestamp, and firstTs/lastTs bound the * group's time range. Empty input yields []. */ -export function groupByTarget(events: EventLike[]): TargetGroup[] { +export function groupByTarget(events: readonly EventLike[]): TargetGroup[] { // Bucket events by target while preserving nothing but membership; sorting happens // once at the end so unsorted input still produces correct group and member order. const buckets = new Map() diff --git a/src/renderer/src/lib/timelineAxis.ts b/src/renderer/src/lib/timelineAxis.ts index 0e85dd55..def1be0b 100644 --- a/src/renderer/src/lib/timelineAxis.ts +++ b/src/renderer/src/lib/timelineAxis.ts @@ -21,14 +21,13 @@ export interface AxisLane { label: string } -// Minimal event shape both axes need. Target axis reads id/timestamp/targetId -// (via groupByTarget); source axis reads whatever the caller's sourceLaneOf -// needs (e.g. agentType/subtype) — hence the open index signature. +// Minimal event shape both axes need. The functions are generic over the +// caller's concrete event type (e.g. RedLogEvent), so no open index signature is +// needed — sourceLaneOf reads whatever fields it wants off the concrete type. export interface AxisEvent { id: string timestamp: number targetId?: string | null - [k: string]: unknown } /** Stable id for the catch-all lane holding events with no target (§12). */ diff --git a/tsconfig.web.json b/tsconfig.web.json index 3450694e..8d409543 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -1,11 +1,13 @@ { "compilerOptions": { "composite": true, + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "allowSyntheticDefaultImports": true, "esModuleInterop": true, + "resolveJsonModule": true, "strict": true, "skipLibCheck": true, "outDir": "./out", From c9ea922851c7fc14b7f0f25b3c68a42aabf21fc4 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 23:53:27 +0800 Subject: [PATCH 15/47] test(e2e): de-flake timeline-axis suite via per-test axis reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serial suite shared persisted localStorage, so a prior test's axis toggle leaked into the next. beforeEach now resets to the source axis by dispatching the redlog-timeline-set-axis event (the mounted Timeline listens for it) — no racy reload/re-navigation. Also made the Targets click a nav-scoped role selector (the label matched two elements) and assert on target lanes, not a data-view proxy. 5/5 stable across repeated runs. Co-Authored-By: Claude Opus 4.8 --- e2e/timeline-axis.spec.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/e2e/timeline-axis.spec.ts b/e2e/timeline-axis.spec.ts index e06eeab3..88c66ee6 100644 --- a/e2e/timeline-axis.spec.ts +++ b/e2e/timeline-axis.spec.ts @@ -49,6 +49,17 @@ test.describe.serial('timeline lane axis', () => { test.afterAll(async () => { await app?.close() }) + // Normalise the axis to source before each test, deterministically and without + // a reload: the app already sits on the timeline (beforeAll), and the mounted + // Timeline listens for this event, so dispatching 'source' resets a prior + // test's toggle without a racy reload/re-navigation. + test.beforeEach(async () => { + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('redlog-timeline-set-axis', { detail: 'source' })) + }) + await expect(shellLane()).toBeVisible({ timeout: 10_000 }) + }) + test('source axis renders source lanes, no crash', async () => { await expect(page.getByText('timeline crashed')).toHaveCount(0) await expect(shellLane()).toBeVisible() @@ -61,7 +72,9 @@ test.describe.serial('timeline lane axis', () => { await expect(page.getByText('Untargeted').first()).toBeVisible() }) - test('toggling back to source restores source lanes', async () => { + test('toggling to target and back restores source lanes', async () => { + await page.getByRole('button').filter({ hasText: 'By source' }).first().click({ timeout: 5000 }) + await expect(page.getByText('10.0.0.5').first()).toBeVisible() await page.getByRole('button').filter({ hasText: 'By target' }).first().click({ timeout: 5000 }) await expect(page.getByText('timeline crashed')).toHaveCount(0) await expect(shellLane()).toBeVisible() @@ -77,14 +90,11 @@ test.describe.serial('timeline lane axis', () => { test('the Targets sidebar entry deep-links into the timeline target axis', async () => { // Step 5 (O3): TargetView was removed; "Targets" now opens the timeline with - // the target axis on. Start from source (a prior test may have left target). - await page.evaluate(() => localStorage.setItem('redlog-timeline-lane-axis', 'source')) - await page.reload() - await page.keyboard.press(process.platform === 'darwin' ? 'Meta+2' : 'Control+2') - await expect(shellLane()).toBeVisible({ timeout: 10_000 }) - await page.getByText('Targets', { exact: true }).click() - await expect(page.locator('[data-testid="view-root"]')).toHaveAttribute('data-view', 'timeline') + // the target axis on. beforeEach already put us on source. + await page.locator('nav').getByRole('button', { name: /Targets/ }).click() + // Success = the timeline is now on the target axis (target lanes present). await expect(page.getByText('10.0.0.5').first()).toBeVisible() await expect(page.getByText('Untargeted').first()).toBeVisible() + await expect(page.getByText('timeline crashed')).toHaveCount(0) }) }) From 8946b3dec8ef519865de56bf6e578ebfce916578 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Wed, 12 Aug 2026 23:55:24 +0800 Subject: [PATCH 16/47] =?UTF-8?q?docs:=20io=5Fref=20sidecar=20spec=20?= =?UTF-8?q?=E2=80=94=20full=20request/response=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last unshipped piece of v0.10.0 I/O visibility: store full captured HTTP bodies in a prunable /io/ sidecar (content-addressed, deduped) with only the sha256 in the chain — same reference-not-bytes model as the terminal .cast. Covers the capture path (server sidecars the posted body at the DB write chokepoint), io:read + ScannerDetail lazy load, bundle/retention(system.io_pruned)/ redlog-verify support, limits (REDLOG_MAX_BODY inline vs REDLOG_MAX_IO sidecar), acceptance criteria, migration (additive), non-goals, and a 5-step build order. Co-Authored-By: Claude Opus 4.8 --- docs/README.md | 1 + docs/SPEC-IO-SIDECAR.md | 153 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/SPEC-IO-SIDECAR.md diff --git a/docs/README.md b/docs/README.md index e934b645..64ef4bc4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links - **[UX backlog — ticket specs](UX-BACKLOG-TICKETS.md)** — every finding (F1–F7, T1–T6) broken out into an independently implementable ticket: problem, proposed solution, acceptance criteria, pure test seam, effort, priority order. - **[Timeline interaction redesign draft](DESIGN-TIMELINE-INTERACTION.md)** — concrete UI proposal with ASCII wireframes for the legend (T1), the wheel-mode decision matrix (T2), and the active-modes row (T3), plus the build order. - **[Timeline axis spec (Phase C)](SPEC-TIMELINE-AXIS.md)** — executable spec for the flagship refactor: re-axis the timeline to target/phase (per DESIGN-PRINCIPLES §8/§9), the safe toggle-behind incremental strategy, the seams to build on the Phase B foundation, acceptance criteria, regression guards, and three open questions. +- **[io_ref sidecar spec](SPEC-IO-SIDECAR.md)** — full (untruncated) request/response bodies in a prunable on-disk sidecar with only the sha256 in the chain — the last unshipped piece of the v0.10.0 I/O-visibility work. Capture/read/export/retention/verify design, acceptance criteria, and build order. - **[Dev requirements — capture onboarding](DEV-REQUIREMENTS-capture-onboarding.md)** — spec + acceptance criteria for the Capture Readiness onboarding, and the red→green→integrate→cover TDD process every UX change should copy. - **[Timeline I/O visibility](timeline-io-visibility.md)** — design note (proposed). Which sources capture input/output today, where the gaps are, and the `io_ref` sidecar + transcript-view proposal that closes them. diff --git a/docs/SPEC-IO-SIDECAR.md b/docs/SPEC-IO-SIDECAR.md new file mode 100644 index 00000000..95a53a1b --- /dev/null +++ b/docs/SPEC-IO-SIDECAR.md @@ -0,0 +1,153 @@ +# Spec — io_ref Sidecar for Full Request/Response Bodies + +Written 2026-08-12. The last unshipped piece of the v0.10.0 I/O-visibility work +(ROADMAP: "shipped as v0.11.2 except the sidecar"): store **full** captured HTTP +request/response bodies (and any other large capture payload) in a prunable +on-disk sidecar, keeping only their `sha256` in the hash chain. Today mitmproxy +bodies are inline, truncated previews (`request_body_preview` / `response_preview`, +capped at `REDLOG_MAX_BODY`, raised to 16 KB in b986441). This closes the "the +body was bigger than the cap, so you can't see what actually went over the wire" +gap without bloating the events table or the chain. + +## Why (the problem) + +1. **Truncation loses evidence.** A 40 KB JSON response or a multipart upload is + the thing a reviewer most wants, and it's exactly what the inline cap drops. +2. **Inline bodies bloat the chain.** Every byte of an inline preview is hashed + into the append-only chain and re-walked on every full verify. Bodies are + large, frequent (every request/response), and — unlike a marker — not the + operator's assertion; they don't belong in the chain, only their digest does. +3. **The pattern already exists.** Terminal stdout is NOT stored in the chain — + the `.cast` lives on disk and the event carries a reference + (`{stream:'cast', ref, off, len, truncated}`), sliced on demand by + `cast-slice.ts` (`src/core/api-server.ts` ~L409). The io_ref sidecar is the + same idea, generalized to HTTP (and future) bodies. + +## Core invariant (unchanged) + +**Bytes never enter the chain — only their `sha256`.** The v0.6.47 revert stands +(`ROADMAP.md`). A body's digest is chained; the bytes live in the sidecar. This +keeps `redlog-verify` and OpenTimestamps meaningful (they attest the digest, and +the sidecar bytes are checkable against it) while the chain stays small. + +## The sidecar + +- **Location:** `/io/` (peer of `screenshots/`, `terminal/`). +- **Append-only, content-addressed:** a body is written once to + `io/.bin` (dedup by digest — identical bodies stored once). No edits, + no deletes except retention pruning. +- **Ref shape on the event** (mirrors the io.stdout shape from v0.9.6): + ```jsonc + "io": { + "request": { "ref": "", "len": 40213, "sha256": "", "ct": "application/json", "truncated": false }, + "response": { "ref": "", "len": 128932, "sha256": "", "ct": "text/html", "truncated": false } + } + ``` + `ref` is the sidecar filename stem; `len` the full byte length; `truncated` + true only if capture itself hit a hard ceiling (see limits). The short inline + `*_preview` fields stay for at-a-glance display and offline bundles; the sidecar + holds the full body. + +## Capture path (who writes the sidecar) + +Two options; **(B) is recommended** — it keeps the digest authoritative and the +addon dumb. + +- **(A) Addon writes the file.** The mitmproxy addon writes `io/.bin` + directly and posts the ref. Rejected: the addon runs under a HOME that may not + be the project dir; it would need the project path, and a capture source + writing into the evidence store is a wider trust surface. +- **(B) Addon posts the full body; the server sidecars it.** *(recommended)* The + addon posts the full body (up to a generous `REDLOG_MAX_IO` ceiling, e.g. + 2 MB) on a new field; `POST /api/events` computes the `sha256`, writes + `io/.bin` if absent, replaces the body with the `io.*` ref before the + event is chained, and keeps the short `*_preview` inline. The server is already + the single DB-write chokepoint (pause gate, `noteDbError`), so this is where + the sidecar write belongs. Bodies above the post ceiling are marked + `truncated:true` and only the preview is kept. + +## Read path + +- **`io:read` IPC** (already named in ROADMAP): `io:read(ref, off?, len?) → + bytes`, validated to stay within `/io/` (same path-validation + discipline as screenshot reads). Range args mirror the cast slice API. +- **ScannerDetail** lazy-loads: shows the inline preview immediately, and when a + body is sidecar-backed and larger than the preview, a "load full body + (`len`)" affordance calls `io:read`. Binary/oversize bodies say so rather than + dumping raw bytes. +- Generalizes: the same viewer serves any event carrying `io.*` refs (browser + console payloads, future large captures). + +## Export / retention / verify + +- **Bundle export** (`redlog-cli export bundle`): copy the referenced + `io/.bin` files into the bundle's `io/`; the manifest already lists + screenshots + casts, add io refs. Sanitized exports (`redlog-cli sanitize`) + apply to sidecar bodies too — a sanitized replacement body is written and the + `system.sanitized` event records the digest swap (per `redaction-design.md` + Layer 4), so the chain still verifies. +- **Retention** (`src/core/retention.ts`): prune `io/` bodies older than the + keep window, emitting `system.io_pruned` (already reserved in ROADMAP) so the + gap is explainable — same arc as `.cast` pruning. The chain (digests only) is + untouched; a pruned body reads as "pruned by retention" not "missing". +- **`redlog-verify.py`**: when an event carries `io.*` refs, verify the sidecar + file's `sha256` matches the chained digest (bytes-on-disk match the attested + hash). A pruned/absent body is reported as pruned, not tampered. + +## Limits & config + +| Knob | Default | Meaning | +|---|---|---| +| `REDLOG_MAX_BODY` | 16 KB | inline preview cap (unchanged, for at-a-glance) | +| `REDLOG_MAX_IO` | 2 MB | max body posted to the sidecar; above → `truncated`, preview only | +| retention keep-days | project retention | when `io/` bodies are pruned | + +## Acceptance criteria + +- **A1** A response body larger than `REDLOG_MAX_BODY` but under `REDLOG_MAX_IO` + is fully retrievable via `io:read`; ScannerDetail shows the full body on + demand, and `len`/`truncated:false` are correct. +- **A2** The event row + chain contain only the `sha256` (+ short preview), never + the full bytes; DB size is ~unchanged vs. today for large bodies. +- **A3** Identical bodies across events are stored once (dedup by digest). +- **A4** `redlog-verify.py` confirms the sidecar file matches the chained digest; + a retention-pruned body verifies as *pruned*, not *tampered*. +- **A5** Bundle export includes referenced io bodies; `ots verify` on the bundle + still passes (chain unchanged). +- **A6** Pause semantics hold: while recording is paused, no body is sidecarred + (the gate is at `insertEvent`/`POST /api/events`, which already covers this). +- **A7** Path traversal is impossible — `io:read` refuses any ref outside + `/io/`. + +## Migration & compatibility + +- Purely additive: events without `io.*` refs (all historical, and any capture + under the post ceiling) behave exactly as today via the inline preview. +- No chain migration — old events keep their inline previews; new large bodies + gain refs. `redlog-verify` handles both. + +## Non-goals + +- Not a general blob store or attachment system — scoped to capture payloads + (HTTP bodies first). +- No at-rest encryption of the sidecar (tracked separately as a 1.x item, + `ROADMAP.md`); the threat model discloses on-disk exposure honestly today. +- No streaming of unbounded bodies — `REDLOG_MAX_IO` is a real ceiling; above it + the preview + `truncated` flag is the honest record. + +## Build order (each testable) + +1. Sidecar store module (`src/core/io-store.ts`): `putBody(projectDir, bytes) → + {ref, len, sha256}` (dedup), `readBody(projectDir, ref, off?, len?)`. Pure-ish + + fs; unit-testable with a temp dir. +2. `POST /api/events` (option B): sidecar the full-body field, stamp `io.*`, + drop bytes before chaining. Unit + the existing mitm e2e extended to assert a + >16 KB body round-trips. +3. `io:read` IPC + path validation. +4. ScannerDetail lazy full-body load. +5. Bundle export + retention prune (`system.io_pruned`) + `redlog-verify.py`. + +Cross-references: `redaction-design.md` (Layer 4 sanitize), `event-schema.md` +(io.stdout precedent), `src/core/cast-slice.ts` (the reference-not-bytes model), +`hooks/mitmproxy-addon.py` (`REDLOG_MAX_BODY`), commit b986441 (request-body +display + raised cap). From 47dc19d9e65fc6b4dae549ca3d30114f11e316a3 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 00:17:11 +0800 Subject: [PATCH 17/47] =?UTF-8?q?chore(typecheck):=20clear=20the=2038=20pr?= =?UTF-8?q?e-existing=20drifts=20=E2=80=94=20gate=20is=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run typecheck` now reports 0 errors, so it's a real CI gate. All type-level, no runtime change (verified: build + renderer-smoke + timeline-axis e2e green). - env.d.ts: brought RedLogAPI back in sync with the real preload bridge (src/preload/index.ts) — added app.openExternal, events.logSecretRevealed, the hooks and plugins namespaces (+ HookInfo/PluginInfo/PluginEventType), and CaptureHealthInfo.lastSampleBroken.eventTimestamp. Removed 3 workaround casts. - Config drift: scope.enforcement (legacy import), screenshot.intervalSec, and the overlay config union made honest about optional fields. - i18n: t()/interpolate vars widened to string|number|null|undefined (runtime already coerces); include src/renderer/src/**/*.json (composite needed it). - Sidebar/Timeline/OverlayApp/ProjectPicker: narrow SidebarViewId/LaneId casts, optional-chain the HUD-only overlay methods, Boolean()-coerce an unknown JSX child, non-null assert an already-guarded value. Note: overlay.quickMark/setExpanded live on a SEPARATE preload (overlay.ts) but share the one window.redlog type — modelled as optional members, so the shared type is honest about the HUD-only surface. No real bugs surfaced (unlike the onNavigate case earlier). Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/OverlayApp.tsx | 6 +- src/renderer/src/components/ProjectPicker.tsx | 4 +- src/renderer/src/components/Settings.tsx | 12 ++-- src/renderer/src/components/Sidebar.tsx | 8 +-- src/renderer/src/components/Timeline.tsx | 16 ++--- src/renderer/src/env.d.ts | 63 ++++++++++++++++++- src/renderer/src/i18n/I18nContext.tsx | 6 +- tsconfig.web.json | 2 +- 8 files changed, 88 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/OverlayApp.tsx b/src/renderer/src/OverlayApp.tsx index 500fae41..20bc0092 100644 --- a/src/renderer/src/OverlayApp.tsx +++ b/src/renderer/src/OverlayApp.tsx @@ -104,11 +104,11 @@ export default function OverlayApp(): JSX.Element { return () => { if (timerRef.current) clearTimeout(timerRef.current) } }, [expanded, interactive, pinned]) - const collapse = (): void => { setExpanded(false); window.redlog.overlay?.setExpanded(false) } + const collapse = (): void => { setExpanded(false); window.redlog.overlay?.setExpanded?.(false) } const toggleExpand = (): void => { const next = !expanded setExpanded(next) - window.redlog.overlay?.setExpanded(next) + window.redlog.overlay?.setExpanded?.(next) } // fs = font-size scaler; ip = extra emphasis for the external IP. @@ -326,7 +326,7 @@ export default function OverlayApp(): JSX.Element { {justMarked ? `✓ ${t('overlay.marked').toUpperCase()}` : `⚡ ${t('overlay.markQuick').toUpperCase()}`}
{project.name}
+ {/* F6: a persistent pointer entry to the Search view. It was only + reachable via ⌘/ (or ⌘K) before, so a newcomer couldn't find the + headline full-text search by looking. Kept out of the sidebar to + leave DEFAULT_ORDER length + the ⌘1..N numbering untouched. */} + ) })} diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index b87e50c6..21e63ee1 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -2,6 +2,7 @@ "app.title": "REDLOG", "app.subtitle": "Red Team Operation Log", "app.mark": "+ Mark", + "app.search": "Search (⌘/)", "app.copyVersionHint": "Double-click to select for copying", "project.new": "New Project", "project.create": "Create", diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 5570eb58..61d7ed1d 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -2,6 +2,7 @@ "app.title": "REDLOG", "app.subtitle": "紅隊行動日誌", "app.mark": "+ 標記", + "app.search": "搜尋 (⌘/)", "app.copyVersionHint": "點兩下選取版號複製", "project.new": "新專案", "project.create": "建立", From 2861d1dd740cab933487a5725c9d90ac3767a7bb Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 00:48:29 +0800 Subject: [PATCH 19/47] =?UTF-8?q?docs:=20Settings=20IA=20reorg=20proposal?= =?UTF-8?q?=20(F3=20structural)=20=E2=80=94=20for=20ratification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design proposal to reorganise Settings so weight mirrors necessity (DESIGN-PRINCIPLES §9): maps the current 8 tabs / 34 groups to necessity tiers (the mismatch: evidence integrity buried in Data, operator identity split, language/scale greeting newcomers), then offers two structures — (A) reorder + regroup into Capture·Scope·Evidence·OPSEC·Advanced, (B) two-level with a collapsed Advanced — recommends A + collapsed-Advanced, with migration (F3 search mitigates muscle memory), an app-verified one-tab-at-a-time strategy, acceptance criteria, and four open questions for the user. Co-Authored-By: Claude Opus 4.8 --- docs/DESIGN-SETTINGS-IA.md | 140 +++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/DESIGN-SETTINGS-IA.md diff --git a/docs/DESIGN-SETTINGS-IA.md b/docs/DESIGN-SETTINGS-IA.md new file mode 100644 index 00000000..5d95cff9 --- /dev/null +++ b/docs/DESIGN-SETTINGS-IA.md @@ -0,0 +1,140 @@ +# Design Proposal — Settings Information Architecture (F3 structural) + +Written 2026-08-12. For ratification before implementation. This is the +*structural* half of F3 (`UX-BACKLOG-TICKETS.md`): reorganise Settings so its +**weight mirrors necessity** (`DESIGN-PRINCIPLES.md` §9), not so its 34 groups +sit flat across 8 equal tabs. The filter box (F3 search) and the marketplace +shelving already shipped; this is the layout. + +The complaint (`UX-AUDIT-2026-08.md` F3): 8 tabs / 34 `FieldGroup`s / 234 i18n +keys, all equal weight — a P1's daily essentials (capture sources, scope) compete +for attention with expert-only surfaces (MCP, deconfliction, plugins) they touch +once. Under-sequenced, not over-featured. + +## Current structure (mapped to necessity tiers) + +Eight tabs today (`Settings.tsx` L182–190), 34 groups. Tier per +`DESIGN-PRINCIPLES` (Core = essential to record + evidence; OPSEC = second front +door; Advanced = frozen/secondary identities): + +| Current tab | Groups | Necessity tier | +|---|---|---| +| **General** | engagement, operator, language, uiScale | mixed — engagement/operator are **core**; language/uiScale are **app-chrome (advanced)** | +| **Capture** | hooks, agent-tailer, clipboard, screenshot(×2), file-watcher, process-monitor, excluded-paths | **Core (essential)** — the #1 thing a P1 sets up | +| **Scope** | enforcement, in-scope, excluded, scope-file | **Core (essential)** | +| **Network / IP** | ip-safety, polling, vpn-adapters | **OPSEC front door** | +| **HUD** | overlay group (all HUD/overlay knobs) | **OPSEC front door** | +| **Integrations** | cdp/proxied-browser, mcp, agents | **Advanced** (agent control plane, §6/§7) | +| **Data** | export-all, scope-export, profile-sync, export-bundle, **integrity**, update, cloud-share | mixed — **integrity + bundle export are core evidence**; profile-sync/cloud-share/update are **advanced** | +| **Plugins** | local/declarative plugins, (marketplace — shelved) | **Advanced (frozen)** | +| *(within tabs)* | operators, deconfliction | operators = **core (attribution)**; deconfliction = **advanced (team, frozen)** | + +The mismatch is visible: **evidence integrity** (core) is buried in the Data +tab next to update-check and cloud-share (advanced); **operator identity** (core +attribution) is split between General and a sub-panel; **language/UI-scale** +(pure chrome) sit in the first tab a newcomer sees. + +## Two proposals + +### Option A — Reorder + regroup the existing tabs *(lighter, lower risk)* + +Keep tab-based navigation; fix the ordering and the mis-filed groups so weight +follows necessity. No tab merging. + +1. **Tab order becomes tier order** (left→right = most→least essential): + `Capture · Scope · Evidence · OPSEC · Advanced` — plus the F3 filter spanning + all. +2. **Re-file the mis-placed groups:** + - New **Evidence** tab (was scattered): engagement, operators, integrity/chain + verify, bundle export, redaction (when it lands), profile-sync. + - **OPSEC** tab = today's HUD + Network/IP merged (one front door, §4). + - **Advanced** tab = Integrations (mcp/agents/browser) + deconfliction + + plugins + cloud-share + app-chrome (language, uiScale, update-check). +3. `General` dissolves — engagement/operator → Evidence; language/uiScale/update + → Advanced. + +Net: **5 tabs** (Capture, Scope, Evidence, OPSEC, Advanced) instead of 8, but the +*mechanism* (tabs) is unchanged — this is moving `FieldGroup`s between tab +conditionals, not rewriting them. + +### Option B — Two-level: essentials up front, everything else behind "Advanced" + +A stronger progressive-disclosure cut. Primary surface shows only what a P1 +needs; a single **Advanced** disclosure holds every frozen/secondary surface. + +- **Primary (always visible):** Capture, Scope, Evidence, OPSEC — four tabs. +- **Advanced (one collapsed section / tab, closed by default):** Integrations + (mcp/agents/browser), Team (deconfliction), Plugins, Cloud share, App + (language/scale/updates). +- The F3 filter still searches across everything (including collapsed Advanced), + so nothing becomes unfindable — just un-prominent. + +Net: the newcomer sees 4 essential tabs; the long tail is one deliberate click +away. Higher impact on the "under-sequenced" complaint, slightly more work (the +Advanced disclosure + making the filter reach into it). + +## Recommendation + +**Option A**, then adopt B's "Advanced is collapsed by default" idea for the +Advanced tab only. Rationale: A is a faithful, low-risk re-file (weight follows +necessity, `General` chrome stops greeting newcomers) that we can ship and +verify tab-by-tab; collapsing just the Advanced tab's contents by default gets +most of B's progressive-disclosure benefit without a new nav paradigm. Full B can +follow if A proves the tiers are right. + +## Migration & muscle memory + +- **F3 search already mitigates** the "where did X move?" cost — typing the + setting name finds it regardless of tab. The marketplace change already reset + Plugins-tab muscle memory, so this is a good moment to move things. +- **⌘-number / deep-links:** Settings is a single view (⌘9); the tabs aren't + individually shortcutted, so no shortcut churn. +- **No config/key changes** — this is pure presentation. Every `FieldGroup` + keeps its config path, state, and i18n key; only its parent tab changes. i18n: + add the new tab labels (Evidence, OPSEC, Advanced), keep the rest. + +## Risk & incremental strategy + +Settings.tsx is 2,681 lines; moving groups between tab conditionals is mechanical +but wide. Do it as **one tab at a time, verified in the app** (the loop is set up): + +1. Introduce the new tab list + empty new tabs; keep old tabs rendering. +2. Move groups into the new tabs one tier at a time; after each, screenshot that + tab in the running app + `renderer-smoke` + `npm run typecheck` (gate is + green — must stay 0). +3. Delete the emptied old tabs last. +4. Collapse the Advanced tab's groups by default. + +Each step is revertible; the app never ships a half-moved tab because the old one +stays until its groups are gone. + +## Acceptance criteria + +- **A1** Tabs read Capture · Scope · Evidence · OPSEC · Advanced, left→right. +- **A2** A newcomer's first tab (Capture) is an essential, not app chrome; + language/UI-scale/update-check live only under Advanced. +- **A3** Evidence integrity (chain verify) + bundle export sit together under + Evidence, not scattered in Data. +- **A4** Every setting reachable before the move is still reachable (F3 filter + finds it; Advanced is collapsed but searchable). +- **A5** No config key or behaviour changes — a saved profile round-trips + identically. `npm run typecheck` stays 0; `renderer-smoke` green; each tab + screenshotted in the app. +- **A6** i18n parity for the new tab labels. + +## Open questions (for ratification) + +- **Q1 — A or B?** Recommend A + collapsed-Advanced. Full two-level (B) is more + disruptive; worth it? +- **Q2 — "Evidence" vs "Engagement" as the tab name** for engagement/operators/ + integrity/export? "Evidence" states the purpose; "Engagement" is the current + mental model. Recommend **Evidence**. +- **Q3 — merge HUD + Network into one "OPSEC" tab** (recommended, §4 one front + door), or keep them separate? Merging is truer to the model but changes two + familiar tabs into one. +- **Q4 — fold PL1** (the new "plugin lifecycle: Install≠Enable" ticket) into this + reorg's Advanced/Plugins tab, or keep it a separate follow-up? Recommend + separate — PL1 is a behaviour change, this is layout only. + +Cross-references: `DESIGN-PRINCIPLES.md` §4/§9 · `UX-AUDIT-2026-08.md` F3 · +`UX-BACKLOG-TICKETS.md` (F3, PL1) · `Settings.tsx` L182–742. From b19dd99f6aae2f1207b6e704434f9d9939892b33 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 01:00:12 +0800 Subject: [PATCH 20/47] i18n(timeline): keys for inline stdout preview (show full/less) Add timeline.replay.showFull / showLess for the expandable command output preview, and reword timeline.detail.ioOnDisk now that the output shows inline on select instead of behind a manual expand. Co-Authored-By: Claude Fable 5 --- src/renderer/src/i18n/en.json | 4 +++- src/renderer/src/i18n/zh-TW.json | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 21e63ee1..d40300c0 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -536,6 +536,8 @@ "timeline.replay.speed": "Speed", "timeline.replay.seek": "Seek", "timeline.replay.truncated": "(cast truncated — file exceeded read cap)", + "timeline.replay.showFull": "Show full output ({{size}})", + "timeline.replay.showLess": "Show less", "timeline.showAll": "Show all", "timeline.showAllLanes": "Show all lanes", "timeline.laneChipHint": "Click to toggle {{lane}}; Alt/Option-click = solo this lane", @@ -802,7 +804,7 @@ "settings.scopeExport": "Scope-Filtered Export", "settings.exportScopeJson": "Export Scope-Only JSON", "settings.scopeExportHint": "Exports only events matching configured scope targets. Use this when uploading logs to a remote server — out-of-scope data is excluded.", - "timeline.detail.ioOnDisk": "output recorded — {{size}} of session capture, expand below to read it", + "timeline.detail.ioOnDisk": "output recorded — {{size}} of session capture, shown below", "timeline.detail.ioNone": "nothing recorded for this command", "timeline.detail.ioUnbracketed": "output not bracketed — RedLog could not match this command to a span of the session recording. Try the full session replay below.", "timeline.detail.ioNotCaptured": "output not captured — external shells record the command only. Prefix with `redlog-run` to capture stdout/stderr, or use the built-in terminal.", diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 61d7ed1d..0650b166 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -536,6 +536,8 @@ "timeline.replay.speed": "速度", "timeline.replay.seek": "跳到", "timeline.replay.truncated": "(cast 已截斷 — 檔案超過讀取上限)", + "timeline.replay.showFull": "顯示完整輸出({{size}})", + "timeline.replay.showLess": "收合", "timeline.showAll": "全部顯示", "timeline.showAllLanes": "顯示所有欄位", "timeline.laneChipHint": "點擊切換{{lane}}顯示;按住 Alt/Option 點擊 = 只看該欄", @@ -802,7 +804,7 @@ "settings.scopeExport": "範圍過濾匯出", "settings.exportScopeJson": "匯出範圍內 JSON", "settings.scopeExportHint": "僅匯出符合已設定範圍目標的事件。適用於上傳日誌至遠端伺服器時——排除範圍外資料。", - "timeline.detail.ioOnDisk": "已記錄輸出 — 工作階段錄影 {{size}},展開下方即可閱讀", + "timeline.detail.ioOnDisk": "已記錄輸出 — 工作階段錄影 {{size}},顯示於下方", "timeline.detail.ioNone": "這條指令沒有留下任何錄影段落", "timeline.detail.ioUnbracketed": "輸出無法定位 — RedLog 無法將這條指令對應到工作階段錄影的某一段。請改用下方的完整工作階段重播。", "timeline.detail.ioNotCaptured": "未捕獲輸出 — 外部 shell 只記錄指令本身。在指令前加上 `redlog-run` 可捕獲 stdout/stderr,或改用內建終端機。", From 2dc05ac8804e0388ad45359bd8f2ea380b00bca8 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 01:09:08 +0800 Subject: [PATCH 21/47] =?UTF-8?q?feat(settings):=20reorg=20by=20necessity?= =?UTF-8?q?=20tier=20=E2=80=94=20Option=20B=20(Capture=C2=B7Scope=C2=B7Evi?= =?UTF-8?q?dence=C2=B7OPSEC=C2=B7Advanced)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ratified F3 structural reorg (DESIGN-SETTINGS-IA.md §9): weight now mirrors necessity instead of 8 flat tabs. - 5 tabs: Capture (default — hooks + capture sources), Scope, Evidence (engagement, operators, chain integrity, bundle/export, profile-sync — the core evidence, was scattered across General/Data), OPSEC (HUD + Network/IP merged, §4 one front door), Advanced. - Advanced holds the frozen/secondary surfaces in five sections collapsed by default: Integrations (MCP/proxied-browser/CDP), Team (deconfliction), Plugins, Cloud share, App (language/UI-scale/updates). A new self-contained Collapsible. - General dissolved; app chrome (language/scale/updates) no longer greets newcomers. Panels moved verbatim (props/state intact); F3 search index updated so filter hits still jump to the right (now correct) tab. AgentsPanel kept in Capture (passive capture source). - i18n: +8 keys (settings.evidence/opsec/advanced + 5 section labels), en/zh 888. Verified: typecheck 0, build green, renderer-smoke 13, and app-in-the-loop screenshots of the 5-tab bar + the Advanced collapsibles (Integrations expanded → MCP/browser/CDP). Doc updated with the ratified structure. Co-Authored-By: Claude Opus 4.8 --- docs/DESIGN-SETTINGS-IA.md | 37 +- src/renderer/src/components/Settings.tsx | 804 ++++++++++++----------- src/renderer/src/i18n/en.json | 8 + src/renderer/src/i18n/zh-TW.json | 8 + 4 files changed, 448 insertions(+), 409 deletions(-) diff --git a/docs/DESIGN-SETTINGS-IA.md b/docs/DESIGN-SETTINGS-IA.md index 5d95cff9..43abd5d4 100644 --- a/docs/DESIGN-SETTINGS-IA.md +++ b/docs/DESIGN-SETTINGS-IA.md @@ -122,19 +122,30 @@ stays until its groups are gone. screenshotted in the app. - **A6** i18n parity for the new tab labels. -## Open questions (for ratification) - -- **Q1 — A or B?** Recommend A + collapsed-Advanced. Full two-level (B) is more - disruptive; worth it? -- **Q2 — "Evidence" vs "Engagement" as the tab name** for engagement/operators/ - integrity/export? "Evidence" states the purpose; "Engagement" is the current - mental model. Recommend **Evidence**. -- **Q3 — merge HUD + Network into one "OPSEC" tab** (recommended, §4 one front - door), or keep them separate? Merging is truer to the model but changes two - familiar tabs into one. -- **Q4 — fold PL1** (the new "plugin lifecycle: Install≠Enable" ticket) into this - reorg's Advanced/Plugins tab, or keep it a separate follow-up? Recommend - separate — PL1 is a behaviour change, this is layout only. +## RATIFIED 2026-08-12 + +- **Q1 → Option B (two-level).** Four essential tabs up front + a single + **Advanced** tab whose sections are **collapsed by default**. (More ambitious + than the A recommendation — the user chose the stronger progressive-disclosure + cut.) +- **Q2 → "Evidence"** for engagement / operators / integrity / export. +- **Q3 → merge** HUD + Network into one **OPSEC** tab (§4, one front door). +- **Q4 → PL1 stays a separate follow-up** (behaviour change, not layout). + +### Ratified target structure + +Five tabs, left→right: **Capture · Scope · Evidence · OPSEC · Advanced**. + +| Tab | Contents (FieldGroups / panels) | +|---|---| +| **Capture** | hooks (HooksPanel), agent-tailer, clipboard, screenshot (quality + group), file-watcher, process-monitor, hook-excluded-paths | +| **Scope** | enforcement, in-scope, excluded, scope-file | +| **Evidence** | engagement, operators, integrity (chain verify), export-all, export-bundle, scope-export, profile-sync | +| **OPSEC** | HUD/overlay group, ip-safety, polling, vpn-adapters | +| **Advanced** *(sections collapsed by default)* | Integrations (cdp/proxied-browser, mcp, agents) · Team (deconfliction) · Plugins (local/declarative) · Cloud share · App (language, UI scale, update-check) | + +`General` dissolves. New i18n tab labels: `settings.evidence`, `settings.opsec`, +`settings.advanced`. No config-key or behaviour changes. Cross-references: `DESIGN-PRINCIPLES.md` §4/§9 · `UX-AUDIT-2026-08.md` F3 · `UX-BACKLOG-TICKETS.md` (F3, PL1) · `Settings.tsx` L182–742. diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index abf6ccb0..d7bec761 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -69,13 +69,15 @@ const LOCALE_LABELS: Record = { 'zh-TW': '繁體中文' } -type TabId = 'general' | 'hud' | 'capture' | 'network' | 'scope' | 'integrations' | 'data' | 'plugins' +// v0.11.8 (DESIGN-SETTINGS-IA Option B): five tabs, ordered by the operator's +// question. Advanced is a two-level tab whose sections are collapsed by default. +type TabId = 'capture' | 'scope' | 'evidence' | 'opsec' | 'advanced' // F3 (UX-BACKLOG / DESIGN-PRINCIPLES §9): a static index of every FieldGroup on // the page so the header filter can find a setting by its group title OR any of -// its field labels without the operator having to remember which of the 8 tabs -// it lives in. Titles and labels are i18n keys resolved at render time; the pure -// match rule lives in lib/settingsSearch.ts. Order mirrors on-screen order so a +// its field labels without the operator having to remember which tab it lives +// in. Titles and labels are i18n keys resolved at render time; the pure match +// rule lives in lib/settingsSearch.ts. Order mirrors on-screen order so a // filtered result list reads top-to-bottom like the real page. This is // presentation metadata only — it points at controls, it does not own them. interface SettingsGroupSource { @@ -86,53 +88,50 @@ interface SettingsGroupSource { } const SETTINGS_GROUPS: SettingsGroupSource[] = [ - // general - { tab: 'general', groupId: 'engagement', titleKey: 'settings.engagement', labelKeys: ['settings.id', 'settings.name'] }, - { tab: 'general', groupId: 'operator', titleKey: 'settings.operatorGroup', labelKeys: ['settings.id', 'settings.name'] }, - { tab: 'general', groupId: 'language', titleKey: 'settings.language', labelKeys: [] }, - { tab: 'general', groupId: 'uiScale', titleKey: 'settings.uiScale', labelKeys: [] }, // capture { tab: 'capture', groupId: 'hooks', titleKey: 'settings.hooksDetected', labelKeys: [] }, { tab: 'capture', groupId: 'agents', titleKey: 'settings.agents', labelKeys: [] }, - { tab: 'capture', groupId: 'screenshotQuality', titleKey: 'settings.screenshotQuality', labelKeys: ['settings.jpegQuality'] }, { tab: 'capture', groupId: 'clipboard', titleKey: 'settings.clipboardGroup', labelKeys: ['settings.clipboardEnable', 'settings.clipboardStorePreview'] }, + { tab: 'capture', groupId: 'screenshotQuality', titleKey: 'settings.screenshotQuality', labelKeys: ['settings.jpegQuality'] }, + { tab: 'capture', groupId: 'screenshotSchedule', titleKey: 'settings.screenshotGroup', labelKeys: [] }, { tab: 'capture', groupId: 'fileWatcher', titleKey: 'settings.fileWatcherGroup', labelKeys: ['settings.fileWatcherPaths', 'settings.fileWatcherIgnore'] }, { tab: 'capture', groupId: 'processMonitor', titleKey: 'settings.processMonitorGroup', labelKeys: ['settings.processMonitorIgnore'] }, + { tab: 'capture', groupId: 'hookExcludedPaths', titleKey: 'settings.hookExcludedPaths.title', labelKeys: [] }, // scope { tab: 'scope', groupId: 'scopeEnforcement', titleKey: 'settings.scopeEnforcement', labelKeys: ['settings.warnOnViolation'] }, { tab: 'scope', groupId: 'inScopeTargets', titleKey: 'settings.inScopeTargets', labelKeys: ['settings.targetsLabel'] }, { tab: 'scope', groupId: 'excludedTargets', titleKey: 'settings.excludedTargets', labelKeys: ['settings.excludeLabel'] }, { tab: 'scope', groupId: 'scopeFile', titleKey: 'settings.scopeFile', labelKeys: ['settings.scopeFileLabel'] }, - // network - { tab: 'network', groupId: 'ipSafety', titleKey: 'settings.ipSafety', labelKeys: ['settings.whitelist', 'settings.blacklist'] }, - { tab: 'network', groupId: 'polling', titleKey: 'settings.polling', labelKeys: ['settings.ipMode', 'settings.checkInterval', 'settings.confirmations', 'settings.ipProviders', 'settings.showWifiName'] }, - { tab: 'network', groupId: 'vpnAdapters', titleKey: 'settings.vpnAdapters', labelKeys: [] }, - // hud - { tab: 'hud', groupId: 'overlay', titleKey: 'settings.overlayGroup', labelKeys: ['settings.overlayShowInDock'] }, - // integrations - { tab: 'integrations', groupId: 'mcp', titleKey: 'settings.mcp', labelKeys: [] }, - { tab: 'integrations', groupId: 'hookExcludedPaths', titleKey: 'settings.hookExcludedPaths.title', labelKeys: [] }, - { tab: 'integrations', groupId: 'operators', titleKey: 'settings.operators', labelKeys: [] }, - { tab: 'integrations', groupId: 'deconfliction', titleKey: 'settings.deconfliction', labelKeys: [] }, - { tab: 'integrations', groupId: 'browser', titleKey: 'settings.browser', labelKeys: [] }, - { tab: 'integrations', groupId: 'cdp', titleKey: 'settings.cdp', labelKeys: ['settings.testConnection'] }, - // data - { tab: 'data', groupId: 'screenshotSchedule', titleKey: 'settings.screenshotGroup', labelKeys: [] }, - { tab: 'data', groupId: 'update', titleKey: 'settings.updateGroup', labelKeys: ['settings.checkUpdate'] }, - { tab: 'data', groupId: 'exportAll', titleKey: 'settings.exportAll', labelKeys: ['settings.exportJson'] }, - { tab: 'data', groupId: 'exportBundle', titleKey: 'settings.exportBundleTitle', labelKeys: [] }, - { tab: 'data', groupId: 'scopeExport', titleKey: 'settings.scopeExport', labelKeys: ['settings.exportScopeJson'] }, - { tab: 'data', groupId: 'cloudShare', titleKey: 'cloudShare.title', labelKeys: [] }, - { tab: 'data', groupId: 'integrity', titleKey: 'settings.integrity', labelKeys: [] }, - { tab: 'data', groupId: 'profileSync', titleKey: 'settings.profileSync', labelKeys: ['settings.exportProfile', 'settings.importProfile'] }, - // plugins - { tab: 'plugins', groupId: 'plugins', titleKey: 'settings.plugins', labelKeys: [] }, - { tab: 'plugins', groupId: 'marketplace', titleKey: 'settings.marketplace', labelKeys: [] } + // evidence + { tab: 'evidence', groupId: 'engagement', titleKey: 'settings.engagement', labelKeys: ['settings.id', 'settings.name'] }, + { tab: 'evidence', groupId: 'operator', titleKey: 'settings.operatorGroup', labelKeys: ['settings.id', 'settings.name'] }, + { tab: 'evidence', groupId: 'operators', titleKey: 'settings.operators', labelKeys: [] }, + { tab: 'evidence', groupId: 'integrity', titleKey: 'settings.integrity', labelKeys: [] }, + { tab: 'evidence', groupId: 'exportAll', titleKey: 'settings.exportAll', labelKeys: ['settings.exportJson'] }, + { tab: 'evidence', groupId: 'exportBundle', titleKey: 'settings.exportBundleTitle', labelKeys: [] }, + { tab: 'evidence', groupId: 'scopeExport', titleKey: 'settings.scopeExport', labelKeys: ['settings.exportScopeJson'] }, + { tab: 'evidence', groupId: 'profileSync', titleKey: 'settings.profileSync', labelKeys: ['settings.exportProfile', 'settings.importProfile'] }, + // opsec + { tab: 'opsec', groupId: 'overlay', titleKey: 'settings.overlayGroup', labelKeys: ['settings.overlayShowInDock'] }, + { tab: 'opsec', groupId: 'ipSafety', titleKey: 'settings.ipSafety', labelKeys: ['settings.whitelist', 'settings.blacklist'] }, + { tab: 'opsec', groupId: 'polling', titleKey: 'settings.polling', labelKeys: ['settings.ipMode', 'settings.checkInterval', 'settings.confirmations', 'settings.ipProviders', 'settings.showWifiName'] }, + { tab: 'opsec', groupId: 'vpnAdapters', titleKey: 'settings.vpnAdapters', labelKeys: [] }, + // advanced + { tab: 'advanced', groupId: 'mcp', titleKey: 'settings.mcp', labelKeys: [] }, + { tab: 'advanced', groupId: 'browser', titleKey: 'settings.browser', labelKeys: [] }, + { tab: 'advanced', groupId: 'cdp', titleKey: 'settings.cdp', labelKeys: ['settings.testConnection'] }, + { tab: 'advanced', groupId: 'deconfliction', titleKey: 'settings.deconfliction', labelKeys: [] }, + { tab: 'advanced', groupId: 'plugins', titleKey: 'settings.plugins', labelKeys: [] }, + { tab: 'advanced', groupId: 'marketplace', titleKey: 'settings.marketplace', labelKeys: [] }, + { tab: 'advanced', groupId: 'cloudShare', titleKey: 'cloudShare.title', labelKeys: [] }, + { tab: 'advanced', groupId: 'language', titleKey: 'settings.language', labelKeys: [] }, + { tab: 'advanced', groupId: 'uiScale', titleKey: 'settings.uiScale', labelKeys: [] }, + { tab: 'advanced', groupId: 'update', titleKey: 'settings.updateGroup', labelKeys: ['settings.checkUpdate'] } ] export default function Settings(): JSX.Element { const [config, setConfig] = useState(null) - const [tab, setTab] = useState('general') + const [tab, setTab] = useState('capture') const [filter, setFilter] = useState('') const [saved, setSaved] = useState(false) const [exportResult, setExportResult] = useState(null) @@ -166,28 +165,20 @@ export default function Settings(): JSX.Element { if (!config) return
{t('settings.loading')}
- // v0.9.10: ten tabs down to eight, ordered by the question the operator is - // actually asking rather than by when each feature was added. - // identity -> what gets recorded -> what's in bounds -> exposure -> - // display -> external tools -> retention -> extensions - // - // Two merges: - // · AI agent transcripts moved into Capture. It is a passive capture - // source exactly like the clipboard, the file watcher and the process - // monitor, all of which already live there; on its own it was a tab - // holding three checkboxes, which made it look like a subsystem rather - // than one source among several. - // · Marketplace folded into Plugins as a sub-tab. "What is installed" and - // "where to get more" are one task split across two top-level tabs. + // v0.11.8 (DESIGN-SETTINGS-IA Option B): eight tabs down to five, each named + // for the operator's question rather than a subsystem — + // what gets recorded (Capture) -> what's in bounds (Scope) -> + // what proves the work (Evidence) -> what leaks (OPSEC) -> the rest. + // General/HUD/Network/Integrations/Data dissolve: HUD+Network merge into one + // OPSEC front door; identity+integrity+export gather under Evidence; and + // everything rarely touched folds into Advanced, whose sections are collapsed + // by default (progressive disclosure). const tabs = [ - { id: 'general' as const, label: t('settings.general') }, { id: 'capture' as const, label: t('settings.capture') }, { id: 'scope' as const, label: t('settings.scope') }, - { id: 'network' as const, label: t('settings.networkIp') }, - { id: 'hud' as const, label: t('settings.hud') }, - { id: 'integrations' as const, label: t('settings.integrations') }, - { id: 'data' as const, label: t('settings.data') }, - { id: 'plugins' as const, label: t('settings.plugins') } + { id: 'evidence' as const, label: t('settings.evidence') }, + { id: 'opsec' as const, label: t('settings.opsec') }, + { id: 'advanced' as const, label: t('settings.advanced') } ] // F3: resolve the static group index against the current locale and run the @@ -252,269 +243,10 @@ export default function Settings(): JSX.Element { ) ) : ( <> - {tab === 'general' && ( - <> - - setConfig({ ...config, engagement: { ...config.engagement, id: v } })} /> - setConfig({ ...config, engagement: { ...config.engagement, name: v } })} /> - - - setConfig({ ...config, operator: { ...config.operator, id: v } })} /> - setConfig({ ...config, operator: { ...config.operator, name: v } })} /> - - -
- {(Object.keys(LOCALE_LABELS) as Locale[]).map((l) => ( - - ))} -
-
- - - - - )} - - {tab === 'integrations' && ( - <> - - - - - - -

{t('settings.cdpHint')}

- -
- - )} - - {tab === 'network' && ( - <> - - setConfig({ ...config, network: { ...config.network, whitelist: items } })} - placeholder={t('settings.safeIpPlaceholder')} - /> - setConfig({ ...config, network: { ...config.network, blacklist: items } })} - placeholder={t('settings.exposedIpPlaceholder')} - /> - - -
- -
- {(['auto', 'dns', 'http'] as const).map((m) => ( - - ))} -
-

{t('settings.ipModeHint')}

-
- {isMacOS && ( -
- -

{t('settings.showWifiNameHint')}

-
- )} - setConfig({ ...config, network: { ...config.network, checkInterval: parseInt(v) || 60 } })} - type="number" - /> -

{t('settings.pollingOpsecHint')}

- setConfig({ ...config, network: { ...config.network, confirmations: Math.max(1, parseInt(v) || 3) } })} - type="number" - /> -

{t('settings.confirmationsHint')}

- setConfig({ ...config, network: { ...config.network, providers: items } })} - placeholder="https://ip.internal.example/json" - /> -

{t('settings.ipProvidersHint')}

-
- - - )} - - {tab === 'hud' && ( - <> - - -

{t('settings.overlayShowMarkHint')}

- -

{t('settings.overlayFlashExposedHint')}

- -
- -
- {[ - { v: 0.85, k: 'small' }, - { v: 1.0, k: 'normal' }, - { v: 1.25, k: 'large' }, - { v: 1.5, k: 'xlarge' } - ].map(({ v, k }) => { - const cur = config.overlay?.scale ?? 1.0 - const active = Math.abs(cur - v) < 0.01 - return ( - - ) - })} -
-

{t('settings.overlayScaleHint')}

-
- - -

{t('settings.overlayEmphasizeIpHint')}

- - -

{t('settings.overlayPassThroughHint')}

- {config.overlay?.passThrough === true && ( -
- {t('settings.overlayPassThroughOpacity')} - setConfig({ ...config, overlay: { ...config.overlay, passThroughOpacity: parseFloat(e.target.value) } })} - className="accent-red-600 w-40" - /> - {Math.round((config.overlay?.passThroughOpacity ?? 0.4) * 100)}% -
- )} - - {isMacOS && ( - <> - -

{t('settings.overlayShowInDockHint')}

- - )} -
- - )} - {tab === 'capture' && ( <> - - setConfig({ ...config, screenshot: { ...config.screenshot, quality: Math.min(100, Math.max(1, parseInt(v) || 85)) } })} - type="number" - /> -

- {t('settings.qualityHint')} -

-
- - -

{t('settings.fileWatcherEnableHint')}

- {config.fileWatcher?.enabled && ( - <> - setConfig({ ...config, fileWatcher: { ...config.fileWatcher, enabled: true, watchPaths: items } })} - placeholder={t('settings.fileWatcherPathsPlaceholder')} - /> - setConfig({ ...config, fileWatcher: { ...config.fileWatcher, enabled: true, ignorePatterns: items } })} + + setConfig({ ...config, screenshot: { ...config.screenshot, quality: Math.min(100, Math.max(1, parseInt(v) || 85)) } })} + type="number" + /> +

+ {t('settings.qualityHint')} +

+
+ +
+ {[ + { v: 0, k: 'settings.screenshot.interval.off' }, + { v: 30, k: 'settings.screenshot.interval.30s' }, + { v: 60, k: 'settings.screenshot.interval.60s' }, + { v: 300, k: 'settings.screenshot.interval.5m' } + ].map((opt) => ( + + ))} +
+

{t('settings.screenshot.intervalHint')}

+
+ + +

{t('settings.fileWatcherEnableHint')}

+ {config.fileWatcher?.enabled && ( + <> + setConfig({ ...config, fileWatcher: { ...config.fileWatcher, enabled: true, watchPaths: items } })} + placeholder={t('settings.fileWatcherPathsPlaceholder')} + /> + setConfig({ ...config, fileWatcher: { ...config.fileWatcher, enabled: true, ignorePatterns: items } })} placeholder={t('settings.fileWatcherIgnorePlaceholder')} />

{t('settings.fileWatcherIgnoreHint')}

@@ -593,44 +357,65 @@ export default function Settings(): JSX.Element { )}
+ )} - {tab === 'data' && ( + {tab === 'scope' && ( <> - -
- {[ - { v: 0, k: 'settings.screenshot.interval.off' }, - { v: 30, k: 'settings.screenshot.interval.30s' }, - { v: 60, k: 'settings.screenshot.interval.60s' }, - { v: 300, k: 'settings.screenshot.interval.5m' } - ].map((opt) => ( - - ))} -
-

{t('settings.screenshot.intervalHint')}

+ + +

{t('settings.warnOnViolationHint')}

- -
- - v{__APP_VERSION__} -
-

{t('settings.checkUpdateHint')}

+ + setConfig({ ...config, scope: { ...config.scope, targets: items } })} + placeholder={t('settings.targetsPlaceholder')} + /> + + + setConfig({ ...config, scope: { ...config.scope, excludeTargets: items } })} + placeholder={t('settings.excludePlaceholder')} + /> + + + setConfig({ ...config, scope: { ...config.scope, scopeFile: v } })} + /> +

+ {t('settings.scopeFileHint')} +

+
+ + )} + + {tab === 'evidence' && ( + <> + + setConfig({ ...config, engagement: { ...config.engagement, id: v } })} /> + setConfig({ ...config, engagement: { ...config.engagement, name: v } })} /> + + + setConfig({ ...config, operator: { ...config.operator, id: v } })} /> + setConfig({ ...config, operator: { ...config.operator, name: v } })} /> + + + ) + })} +
+

{t('settings.overlayScaleHint')}

+
+ + +

{t('settings.overlayEmphasizeIpHint')}

+ + +

{t('settings.overlayPassThroughHint')}

+ {config.overlay?.passThrough === true && ( +
+ {t('settings.overlayPassThroughOpacity')} + setConfig({ ...config, overlay: { ...config.overlay, passThroughOpacity: parseFloat(e.target.value) } })} + className="accent-red-600 w-40" + /> + {Math.round((config.overlay?.passThroughOpacity ?? 0.4) * 100)}% +
+ )} + + {isMacOS && ( + <> + +

{t('settings.overlayShowInDockHint')}

+ + )} - + setConfig({ ...config, scope: { ...config.scope, targets: items } })} - placeholder={t('settings.targetsPlaceholder')} + label={t('settings.whitelist')} + items={config.network.whitelist} + onChange={(items) => setConfig({ ...config, network: { ...config.network, whitelist: items } })} + placeholder={t('settings.safeIpPlaceholder')} /> - - setConfig({ ...config, scope: { ...config.scope, excludeTargets: items } })} - placeholder={t('settings.excludePlaceholder')} + label={t('settings.blacklist')} + items={config.network.blacklist} + onChange={(items) => setConfig({ ...config, network: { ...config.network, blacklist: items } })} + placeholder={t('settings.exposedIpPlaceholder')} /> - + +
+ +
+ {(['auto', 'dns', 'http'] as const).map((m) => ( + + ))} +
+

{t('settings.ipModeHint')}

+
+ {isMacOS && ( +
+ +

{t('settings.showWifiNameHint')}

+
+ )} setConfig({ ...config, scope: { ...config.scope, scopeFile: v } })} + label={t('settings.checkInterval')} + value={String(config.network.checkInterval)} + onChange={(v) => setConfig({ ...config, network: { ...config.network, checkInterval: parseInt(v) || 60 } })} + type="number" /> -

- {t('settings.scopeFileHint')} -

+

{t('settings.pollingOpsecHint')}

+ setConfig({ ...config, network: { ...config.network, confirmations: Math.max(1, parseInt(v) || 3) } })} + type="number" + /> +

{t('settings.confirmationsHint')}

+ setConfig({ ...config, network: { ...config.network, providers: items } })} + placeholder="https://ip.internal.example/json" + /> +

{t('settings.ipProvidersHint')}

+ + + )} + + {tab === 'advanced' && ( + <> + + + + +

{t('settings.cdpHint')}

+ +
+
+ + + + + + + + + + + +
+ {(Object.keys(LOCALE_LABELS) as Locale[]).map((l) => ( + + ))} +
+
+ + + + +
+ + v{__APP_VERSION__} +
+

{t('settings.checkUpdateHint')}

+
+
)} - {tab === 'plugins' && } )}
@@ -776,6 +767,27 @@ function FieldGroup({ title, children }: { title: string; children: React.ReactN ) } +// v0.11.8 (DESIGN-SETTINGS-IA Option B): a self-contained collapsible section +// for the Advanced tab. Each section owns its own open state and starts +// collapsed — the two-level progressive-disclosure cut that keeps rarely +// touched settings one click away without cluttering the four front-door tabs. +function Collapsible({ title, children }: { title: string; children: React.ReactNode }): JSX.Element { + const [open, setOpen] = useState(false) + return ( +
+ + {open &&
{children}
} +
+ ) +} + function Field({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (v: string) => void; type?: string }): JSX.Element { diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index d40300c0..65937b6e 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -312,6 +312,14 @@ "settings.uiScale.xlarge": "Extra large", "settings.hud": "HUD", "settings.capture": "Capture", + "settings.evidence": "Evidence", + "settings.opsec": "OPSEC", + "settings.advanced": "Advanced", + "settings.advanced.integrations": "Integrations", + "settings.advanced.team": "Team", + "settings.advanced.plugins": "Plugins", + "settings.advanced.cloud": "Cloud share", + "settings.advanced.app": "App", "settings.integrations": "Integrations", "settings.team": "Team & Integrations", "settings.networkIp": "Network / IP", diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 0650b166..1438ddb6 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -312,6 +312,14 @@ "settings.uiScale.xlarge": "特大", "settings.hud": "HUD", "settings.capture": "擷取", + "settings.evidence": "證據", + "settings.opsec": "OPSEC", + "settings.advanced": "進階", + "settings.advanced.integrations": "整合", + "settings.advanced.team": "團隊", + "settings.advanced.plugins": "外掛", + "settings.advanced.cloud": "雲端分享", + "settings.advanced.app": "應用程式", "settings.integrations": "整合", "settings.team": "團隊與整合", "settings.networkIp": "網路 / IP", From 5ae41e0f31a9817b192b5f910bd1a30a864f4270 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 01:13:08 +0800 Subject: [PATCH 22/47] docs: decomposition-method design-framework suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DECOMPOSITION-METHOD.md plus 10 worked instances that close 9 RedLog subsystems into small, provably-complete role/tier sets — each with a per-item development template and a named-API-gaps list, so new work is "pick a row, fill the template" instead of a bespoke design: - Plugins: PLUGIN-ROLES (7 roles) + SPEC-AI-ERA-PLUGINS (4 gaps) - Capture sources: CAPTURE-SOURCE-TAXONOMY (built-in vs plugin, 4 gates) - Sanitize/retention/rotation: SPEC-SCOPE-AWARE-LIFECYCLE - Detectors: DETECTOR-ROLES (Extractor/Classifier/Monitor/Correlator) - Control-plane faces: CONTROL-PLANE-FACES (one-role + op catalog, §7) - Delivery: DELIVERY-TARGETS (Snapshot vs Stream) - Timeline: TIMELINE-ELEMENTS (7 channels x 4 modes, structural lens) - Event-type vocabulary: EVENT-TYPE-VOCABULARY (origin x authority) - Off-chain content stores: OFF-CHAIN-CONTENT-STORES (Blob vs Stream) Index them in README under "Design frameworks"; add UX ticket PL1 (plugin lifecycle UI: unify four gates, Install != Enable). Docs only; verified against code with cross-session review. Excludes an in-progress Timeline.tsx change owned by another session. Co-Authored-By: Claude Opus 4.8 --- docs/CAPTURE-SOURCE-TAXONOMY.md | 193 +++++++++++++++++++++ docs/CONTROL-PLANE-FACES.md | 95 +++++++++++ docs/DECOMPOSITION-METHOD.md | 87 ++++++++++ docs/DELIVERY-TARGETS.md | 96 +++++++++++ docs/DETECTOR-ROLES.md | 142 +++++++++++++++ docs/EVENT-TYPE-VOCABULARY.md | 88 ++++++++++ docs/OFF-CHAIN-CONTENT-STORES.md | 105 ++++++++++++ docs/PLUGIN-ROLES.md | 266 +++++++++++++++++++++++++++++ docs/README.md | 17 ++ docs/SPEC-AI-ERA-PLUGINS.md | 139 +++++++++++++++ docs/SPEC-SCOPE-AWARE-LIFECYCLE.md | 192 +++++++++++++++++++++ docs/TIMELINE-ELEMENTS.md | 120 +++++++++++++ docs/UX-BACKLOG-TICKETS.md | 63 +++++++ 13 files changed, 1603 insertions(+) create mode 100644 docs/CAPTURE-SOURCE-TAXONOMY.md create mode 100644 docs/CONTROL-PLANE-FACES.md create mode 100644 docs/DECOMPOSITION-METHOD.md create mode 100644 docs/DELIVERY-TARGETS.md create mode 100644 docs/DETECTOR-ROLES.md create mode 100644 docs/EVENT-TYPE-VOCABULARY.md create mode 100644 docs/OFF-CHAIN-CONTENT-STORES.md create mode 100644 docs/PLUGIN-ROLES.md create mode 100644 docs/SPEC-AI-ERA-PLUGINS.md create mode 100644 docs/SPEC-SCOPE-AWARE-LIFECYCLE.md create mode 100644 docs/TIMELINE-ELEMENTS.md diff --git a/docs/CAPTURE-SOURCE-TAXONOMY.md b/docs/CAPTURE-SOURCE-TAXONOMY.md new file mode 100644 index 00000000..610182b7 --- /dev/null +++ b/docs/CAPTURE-SOURCE-TAXONOMY.md @@ -0,0 +1,193 @@ +# Capture Source Taxonomy — built-in vs plugin, AI-era + +Written 2026-08-13. Companion to `SPEC-SCOPE-AWARE-LIFECYCLE.md`. Answers three +questions before the lifecycle work: (1) **what capture items must ship built-in**, +(2) **what the AI era adds to the pentest/red-team tool surface** and whether +RedLog covers it, (3) **where the built-in/plugin line sits** and why the plugin +interface is general enough for the long tail. Everything here is constrained by +one fact: **RedLog runs on the operator's own laptop**, so every capture item is +judged on CPU, disk, IO, and install/config friction, not just coverage. + +## The organizing lens (DESIGN-PRINCIPLES §2) + +Capture items split by **who initiates the bytes**, which decides default-on vs +opt-in and the exposure profile: + +- **Operator-deliberate** (default-on): commands, tool-calls, navigation, targets + — the operator's own actions. Bounded exposure (fires per-action). +- **Ambient vacuum** (opt-in): clipboard, periodic screenshots — run continuously, + carry a standing exposure window, so the operator consciously accepts the bet. + +The key design move for the AI era: capture the **substrate** (command / packet / +tool-call / screen), not the specific tool. A substrate-level item covers a whole +class of tools; only a tool with its own out-of-band channel needs a dedicated +plugin (see Part 4). + +## Part 1 — Built-in capture inventory (17 `agent_type`s) + +| # | agent_type | What it captures | Heavy artifact | Local cost | Install / config | +|---|---|---|---|---|---| +| 1 | `shell`/`terminal` | commands (hook or built-in PTY) + output | **`.cast` recording** | disk hog; PTY cheap | shell-source into `.zshrc`, or built-in terminal | +| 2 | `http` | mitmproxy request/response | **io body (sidecar)** | TLS intercept per-flow; dedup+2MB cap | mitmproxy addon + **CA cert trust** (per-OS) | +| 3 | `http_navigation` | page loads in CDP browser | io body | CDP session | launch proxied browser | +| 4 | `browser` | proxied browser capture/nav | io body | as above | as above | +| 5 | `scanner` | mitmproxy / port / vuln scan output (open type) | io body | varies by tool | via API / addon | +| 6 | `dns` | DNS queries | — | light | resolver hook / passive | +| 7 | `agent` | **AI agent tool-calls** (Claude Code `PostToolUse`, etc.) | transcript | tailer light | `claude-settings` hook (Tier A) | +| 8 | `process` | process spawn/exit (ps polling) | — | **polling CPU** | macOS/Linux ps monitor, opt-in | +| 9 | `file_transfer` | ingress/exfil, auto-detected from shell | — | free (rides shell) | none (auto) | +| 10 | `pivot` | tunnels/SOCKS, auto-detected from shell | — | free | none (auto) | +| 11 | `cleanup` | anti-forensics, auto-detected from shell | — | free | none (auto) | +| 12 | `loot` | detected secrets (derived) | — | regex per-event | none (built-in detector) | +| 13 | `marker` | operator finding notes | — | free | operator UI | +| 14 | `screenshot` | desktop capture (periodic + on-demand) | **`.jpg` files** | **capture+encode CPU, disk hog** | opt-in periodic; on-demand free | +| 15 | `clipboard` | clipboard changes (sampled) | — | **poll interval CPU** | **default OFF** (highly sensitive) | +| 16 | `system` | scope_violation / sanitized / *_pruned / pause | — | free (meta) | n/a | +| 17 | *(watchPaths)* `file_transfer` via fs watch | filesystem drops in watched dirs | — | fs-watch cost | needs `watchPaths` list | + +**Substrate coverage:** items 1–8 + 14–15 are tool-agnostic — they record the +*medium* (shell, HTTP, DNS, tool-call, screen, clipboard, process) regardless of +which specific tool produced it. Items 9–12 are auto-detections layered on the +shell substrate. + +## Part 2 — AI-era tool surface → capture mapping + +The 2026 tool stack (per web research) mapped to what it *emits* and which +built-in item captures it. "Gap → plugin" marks the long tail. + +| Phase | Common tools (2026) | Emits | Built-in covers | Gap → plugin | +|---|---|---|---|---| +| Recon | Nmap, Shodan, theHarvester, subfinder/httpx/katana, Maltego | shell cmd + output; DNS/HTTP | `shell`+`dns`+`http`, target extract | structured **nmap XML / nuclei JSON** parsing | +| Vuln scan | Nessus, OpenVAS, Nikto, nuclei | shell/scanner output | `shell`/`scanner` | structured findings ingest | +| Web/exploit | Burp Suite Pro, SQLMap, Metasploit | HTTP; interactive consoles | `http` (via proxy), `shell` for SQLMap | **Burp proxy overlap, msfconsole RPC** | +| C2 | Cobalt Strike, Sliver, Mythic | beacon check-ins, own logs | — (out-of-band channel) | **C2 log ingest** (Sliver log / CS Aggressor) | +| Post-ex | BloodHound, Mimikatz, LinPEAS/WinPEAS, Empire | shell cmd; graph data | `shell`+`loot` | **BloodHound graph ingest** | +| **AI-agent** | **PentestGPT, Nebula, hackingBuddyGPT, CAI** | tool-calls; some shell-out | `agent` (native hook) / `shell` (fallback) | tool-specific **native hooks** for richer attribution | +| **AI-MCP** | **HexStrike, PentestMCP** (MCP servers) | MCP tool-calls | `agent` tool-call capture | **🔴 `mcpTools`** for RedLog-as-tool | +| **AI-target** | **DeepTeam, LLM/Agentic red teaming** (OWASP LLM/Agentic Top 10, MITRE ATLAS) | HTTP API calls to target LLM; injection payloads | `http` + `agent` | **semantic labels** (prompt-injection / tool-call-hijack) as `eventType`/loot-pattern | + +### The AI-era shift (the load-bearing finding) + +The capture surface moved from **"commands + packets"** to also include **agent +tool-calls, MCP server interactions, and LLM API exchanges**. RedLog is +architecturally ready — it already has `agent` capture, the agent-hook framework +(Tier A native / Tier C shell fallback, `docs/plugin-development.md`), and 🔴 +`mcpTools`. What the *common built-in* set must explicitly own: + +1. **AI-agent tool-call capture** — have it (`agent` + `PostToolUse` hook). +2. **MCP tool-call visibility** — the operator's AI running MCP security tools + (HexStrike, PentestMCP) is now a primary evidence stream; its tool-calls should + land like any other action. Partly via `mcpTools`, but capturing *another* + MCP server's calls is plugin territory. +3. **LLM-target exchanges** — testing an AI *target* is captured as `http` + (API calls) + `agent` (attacker-LLM transcript); the injection-attempt + *labelling* is a `eventType`/loot-pattern contribution, not a new source. + +Everything tool-specific (Burp, msfconsole, Sliver/CS, BloodHound, per-tool AI +hooks, structured scan parsers) is correctly **long tail → plugin**. + +## Part 3 — Local-execution constraints (perf / space / install) + +Grouped by the real bottleneck on a laptop: + +- **Disk hogs (space):** `.cast` (terminal), `.jpg` (periodic screenshots), io + bodies. → governed by `SPEC-SCOPE-AWARE-LIFECYCLE.md` (compress→tier→prune, + size+age triggers). Screenshots/`.cast` are Tier-3 (size/time only), the io + bodies are Tier-1 (scope-prioritized). Dedup + 2 MB ceiling already cap bodies. +- **CPU (perf):** process ps-polling, clipboard polling, mitmproxy TLS intercept, + screenshot encode. → interval-tunable + opt-in per §2; the two continuous + vacuums (clipboard, periodic screenshot) are opt-in precisely for this. +- **Install friction (setup):** the hard ones are **mitmproxy CA cert trust** + (per-OS, per-browser), **shell hook into `.zshrc`**, **`claude-settings` hook**, + **CDP browser launch**. Everything else is passive/auto. → `installMethod` + (`shell-source` / `claude-settings` / `manual`) + `verify` command must make + each self-describing (see Part 4). + +## Part 4 — The built-in / plugin boundary + interface generality + +**Ships built-in (common, substrate-level, tool-agnostic):** items 1–15 above + +auto-detections. Rule: *if it captures a medium (shell/HTTP/DNS/tool-call/screen/ +clipboard/process) rather than parsing one tool's proprietary output, it's +built-in.* + +**Plugin (long tail, tool-specific or personal):** parsing a specific tool's +output/console/DB, or a bespoke in-house tool. Grounded in the existing +🟢/🔴 trust model (`redlog-plugin-system`): + +| Need | Contribution | Tier | Runs code? | +|---|---|---|---| +| Recognize a new tool's targets | `targetExtractors` | 🟢 | no | +| Stamp fields on its shell events | `commandTags` | 🟢 | no | +| Give its events a timeline identity | `eventTypes` | 🟢 | no | +| Teach loot detector its secret format | `lootPatterns` | 🟢 | no | +| Add allow/deny redaction | `redaction` | 🟢 | no | +| Capture an out-of-band channel (C2 log, Burp, BloodHound) | `capture` script → local API | 🟢 | no (out-of-process, unprivileged) | +| Let an AI agent operate RedLog | `mcpTools` | 🔴 | yes (isolated utilityProcess, capability-scoped `ctx`, hash-pinned consent) | + +**Why the interface is general enough for the long tail:** because built-in +capture is *substrate-level*, a new tool usually needs only a 🟢 `targetExtractor` ++ `eventType` — a few lines of **data**, no code, loaded automatically, unable to +touch the evidence chain. A full `capture` script (still 🟢, out-of-process, +POSTs to the authenticated local API) is needed only when the tool has its own +channel RedLog isn't already sitting on (a C2 beacon, Burp's proxy, BloodHound's +graph DB). Only code that runs *inside* RedLog for an AI agent is 🔴 — isolated, +capability-scoped, human-approved. So: **common = built-in substrate; rare/personal += 🟢 data or out-of-process script; agent-operable = 🔴 isolated.** The trust cost +scales with the actual risk, and the long tail lands almost entirely in trust-free +🟢. + +**Plugin install generality:** every `capture` contribution declares its own +`installMethod` (`shell-source` / `claude-settings` / `manual`), `manualSteps` +(with the `{hookFile}` placeholder RedLog resolves to an absolute path), and a +`verify` command — so the long tail is self-describing about setup even without a +one-click path, matching the local-install reality of Part 3. + +### The install/enable separation (four gates, not two) + +The lifecycle has **four separate, differently-consequential, independently +reversible** states — the design deliberately splits *enablement inside RedLog* +from *installing a hook into the operator's environment*: + +| Gate | State | Where it lives | Blast radius | Reversibility | +|---|---|---|---|---| +| 1 | **Present** | folder in `~/.redlog/plugins//` or bundled | files only | delete folder | +| 2 | **Enabled** | `state.json` `disabled[]`; Settings ▸ Enable/Disable | in-RedLog process; contributions merged | flip a flag, zero external footprint | +| 3 | **Trusted** (🔴 only) | `trust.json` content-hash + capability pin | privileged code may run | revoke consent | +| 4 | **Capture hook installed** | `.zshrc` / `~/.claude/settings.json` via `installMethod`; tracked by `capture-health` `hookInstalled` | **mutates files OUTSIDE RedLog that persist when it's closed** | separate uninstall | + +**Why gates 2 and 4 must be separate:** enabling merges data into RedLog's own +process — cheap, reversible, no footprint outside the app. Installing a capture +hook mutates *external* system state (`.zshrc`, `~/.claude/settings.json`) that +outlives RedLog's process, so it needs its own consent and its own uninstall. +Conflating them forces a bad choice: either enabling silently edits your shell +config (unrequested footprint), or disabling can't clean up because it never +tracked what it wrote. The split lets the operator vet and enable a plugin safely +in-process **first**, then take the consequential environment-wiring step +deliberately. + +**Gotcha to preserve — Disable ≠ Uninstall.** `setPluginEnabled(false)` +(`plugins/index.ts`) only flips `state.json`; it does **not** remove the hook +line from `.zshrc`. Hook uninstall must be its own UI action, or orphaned hook +lines accumulate in the operator's shell config (and keep firing on every shell, +whether or not RedLog is open). Gate 4 (hook install) *is* the "install friction" +of Part 3 (mitmproxy cert, `.zshrc`, `claude-settings`, CDP browser) — isolating +it as one deliberate, separately-reversible step is what makes the local-install +reality manageable. + +## Recommendation + +- **Ship built-in:** the 15 substrate items + auto-detections. Add nothing + tool-specific to core. +- **AI-era, promote to first-class built-in emphasis:** `agent` tool-call capture + and MCP tool-call visibility — this is where 2026 evidence increasingly lives. +- **Everything else → plugin**, and the 🟢-first interface already makes the long + tail cheap (data/script, trust-free) with 🔴 reserved for agent-operable code. +- **Local cost is handled by the lifecycle spec**, not by dropping capture: disk + hogs (`.cast`/screenshots/bodies) rotate; continuous vacuums stay opt-in. + +## Sources + +- Pentest stack 2026 — [PlexTrac](https://plextrac.com/the-most-popular-penetration-testing-tools-this-year/), [Axis Intelligence](https://axis-intelligence.com/best-penetration-testing-tools-tested-guide/), [HackerDNA](https://hackerdna.com/blog/penetration-testing-tools) +- AI red-team frameworks — [Synack](https://www.synack.com/blog/best-ai-red-teaming-tools/), [Straiker](https://www.straiker.ai/blog/top-6-ai-red-teaming-and-adversarial-testing-tools) +- AI pentest agents — [Ostorlab](https://blog.ostorlab.co/8-open-source-ai-pentest-tools-2026.html), [awesome-ai-pentesting](https://github.com/skyvanguard/awesome-ai-pentesting), [appsecsanta](https://appsecsanta.com/research/ai-pentesting-agents-2026) +- Operator logging / evidence — [Red Team Guide — Operator Log](https://redteam.guide/docs/Templates/oplog/), [Hacking Articles](https://www.hackingarticles.in/guide-to-red-team-operations/) diff --git a/docs/CONTROL-PLANE-FACES.md b/docs/CONTROL-PLANE-FACES.md new file mode 100644 index 00000000..3445f49d --- /dev/null +++ b/docs/CONTROL-PLANE-FACES.md @@ -0,0 +1,95 @@ +# Control-Plane Faces — one implementation, many thin adapters + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to RedLog's control plane +(DESIGN-PRINCIPLES §7). This subsystem decomposes **differently** from plugins and +detectors, and that difference is itself instructive: every face does the *same* +shape of work (adapt the canonical op set to a transport), so the closed set that +matters is not "N roles" but **the canonical op catalog + a single Adapter role**. + +## The method, adapted + +For plugins/detectors the mechanism axis produced *several* roles (different shapes +of work). Here every face is structurally identical — a thin adapter. So: + +- **The closed set = the canonical op catalog** (the evidence-relevant control ops). +- **The single role = Adapter** (project a subset of ops onto a transport, adding + only serialization + auth, never logic). +- **Faces are then *classified*** along transport × audience × generation — a + catalog, not a role zoo. + +This is the "one role + canonical catalog" variant of the method (see +`DECOMPOSITION-METHOD.md`). + +## The canonical layer (what everything adapts) + +§7 names "the localhost REST handlers" as canonical, but structurally the true +canonical implementation is **the core op modules** (`insertEvent`, `searchEvents`, +the chain ops, scope/loot reads). **REST is itself an adapter** over those — the +first and most privileged one, but not the source of truth. Naming the core (not +REST) as canonical is what lets MCP, IPC, CLI, and the Codex schema all be *peer* +adapters that cannot drift from each other, because none is defined in terms of +another. + +## The canonical op catalog (closed, §7 "evidence-relevant only") + +Grouped by §7's three purposes: + +| Group | Ops | Source | +|---|---|---| +| **Write evidence** | `mark`, `quickmark`, `log_event` | `api-server` L125/L136/L169 | +| **Operate chain / recording** | `chain_status`, `chain_anchor_now`, `chain_verify`, `chain_upgrade`, `recording` (pause/resume) | L199–208, L191 | +| **Read for decisions** | `status`, `whoami`, `operators_list`, `scope`, `search`, `events`, `config`, `loot_scan`, `screenshot`, `quickmarks_list` | L111–185 | + +**Boundary (§7):** the catalog is *evidence-relevant control* only. It **never +duplicates capture** (hooks own that) and **never grows evidence-irrelevant ops**. +Delivery ops (`export/bundle`, `sanitize`, `deconfliction`, `anchors`) that appear +on REST belong to the **Delivery** subsystem (next candidate), not the control +catalog — see Gaps. + +## The Adapter role (the contract every face fills) + +- **Declares:** transport, audience, the op-subset it exposes, auth model, + serialization. +- **Contains:** no logic. It marshals a request into a canonical op call and + marshals the result back. **A face that makes a decision is a bug** — that is the + §7 failure mode, and the review test for any face change. +- **Test seam:** given a mocked canonical op layer, assert the adapter calls the + right op with the right args and serializes the result — no behaviour of its own + to test beyond marshalling. + +## Face classification (transport × audience × generation) + +| Face | Transport | Audience | Generation | Op coverage | Source | +|---|---|---|---|---|---| +| **REST** | HTTP `/api/*` | scripts / CLI backend | canonical hand-written | full | `src/core/api-server.ts` | +| **MCP** | JSON-RPC over HTTP `/mcp` + stdio bridge | AI agents | mirrored (18 `redlog_*` tools) | full control catalog | `api-server` L111–208, `mcp-tools.ts` | +| **Renderer IPC** | Electron IPC (`window.redlog`) | operator UI | native | UI-relevant subset | `src/main` ↔ `src/preload` | +| **CLI** | shells out to REST | humans / scripts | thin wrapper | partial (events, marker, search) | `cli/redlog-cli.js` | +| **Shell helpers** | source into `.zshrc`; curl REST | human shell | thin wrapper | quick ops | `shell/redlog-agent.sh` | +| **Codex schema** | static JSON function schema | external GPT/codegen | mirrors MCP (18) | full control catalog | `docs/codex-tools.json` | + +## Gaps this framework surfaces + +| # | Gap | Why it matters | Fix | +|---|---|---|---| +| 1 | Is MCP a thin adapter over the *same* canonical ops as REST, or a **parallel `switch`**? | If parallel, the two can drift — the exact §7 risk | verify both dispatch into one shared op layer (the core modules); if not, extract it | +| 2 | `codex-tools.json` is hand-maintained but must equal the MCP surface | §7 wants faces "ideally generated" — hand-sync drifts | **generate** the Codex schema from the MCP tool registry | +| 3 | REST mixes control ops with **delivery ops** (`export/bundle`, `sanitize`, `deconfliction`, `anchors`) | blurs the §7 "evidence-relevant control only" boundary | move delivery ops to the Delivery subsystem's face; keep the control catalog minimal | +| 4 | CLI covers only a subset of ops | fine if intentional, opaque if not | declare the CLI's op-subset explicitly (part of the Adapter contract) | +| 5 | No single place lists the canonical catalog | new faces guess the op set | this doc's catalog table is the source; keep it in sync with the tool registry | + +## Why this decomposition pays + +- **Adding an integration surface** (a new agent client, a new script transport) + becomes "fill the Adapter contract for these ops on this transport" — no design. +- **Drift becomes detectable:** any face exposing an op not in the catalog, or + containing logic, fails the contract on review. +- **§7 stops being a slogan:** "one implementation, many thin faces" is now a + catalog + a contract you can check a diff against. + +## Cross-references + +- The method + variants: `DECOMPOSITION-METHOD.md` +- The principle: `DESIGN-PRINCIPLES.md` §7 +- The full agent surface (transports, setup): `agent-integration.md` +- Next candidate that owns the delivery ops split out here: Delivery / export targets diff --git a/docs/DECOMPOSITION-METHOD.md b/docs/DECOMPOSITION-METHOD.md new file mode 100644 index 00000000..6cf9a8b9 --- /dev/null +++ b/docs/DECOMPOSITION-METHOD.md @@ -0,0 +1,87 @@ +# The Decomposition Method — how RedLog closes open subsystems + +Written 2026-08-13. RedLog has several subsystems that grow over time (plugins, +capture sources, detectors, integration surfaces). Left ad-hoc, each addition is a +bespoke design decision and the subsystem drifts. This doc names the **repeatable +method** already used for plugins (`PLUGIN-ROLES.md`) and capture +(`CAPTURE-SOURCE-TAXONOMY.md`) so any future subsystem — plugin or not — can be +decomposed the same way, and lists which RedLog subsystems are next. + +## The method (six steps) + +1. **Name the open-ended subsystem.** The tell: things get *added* to it over time + (new plugin, new detector, new integration), and today each addition is a + one-off. If it's a fixed list that never grows, it doesn't need this. +2. **Find the mechanism axis** — *what shape of work is this?* Usually + "what layer does it act on" or "what is its input → output shape." This axis + produces the **roles**. +3. **Find the tier / authority axis** — the orthogonal concern that changes the + *rules* not the *shape*: trust (runs code inside RedLog?), authority (§3: + authoritative fact vs inferred suggestion?), or origin (built-in vs plugin). +4. **Enumerate the closed role set** at the axes' intersection, then **prove it's + exhaustive** over the actual API/surface — a table mapping every existing slot + (every `PluginContributes` field, every `*-detector.ts`) to exactly one role. + The proof is what turns "a taxonomy" into "the taxonomy." + + **Variant — one role + a canonical catalog.** Sometimes every member does the + *same* shape of work (e.g. control-plane faces are all thin adapters). Then the + mechanism axis yields a *single* role, and the closed set that matters is a + **canonical catalog** the role adapts (the op set). You still prove + exhaustiveness — every face exposes only catalog ops; anything outside is drift. + See `CONTROL-PLANE-FACES.md`. +5. **Give each role a template** — the same headings for every role so building a + new one is filling a known shape: contract (what it emits), tier & why, local + cost, install/wiring, test seam, and a real instance. +6. **List the API gaps the framework surfaces** — needs that fit no role are not + "unclassifiable," they are places the *surface* must grow. Naming them turns + unknowns into a backlog. + +## What makes a decomposition *good* (the invariants) + +- **Closed:** a small, named set — not "here are some examples." +- **Exhaustive with proof:** a mapping table showing nothing falls outside. If you + can't build the table, the axes are wrong — pick different ones. +- **One axis per concern:** mechanism decides the role; tier decides the rules. + Mixing them (a role that's half-shape, half-trust) is the usual failure. +- **Template per role:** identical headings, so the docs are diff-able and a new + contributor fills a form, not a blank page. +- **Gaps named, not hidden:** every "doesn't fit yet" is an explicit API-gap row. + +## Why it pays + +- **Design:** "should we build X?" becomes "which role is X?" — and if none, "which + gap does X open?" Both have answers; the ambiguous middle disappears. +- **Build:** each role has a test seam and a contract, so implementation is + mechanical and reviewable against a template. +- **AI-navigability:** a coding agent (or a new dev) routes a task to a role in one + lookup instead of reading the whole subsystem. +- **Laws enforced structurally:** when every detector role must emit a §3 + suggestion, honesty stops depending on each author remembering §3 — the role + contract carries it. + +## RedLog decomposition map + +| Subsystem | Status | Mechanism axis (→ roles) | Tier / authority axis | Doc | +|---|---|---|---|---| +| **Plugins** | ✅ done | what layer × what shape → 7 roles | runs code inside RedLog (🟢/🔴) | `PLUGIN-ROLES.md` | +| **Capture sources** | ✅ done | which medium → 15 substrate items | operator-deliberate vs ambient (§2); built-in vs plugin | `CAPTURE-SOURCE-TAXONOMY.md` | +| **Sanitize/retention/rotation** | ✅ specced | which lifecycle stage → hot/warm/cold | pinned vs unpinned (scope/marker); chain vs artifact | `SPEC-SCOPE-AWARE-LIFECYCLE.md` | +| **Detectors / inference** | 🟡 next (this batch) | input→output shape → Extractor/Classifier/Monitor/Correlator | all §3-inferred; built-in vs plugin | `DETECTOR-ROLES.md` | +| **Control-plane faces (§7)** | ✅ done | *one role* (Adapter) + canonical op catalog; classified by transport × audience × generation | which core ops exposed; auth level | `CONTROL-PLANE-FACES.md` | +| **Delivery / export targets** | ✅ done | timing/cardinality → Snapshot vs Stream | sanitize profile by audience (full / scope-sanitized / filtered) | `DELIVERY-TARGETS.md` | +| **Off-chain content stores** | ✅ done | content shape → Blob (io_ref, screenshot) vs Stream (`.cast`) | on-chain digest vs off-chain bytes; content- vs path-addressed | `OFF-CHAIN-CONTENT-STORES.md` | +| **Timeline surface** | ✅ done (structural lens) | 6 encoding channels × 4 interaction modes | fact vs inferred suggestion (§3, solid/dashed) | `TIMELINE-ELEMENTS.md` (defers to `SPEC-TIMELINE-AXIS.md` for design) | +| **Event-type vocabulary** | ✅ done | origin → authored/captured/agent/derived/system/plugin | authoritative vs inferred (§3); declares timeline identity | `EVENT-TYPE-VOCABULARY.md` (field ref: `event-schema.md`) | + +**Already closed elsewhere (cross-link, don't redo):** trust tiers 🟢/🔴; +capabilities (least-authority set, `types.ts` L119); four-layer redaction +(`redaction-design.md`); timeline surfaces §8 (map / I/O reader / HUD / search); +Settings by necessity tier × front door (F3 ticket, §9). + +## Cross-references + +- Worked instances: `PLUGIN-ROLES.md`, `CAPTURE-SOURCE-TAXONOMY.md`, + `DETECTOR-ROLES.md`, `SPEC-SCOPE-AWARE-LIFECYCLE.md` +- The laws a decomposition must carry: `DESIGN-PRINCIPLES.md` (§2 capture, §3 + facts-vs-suggestions, §7 one-implementation faces, §8 timeline surfaces) +- Deep-module vocabulary (seams, interfaces): the `codebase-design` skill diff --git a/docs/DELIVERY-TARGETS.md b/docs/DELIVERY-TARGETS.md new file mode 100644 index 00000000..66776985 --- /dev/null +++ b/docs/DELIVERY-TARGETS.md @@ -0,0 +1,96 @@ +# Delivery Targets — how evidence leaves RedLog + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the delivery/export +subsystem (`bundle-export`, `cloud-share`, `deconfliction`, `signing`). It also +absorbs the boundary that `CONTROL-PLANE-FACES.md` split out: delivery ops +(`export/bundle`, `sanitize`, `deconfliction`, cloud-share) are **not** control +ops — they live here. Its job: **every way evidence leaves RedLog is one of two +roles, declares a sanitize profile by audience, and carries a shape-appropriate +integrity contract — so "add a delivery target" is filling a template, and no path +can leak un-sanitized or unverifiable evidence.** + +## The two roles (mechanism axis: timing / cardinality) + +``` +Produce a complete, self-verifiable artifact of the store at a point in time? → SNAPSHOT +Forward selected events in real time as they happen? → STREAM +``` + +Everything else (destination, audience, sanitize level) is a *classification*, not +a role — see the axes below. + +## Master table + +| Target | Role | Audience | Sanitize profile | Timing | Transport | Integrity | Source | +|---|---|---|---|---|---|---|---| +| **Evidence bundle** | Snapshot | third-party / court / client | layer-4 sanitized (raw→sanitized swap) or full (opt-in) | batch | local file (out dir) | Ed25519 sig + OTS anchor + manifest + sanitized-reconciliation | `bundle-export.ts` | +| **Cloud share** | Snapshot | operator / shareable link | redaction-gated (`RedactionPreview`) | batch | remote PUT (Worker/S3) + `expiresAt` | manifest + sanitize counts (verify it also signs/anchors — gap #3) | `cloud-share.ts` + `-uploader.ts` | +| **Deconfliction feed** | Stream | blue team | subtype-filtered (`scope_violation` default) + `includeData` flag | real-time per-event | signed webhook (HTTP POST) | HMAC per body | `deconfliction.ts` | + +## The two classifying axes + +- **Audience** → sets the **sanitize profile** (the tier axis): third-party (must + not leak out-of-scope/PII) / blue-team (filtered subset only) / operator-link + (redaction-gated). Audience is *why* a profile is chosen. +- **Transport** → local file / remote upload+expiry / webhook. Independent of role. + +## The uniform contract every target fills + +1. **Declare audience → sanitize profile.** Not ad-hoc per target — one of the + named profiles (see Gaps #1). A Snapshot for a third party runs the scope-aware + sanitize planner (`SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B); a Stream declares its + event/subtype filter. +2. **Carry shape-appropriate integrity.** Snapshot → signed + anchored + a manifest + that reconciles every sanitized swap (a stripped bundle is *detectable*, not + silently smaller). Stream → per-body signature so each forwarded event is + authenticable. +3. **Append an audit event for the delivery action** (export/upload/flush), so the + act of delivering is itself on the chain. +4. **Test seam:** the artifact/payload builder is pure — `build(store, profile) → + artifact` — testable without the transport (cloud-share already splits builder + from uploader "to keep the redaction gate testable", `cloud-share.ts` L16). + +## Per-role reference + +### Snapshot — a complete, self-verifiable artifact + +- **Produces:** the whole store (or a scoped slice) as a signed, anchored, + manifest-described artifact a third party can verify **without RedLog** + (`bundle-export` ships `signerPubKey` for `tools/redlog-verify.py`, L122). +- **Sanitize:** runs the profile before write — layer-4 raw→sanitized swap + (`bundle-export` L86–101), each swap reconciled by a paired `system.sanitized` + event so tampering (stripping bytes) is detectable. +- **Transport variants:** local file (bundle) vs remote upload with expiry + (cloud-share) — same role, different transport. +- **Instances:** evidence bundle, cloud share. **Plugin face:** the reserved 🔴 + `exporters` role (`PLUGIN-ROLES.md`) — a plugin-contributed Snapshot format; its + first instance is the scope-sanitized client bundle. + +### Stream — a real-time per-event feed + +- **Produces:** selected events forwarded as they land, each independently signed. +- **Sanitize:** by *filter*, not redaction — only whitelisted subtypes + (`scope_violation` default, `deconfliction.ts` L21) and an `includeData` toggle + decide what leaves. Buffered with a shutdown flush (L158). +- **Transport:** signed webhook. Config (URL/secret/filter) is frozen per batch to + avoid mid-batch drift (L137). +- **Instances:** deconfliction feed. **Plugin face:** none yet — a plugin SIEM/ + blue-team forwarder would be a gated plugin Stream (Gap #4). + +## Gaps this framework surfaces + +| # | Gap | Role | Fix | +|---|---|---|---| +| 1 | Sanitize is ad-hoc per target (bundle: layer-4 `sanitized_events`; cloud-share: own `RedactionPreview`; deconfliction: subtype filter) | both | unify into **named sanitize profiles** (`full` / `scope-sanitized` / `filtered`) shared across all targets — the same planner as lifecycle Part B | +| 2 | Delivery ops sit on the REST control face | — | move `export/bundle`, `sanitize`, `deconfliction`, cloud-share off the §7 control catalog onto a Delivery face (`CONTROL-PLANE-FACES.md` gap #3) | +| 3 | Cloud-share integrity may be weaker than bundle | Snapshot | verify cloud-share carries the same Ed25519 + OTS as `bundle-export`; a "verifiable snapshot" contract should be uniform, not per-transport | +| 4 | No plugin path for either role | both | `exporters` (🔴, reserved) = plugin Snapshot; add a gated plugin Stream for SIEM/blue-team forwarders | +| 5 | Snapshot scope-sanitize planner not wired | Snapshot | implement `SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B as the third-party Snapshot's default profile | + +## Cross-references + +- The method: `DECOMPOSITION-METHOD.md` +- Sanitize/retention/rotation this shares profiles with: `SPEC-SCOPE-AWARE-LIFECYCLE.md` +- The plugin Snapshot role: `PLUGIN-ROLES.md` (Exporter) +- The control-plane boundary this closes: `CONTROL-PLANE-FACES.md` gap #3 +- Bundle threat model + verify: `audit-trail.md`, `CLOUD_SHARE_BUNDLE.md` diff --git a/docs/DETECTOR-ROLES.md b/docs/DETECTOR-ROLES.md new file mode 100644 index 00000000..493667da --- /dev/null +++ b/docs/DETECTOR-ROLES.md @@ -0,0 +1,142 @@ +# Detector Roles — the inference layer, decomposed + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to RedLog's detection / +inference layer (the ~10 bespoke `*-detector.ts` / `*-monitor.ts` files). Its job: +**every detector is one of four roles emitting one uniform §3 suggestion — so +"add a detector" is filling a template, and DESIGN-PRINCIPLES §3 is carried by the +role contract instead of each author's memory.** + +## The principle everything derives from + +By §3, RedLog records **facts** — operator markers and primary capture (a command +*was* run). **Everything a detector produces is interpretation on top of a fact, +so it is an inferred *suggestion*, never authoritative** — labelled, confidence- +scored, attributable to the detector, and operator-promotable. The detector layer +is therefore uniform on the *authority* axis (all inferred); roles are cut on the +**mechanism axis: input → output shape.** + +## The four roles (mechanism axis) + +``` +Pull a structured fact out of ONE event (a command)? → EXTRACTOR +Attach a LABEL + confidence to one event/output? → CLASSIFIER +Watch WORLD/SYSTEM state and emit on a transition? → MONITOR +Relate TWO+ events into a causal/relational edge? → CORRELATOR +``` + +## Master table (every detector file maps to one role) + +| Role | Input → Output | Built-in files | Emits | Confidence source | Plugin counterpart | +|---|---|---|---|---|---| +| **Extractor** | 1 command → a structured fact | `pivot-detector`, `target-extractor`, `technique-tagger` (`detectCleanup`/`detectFileTransfer`) | `pivot` / target / `cleanup` / `file_transfer` companion events | deterministic rule (implicit-high) | `targetExtractors` (Recognizer) | +| **Classifier** | 1 event/output → label + confidence | `loot-detector`, `scope-monitor`, `command-tagger` | `loot`, `scope_violation`, stamped tags | pattern/entropy → `high\|medium\|low` | `lootPatterns` / `commandTags` (Recognizer) · `detectionPatterns`/`monitors` (Labeller) | +| **Monitor** | world state over time → transition event | `ip-monitor`, opsec-state monitor | `system.ip_transition`, `system.opsec_state_changed` | observed state delta | — (built-in; a plugin path would be a gated Monitor) | +| **Correlator** | N events → causal edge | `causes-resolver` | `_causes` links (start→end via flow_id) | structural match (flow_id/session) | — | + +**Completeness:** all ten inference-layer files map to exactly one of four roles. +The axis (input→output shape) is exhaustive because a detector can only consume +one event, one blob, ambient state, or a set of events — there is no fifth input +class. + +## The §3 suggestion contract (what EVERY role must emit) + +This is the uniform output the role framework enforces — the payoff of the +decomposition: + +- **Inferred, never authoritative:** does not mutate the source event's data; lives + in the derived/review layer or as a typed detection event. +- **Confidence-scored:** an explicit `confidence` (loot already does this, + `loot-detector` L8/L17). Deterministic Extractors carry an implicit-high but + should still *state* it, not omit it. +- **Detector-attributed:** names which detector fired (`target-extractor` already + stamps `extractor_name`; every role should carry the analog). +- **Operator-promotable:** one-click promotion to an authoritative marker; rendered + visually distinct from operator assertions (dashed vs solid, §3). + +## Per-role reference (template) + +### 1. Extractor — pull a structured fact from one command + +- **Input → output:** a command string → a structured record (tool, subtype, via, + route, target). Pure function, no side effects (`technique-tagger` L13, + `pivot-detector` L32 are the model). +- **Emits:** a first-class companion event (`pivot`/`cleanup`/`file_transfer`) or a + resolved target with provenance. +- **Confidence:** deterministic rule → implicit-high; **state it explicitly** (gap: + `pivot-detector`/`technique-tagger` currently omit a confidence field though §3 + says the *interpretation* "this ssh -D is a SOCKS pivot" is inferred). +- **Cost:** one regex pass per command. Negligible. +- **Test seam:** `detectX(command) → record | null` over sample-command fixtures — + already the shape; keep it pure. +- **Instances:** pivot, cleanup, file-transfer, target extraction. Plugin face: + `targetExtractors`. + +### 2. Classifier — label an event/output with confidence + +- **Input → output:** an event or output blob → a label + `confidence`. +- **Emits:** `loot` (type + confidence), `scope_violation` (verdict), stamped tags. +- **Confidence:** pattern/entropy/heuristic → `high|medium|low` (`loot-detector` + L8). The **scope verdict is a Classifier output** — inferred, so it must be + promotable, not silently authoritative. +- **Cost:** regex/entropy per event; a model-backed classifier (injection) belongs + in a 🔴 plugin Monitor, not the built-in hot path. +- **Test seam:** `classify(input) → {label, confidence} | null` over labelled + fixtures (real / not). +- **Instances:** loot, scope verdict, technique/command tags. Plugin face: + `lootPatterns`, `commandTags` (deterministic), and the Labeller role + (`detectionPatterns` 🟢 / `monitors` 🔴) for AI-era injection labels. + +### 3. Monitor — watch state, emit on transition + +- **Input → output:** ambient world/system state sampled over time → a transition + event only when it changes. +- **Emits:** `system.ip_transition`, `system.opsec_state_changed` (VPN/DNS/MAC/ + hostname). `IPMonitor extends EventEmitter` (`ip-monitor` L122) is the model. +- **Confidence:** the delta is observed (a fact *that state changed*); the + *interpretation* (safe vs risky transition) is the inferred part. +- **Cost:** polling (30 s opsec, hysteresis on IP) — standing, so tune intervals; + matches §2's ambient-is-opt-in stance for anything heavier. +- **Test seam:** `next(prevState, sample) → transition | null` — pure over state + pairs; the poller is a thin wrapper. +- **Instances:** IP monitor, OPSEC monitor. + +### 4. Correlator — relate events into a causal edge + +- **Input → output:** two or more events → a causal/relational link between them. +- **Emits:** `_causes` edges (a `command_end` refers to its `command_start`; an + `http_request_end` to its `http_request_start` via `flow_id` — + `causes-resolver` L58/L75). +- **Confidence:** usually a **structural** match (flow_id/session/pid), so + high-certainty; a heuristic correlation (timing-only) would be inferred and must + say so. +- **Cost:** a small in-memory cache keyed by the linker field; O(1) per event. +- **Test seam:** `resolveIncomingCauses(agentType, data) → sourceIds` over an + event sequence (`_resetCausesResolver` exists for test isolation, L104). +- **Instances:** start→end causal linking. Candidate future: pivot→command, + loot→exfil chains. + +## The cross-link that matters + +**The detector layer and the plugin Recognizer/Labeller roles are one taxonomy +seen from two sides.** Built-in `loot-detector`/`target-extractor` are the core's +Classifier/Extractor; plugin `lootPatterns`/`targetExtractors`/`detectionPatterns` +are the *contributed* Classifier/Extractor. So a new detection need has one +decision — **core or plugin** — and the same role either way. This is why the two +docs share vocabulary. + +## Gaps this framework surfaces + +| # | Gap | Role | Fix | +|---|---|---|---| +| 1 | Extractors omit an explicit confidence field | Extractor | add `confidence` (implicit-high) so §3's "this is inferred" is visible, not assumed | +| 2 | scope verdict is emitted as a plain violation, not a promotable suggestion | Classifier | render the verdict as a §3 suggestion with one-click operator promotion | +| 3 | no built-in inferred-`detection` event type | Classifier/Labeller | define the shared §3 suggestion shape (confidence, attribution, promote) — same gap as `PLUGIN-ROLES.md` gap #3 | +| 4 | Monitor has no plugin path | Monitor | if third-party state monitors are wanted, a gated plugin Monitor (mirrors 🔴 `monitors`) | +| 5 | Correlator is single-purpose (start→end) | Correlator | generalize to declare linker-field + relation, enabling pivot→command / loot→exfil chains | + +## Cross-references + +- The method: `DECOMPOSITION-METHOD.md` +- Plugin-side twin roles: `PLUGIN-ROLES.md` (Recognizer, Labeller) +- The law this layer carries: `DESIGN-PRINCIPLES.md` §3 +- Event shapes: `event-schema.md` diff --git a/docs/EVENT-TYPE-VOCABULARY.md b/docs/EVENT-TYPE-VOCABULARY.md new file mode 100644 index 00000000..2def7fa0 --- /dev/null +++ b/docs/EVENT-TYPE-VOCABULARY.md @@ -0,0 +1,88 @@ +# Event-Type Vocabulary — origin, authority, and timeline identity + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the `agent_type` +vocabulary — the set of event kinds and **how each declares its timeline +presentation and its §3 authority**. This is the seam that ties three frameworks +together: a **capture plugin** (`PLUGIN-ROLES.md`) emits events; each event's +`agent_type` must claim a **timeline identity** (`TIMELINE-ELEMENTS.md` channels); +and that identity must state whether the event is a **fact or a suggestion** +(§3). `event-schema.md` owns the field definitions; this doc is the classification ++ the pairing rule. + +## The load-bearing rule (why capture plugins need this) + +**A capture plugin that emits a new event kind MUST pair its `capture` with an +`eventTypes` contribution.** Without it, the event's `agent_type` is unknown to the +registry (`event-registry.ts`) and the timeline drops it into the generic "other" +bucket — no lane, no colour, no glyph, no authority. So "capture" and "how it looks +on the timeline" are **one decision**, not two: emitting without declaring is the +most common way a plugin's evidence becomes illegible on the reconstruction +surface. + +``` +Parser/Tailer/Tee (PLUGIN-ROLES) → emits events with agent_type X + │ + MUST pair with ─────────────► eventTypes: { agentType: X, label, lane, color, icon } + │ (registerEventTypes → event-registry) + ▼ + timeline channels (TIMELINE-ELEMENTS): lane, colour, glyph + + §3 authority (fact vs inferred) +``` + +## Classification — origin axis (mechanism) + +Every `agent_type` maps to exactly one **origin** — where the event comes from — +which also fixes its default authority (§3): + +| Origin class | agent_types | Authority (§3) | Default timeline identity | +|---|---|---|---| +| **Operator-authored** | `marker` | **authoritative** (operator assertion) | own lane, solid | +| **Primary-capture** | `shell`/`terminal`, `http`, `http_navigation`, `browser`, `scanner`, `dns`, `screenshot`, `clipboard`, `process` | **authoritative** (a thing actually happened) | source lane / target lane, solid | +| **Agent** | `agent` (tool-calls) | authoritative (the agent did call the tool) | agent lane, solid | +| **Detector-derived** | `loot`, `scope_violation`, `pivot`, `cleanup`, `file_transfer` | **inferred** (interpretation, §3) | own lane, **dashed/promotable** (see gap #2) | +| **System / meta** | `system` (`recording_paused`, `config_changed`, `sanitized`, `*_pruned`, `ip_transition`, `opsec_state_changed`) | authoritative (system fact / drift signal) | system lane / ribbon, solid | +| **Plugin-contributed** | any of the above, via a plugin | inherits the class it emits into | **must be declared** via `eventTypes` | + +**Completeness:** an event can only be *authored* by the operator, *captured* from +a medium, produced by the *agent*, *derived* by a detector, emitted by the +*system*, or *contributed* by a plugin — six origins, no seventh. Every existing +`agent_type` lands in exactly one. + +## The two axes + +- **Origin** (mechanism) → the class above; also sets **default authority**. +- **Authority** (§3) → authoritative vs inferred, which **must** show as solid vs + dashed on the timeline. This is the same authority axis as `DETECTOR-ROLES.md` + and `TIMELINE-ELEMENTS.md` — one law, three surfaces. + +## Timeline identity contract (per `agent_type`) + +Declared by `EventTypeDef { agentType, label, lane?, color?, icon? }` +(`event-registry.ts`), built-in or plugin-contributed. Maps onto the timeline +channels: + +- `lane` → the **Lane** channel (should resolve under the target/phase axis, §8 — + see gap #3). +- `color` → the **Colour** channel (source-type encoding — see gap #4). +- `icon` → the **Glyph** channel. +- **missing** `authority` → cannot drive the fact/suggestion (solid/dashed) split + (gap #2). + +## Gaps this framework surfaces + +| # | Gap | Fix | +|---|---|---| +| 1 | `eventTypes` is **optional** — a plugin can emit a new `agent_type` with no identity → "other" lane | make a new-emitting `capture`/`tailer` **require** a paired `eventTypes` (load-time warn/lint); surface undeclared kinds in Settings ▸ Plugins | +| 2 | `EventTypeDef` has **no `authority` field** — a plugin's inferred detection can't render dashed | add `authority: 'fact' \| 'inferred'` to `EventTypeDef`; wire to the solid/dashed split (same as `TIMELINE-ELEMENTS.md` G4 + the Labeller role) | +| 3 | `lane` is an **open string** — a plugin can invent a lane that doesn't map to the target/phase axis | reconcile the lane vocabulary with the `lanesForAxis` model (§8): a plugin lane is a *source* lane (a filter), never a new organizing axis | +| 4 | `color` is per-`agent_type`, but the timeline binds **Colour ← source-type**; a plugin picking arbitrary colours collides with the palette | derive plugin colours from the source-type palette, or document the collision rule | +| 5 | vocabulary is documented in `event-schema.md` but the **origin/authority classification** isn't formalised there | keep this doc as the classification; `event-schema.md` stays the field reference | + +## Cross-references + +- The capture roles that emit events: `PLUGIN-ROLES.md` (Parser, Tailer, Tee) +- The channels an identity maps to: `TIMELINE-ELEMENTS.md` +- The inferred-origin detectors: `DETECTOR-ROLES.md` +- Built-in vs plugin capture line: `CAPTURE-SOURCE-TAXONOMY.md` +- Field reference (source of truth): `event-schema.md` +- Fact vs suggestion: `DESIGN-PRINCIPLES.md` §3 diff --git a/docs/OFF-CHAIN-CONTENT-STORES.md b/docs/OFF-CHAIN-CONTENT-STORES.md new file mode 100644 index 00000000..9dd44c06 --- /dev/null +++ b/docs/OFF-CHAIN-CONTENT-STORES.md @@ -0,0 +1,105 @@ +# Off-Chain Content Stores — one pattern, two shapes + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the heavy content RedLog +keeps *off* the hash chain. Today this is **four stores** across separate +implementations — `.cast` recordings, agent transcripts, io_ref bodies, +screenshots — with separate directories, **three live retention paths** (io_ref is +spec-only, never implemented), and parallel reference conventions. This doc shows +they are **one off-chain-content pattern in two shapes (Blob / Stream)**, unified +by a single invariant and a single lifecycle engine (per the cross-session +consensus + code audit with the transcript/log sessions, 2026-08-13). + +## The core invariant (from `SPEC-IO-SIDECAR.md`) + +**Bytes never enter the chain — only their `sha256`.** The event carries a +reference + digest; the bytes live in an off-chain store; a pruned/absent object +reads as **pruned, not tampered** (`redlog-verify` semantics). This is the same +invariant already stated for the io sidecar — the decomposition just recognises it +governs *all* heavy content, not only HTTP bodies. + +## The two roles (mechanism axis = content shape) + +``` +Is the content one opaque object (read whole or by byte-range)? → BLOB +Is it a time-indexed sequence (read by time-slice)? → STREAM +``` + +## Master table + +| Store | Role | Addressing | Dir / layout | Reference on event | Read | Retention today | +|---|---|---|---|---|---|---| +| **io_ref bodies** | Blob | **content-addressed** | `io/.bin`, dedup by digest | `io.*` ref + `sha256` | whole or ranged (`io:read`, path-validated) | **spec-only — never implemented** (no runtime code; only `SPEC-IO-SIDECAR.md` et al.) | +| **Screenshots** | Blob | **path-addressed** | `screenshots/${ts}_${trigger}.jpg`; `sha256` = integrity only, not location/dedup (`screenshot-agent.ts`) | `filename` (basename) + `sha256` | whole (image) | `screenshots.keepDays` → `screenshot_pruned` | +| **Cast recordings** | Stream | path-addressed | `casts/` per session; integrity `castSha256` | `castPath` + `castSha256` | **time-slice** `readCastSlice(path, startMs, endMs)` (asciicast v2 JSON-lines) | `terminal.castKeepDays` → `cast_pruned` | +| **Agent transcripts** | Stream | path-addressed | `agent-transcripts/${agentKind}-${sessionId}.jsonl`, append-only; integrity `cumulative_sha256` | `snapshot_path` + `cumulative_sha256` (`transcript_snapshot` event) | whole / tail | `agentTranscripts.keepDays` → `agent_transcript_pruned` | + +## The two classifying axes + +- **Shape** (mechanism) → **Blob** (opaque object) vs **Stream** (time-indexed + sequence). This is the role. +- **Addressing** → **content-addressed** (dedup by digest, location = the hash) vs + **path-addressed** (per-session/per-capture name, digest is integrity only). + Independent of shape. + +**These two axes give three valid combinations, not two** (per the code audit, +2026-08-13): + +| | content-addressed | path-addressed | +|---|---|---| +| **Blob** | io_ref bodies | screenshots | +| **Stream** | — (a time sequence *is* its position; content-addressing is nonsensical) | `.cast`, agent transcripts | + +So the honest classification is **Blob-content / Blob-path / Stream-path** — see +gap #3. + +## The uniform contract every off-chain store must satisfy + +The payoff — three ad-hoc stores collapse to one interface + two shape adapters: + +1. **Digest on chain, bytes off chain.** The event holds `ref` + `sha256`; the + store holds bytes. Verify checks bytes-match-digest; a missing object is + *pruned*, never *tampered*. +2. **Referenced, not embedded.** The store resolves `ref → bytes` behind a + **path-traversal guard** (stay inside the store dir — `io:read` already does + this; the guard should be shared, not re-implemented per store). +3. **One lifecycle engine.** hot → warm (compress) → pruned, **refcount-gated**, + **scope/marker as pin** — all from `SPEC-SCOPE-AWARE-LIFECYCLE.md`. Every store + plugs into *one* retention/rotation engine, not three keep-day knobs. +4. **Shape-appropriate read.** Blob → whole or byte-range; Stream → time-slice. + Both **lazy** (never loaded to scroll the timeline; a preview/glyph stands in, + per `TIMELINE-ELEMENTS.md`). + +## Completeness + +Heavy content is either **one opaque object** (Blob) or a **time-indexed +sequence** (Stream) — there is no third shape. Every current store maps to one +role. Future heavy captures slot in by shape without a new subsystem: + +- **Blob:** pcap files, memory dumps, downloaded artefacts, binary loot. +- **Stream:** screen-recording video, keystroke timelines, packet streams. + +## Gaps this framework surfaces + +| # | Gap | Fix | +|---|---|---| +| 1 | **io_ref is spec-only** — no runtime code exists (grep hits docs only); it is *never implemented*, not "wired but retention-less" | implement the Blob store, then wire its keep-window as the first store to prove the unified interface | +| 2 | **Three parallel retention branches** — `config.terminal.castKeepDays` + `config.screenshots.keepDays` + `config.agentTranscripts.keepDays`, each a separate branch in `retention.ts` `sweepRetention()` | a `contentStore..keepDays` registry turns 3 branches into **one loop over registered stores** — a real code convergence, not just a doc one | +| 3 | **Addressing is three-way, not two** — the audit found screenshots are path-addressed (filename = `${ts}_${trigger}`, `sha256` integrity only), not content-addressed | classify deliberately as **Blob-content / Blob-path / Stream-path** (Stream-content is nonsensical); document why each store sits where it does | +| 4 | **`isInsideDir()` traversal guard duplicated in 3 places** (tailer sidecar, screenshot delete, terminal replay) | extract one shared traversal guard + one shared "bytes-match-digest / pruned≠tampered" verify, used by every adapter | +| 5 | **Compression/pin not applied uniformly** | the warm-compress + scope-as-pin from the lifecycle spec should cover `.cast`, agent transcripts (both compress well) and screenshots, not just io bodies | +| 6 | **Four stores, one missing from the taxonomy until now** — agent transcripts (`transcript_snapshot` + `cumulative_sha256`) is a Stream store parallel to `.cast` | route it through the same Stream adapter; it already has the `snapshot_path` + `cumulative_sha256` shape | + +## Note on formats + +The `.cast` on-disk format (asciicast v2 today; a v3 greenfield move is under +discussion in the transcript session) is the **Stream adapter's concern**, not this +layer's — the decomposition is about the store contract (digest, ref, lifecycle, +read), not the byte format. See that session for the format decision. + +## Cross-references + +- The Blob store's detailed spec: `SPEC-IO-SIDECAR.md` +- The lifecycle engine all stores share: `SPEC-SCOPE-AWARE-LIFECYCLE.md` +- Which capture items produce which content: `CAPTURE-SOURCE-TAXONOMY.md` +- How content surfaces on the timeline (glyph/preview, lazy): `TIMELINE-ELEMENTS.md` +- The method: `DECOMPOSITION-METHOD.md` diff --git a/docs/PLUGIN-ROLES.md b/docs/PLUGIN-ROLES.md new file mode 100644 index 00000000..43a4e8f5 --- /dev/null +++ b/docs/PLUGIN-ROLES.md @@ -0,0 +1,266 @@ +# Plugin Roles — a categorization for unambiguous development + +Written 2026-08-13. The classification layer above `docs/plugin-development.md` +(mechanics/trust) and `SPEC-AI-ERA-PLUGINS.md` (the four AI-era gaps). Its job: +**a developer with a capture/detection need looks up one row, and knows exactly +which contribution type, which trust tier, what event to emit, and what it costs +locally — before writing a line.** + +## The one principle everything derives from + +**Built-in captures the *medium* (shell / HTTP / DNS / agent tool-call / screen / +clipboard / process). A plugin performs a *role* over that medium.** So the first +question is never "what tool is this" but "**what am I doing, to which layer**" +— that answer is the role, and the role fixes the contribution and the tier. + +## Two classifying axes + +- **Axis 1 — what layer does it act on?** → picks the *role*. +- **Axis 2 — does it run code *inside* RedLog?** → picks the *trust tier* + (🟢 data / out-of-process = trust-free; 🔴 in-process code = isolated, + capability-scoped, human-approved). + +## Decision tree (pick your role) + +``` +Are you teaching RedLog to *understand* a tool with only data/regex, + over events it already captures? → RECOGNIZER 🟢 +Turning a tool's *structured output file* into typed events? → PARSER 🟢 +Following an *external log/stream* continuously? → TAILER 🟢* +Sitting *between two external parties*, forwarding+recording? → TEE 🟢 +*Annotating* already-captured events with a judgement? → LABELLER 🟢/🔴 +Giving an *AI agent* tools to operate RedLog? → TOOL PROVIDER 🔴 +Producing a *deliverable* from the evidence store? → EXPORTER 🔴† +``` +`*` tailers: **bundled-only today** (user-plugin tailers need isolation work, +`types.ts` L144). `†` exporters: contribution **reserved, not yet implemented**. + +## Master table + +| Role | Acts on | Contribution(s) | Tier | Emits | Local cost | AI-era instance | +|---|---|---|---|---|---|---| +| **Recognizer** | substrate's reading of captured events | `targetExtractors`, `commandTags`, `lootPatterns`, `redaction`, `eventTypes` | 🟢 data | tags/targets on existing events; no new event | per-event regex, negligible | recognize a new AI-tool's targets/fields | +| **Parser** | a tool's completed output file | `capture` (+`eventTypes` +`targetExtractors`) | 🟢 | typed `scanner`/`scan_result` events | one-shot parse | **Gap 1** structured scan (nmap XML, nuclei JSON) | +| **Tailer** | an external long-running log/stream | `tailers` | 🟢* | events per line/record | continuous file-follow | **Gap 2** C2 logs (Sliver, CS) | +| **Tee** | a channel between two external parties | `capture` (out-of-process proxy) | 🟢 | `agent`/`tool_call` events | one hop per call | **Gap 3** third-party MCP tee | +| **Labeller** | already-captured event stream | `detectionPatterns` (🟢, proposed) / `monitors` (🔴, reserved) | 🟢/🔴 | inferred `detection` suggestions (§3) | regex per-event / LLM call | **Gap 4** injection / hijack labels | +| **Tool provider** | RedLog itself (as MCP server) | `mcpTools` | 🔴 | tools the agent calls; events attributed to operator | RPC | RedLog-as-tool for the operator's AI | +| **Exporter** | the evidence store | `exporters` | 🔴† | a deliverable artifact | store scan | scope-sanitized bundle (see lifecycle spec) | + +## Completeness — every contribution maps to exactly one role + +The roles are cut along the **contribution mechanism**, not along tools, so they +are provably exhaustive over the plugin API. Every slot in `PluginContributes` +(`types.ts` L130–146) maps to a role: + +| Contribution (`PluginContributes`) | Role | Tier | +|---|---|---| +| `targetExtractors` | Recognizer | 🟢 | +| `commandTags` | Recognizer | 🟢 | +| `lootPatterns` | Recognizer | 🟢 | +| `redaction` | Recognizer | 🟢 | +| `eventTypes` | Recognizer (also a Parser helper) | 🟢 | +| `capture` — parses a completed output | Parser | 🟢 | +| `capture` — forwards in-band | Tee | 🟢 | +| `tailers` | Tailer | 🟢* | +| `monitors` *(reserved)* | Labeller (semantic) | 🔴 | +| `detectionPatterns` *(proposed)* | Labeller (regex) | 🟢 | +| `mcpTools` | Tool provider | 🔴 | +| `exporters` *(reserved)* | Exporter | 🔴† | + +So **7 roles cover all 10 contribution slots** (the 5 Recognizer data-contributions +collapse into one role; `capture` splits into Parser vs Tee by whether it parses a +finished artifact or forwards a live channel). Anything a plugin can express today +is one of these roles. + +**The boundary (stated honestly):** the roles are complete *over the contribution +surface*, not over every imaginable need. A need that fits no contribution type is +not a plugin yet — it is an **API gap**, and the known ones are listed at the +bottom. When the surface grows, the new contribution gets a new role (or extends +one) — the framework is complete-and-extensible, not frozen. Roles are also +**for plugins only**: built-in substrate capture (the 15 medium items in +`CAPTURE-SOURCE-TAXONOMY.md`) is deliberately *not* a role. + +## Cross-cutting rule — emitting roles must claim a timeline identity + +The event-producing roles (**Parser, Tailer, Tee**, and a Labeller's detections) +emit events with an `agent_type`. **Each must pair with an `eventTypes` +contribution** so the event claims a timeline identity (lane / colour / glyph) and +its §3 authority — otherwise the timeline drops it into the generic "other" lane +and the plugin's evidence is illegible on the reconstruction surface. "Capture" +and "how it looks on the timeline" are one decision. See `EVENT-TYPE-VOCABULARY.md`. + +--- + +## Per-role reference (development template) + +Each role below follows the same template — **Layer · Contribution · Tier & why · +Event contract · Local cost · Install · Test seam · Instance** — so building a new +plugin is filling in a known shape. + +### 1. Recognizer 🟢 — teach the substrate, with data only + +- **Layer:** the interpretation of events the substrate already captured (a shell + command, an HTTP flow). Adds no new capture. +- **Contribution:** `targetExtractors` (pull a host from a bespoke tool's cmd), + `commandTags` (stamp fields — MITRE, tool-category), `lootPatterns` (new secret + format), `redaction` (allow/deny), `eventTypes` (give an `agent_type` a lane, + colour, icon). Exact shapes: `types.ts` L45–98. +- **Tier & why:** 🟢 — pure data the app reads; loads automatically; cannot touch + the chain. Zero trust cost, so **the long tail lives here** — most "support tool + X" needs are a `targetExtractor` + `eventType`, nothing more. +- **Event contract:** does not emit; annotates. `targetExtractors` stamp + `extractor_name` for audit traceability (`types.ts` L77). +- **Local cost:** regex per matching event. Negligible. +- **Install:** none — declarative, active on enable. +- **Test seam:** the pattern set is data; test the extractor/tagger as a pure + match over sample commands. +- **AI-era instance:** recognizing a new AI-agent CLI's target/host argument, or + tagging its subcommands with ATT&CK techniques. + +### 2. Parser 🟢 — a tool's structured output → typed events + +- **Layer:** a tool's *completed* structured artifact (nmap `-oX`, nuclei + `-json`, httpx). The substrate already logged the *command*; the Parser turns + the *result file* into typed findings. +- **Contribution:** `capture` (`CaptureContribution`, `types.ts` L100) that parses + and POSTs, **+** `eventTypes` for the timeline identity, **+** + `targetExtractors` for hosts/ports. +- **Tier & why:** 🟢 — an out-of-process script that POSTs to the authenticated + local API like any client; unprivileged, can't touch the chain. +- **Event contract:** one event per host/finding — + `agent_type: 'scanner'`, `subtype: 'scan_result'`, parsed fields (port, + service, template-id, severity). **Severity/loot are inferred suggestions + (§3)**, never authoritative. +- **Local cost:** parse runs once at scan completion. Output files can be large, + but only parsed events enter the chain (raw file optionally io-sidecar'd). +- **Install:** `installMethod: 'manual'` (wrap the tool invocation) or a Tailer + follow of the output directory. `manualSteps` carry the wrap command. +- **Test seam:** the parser is a pure `parse(rawOutput) → Event[]`; test against + captured sample XML/JSON fixtures before it ever POSTs. +- **AI-era instance:** **Gap 1** — a bundled `scan-parsers` pack. + +### 3. Tailer 🟢* — follow an external long-running channel + +- **Layer:** an external channel RedLog isn't sitting on — a C2 server's log/DB, + a scan-output directory being appended to. +- **Contribution:** `tailers` — a module exporting a `TailerAdapter` + (`{ agentKind, transcriptGlob, … }`, `tailer-registry.ts`). The host wires it + into `tailer-host`. +- **Tier & why:** 🟢, but **bundled-only today** — user-plugin tailers need the + isolation work slated for v0.8.3+ (`types.ts` L144). A tailer runs continuously + and reads an external file, so the eventual user-plugin path will be gated. +- **Event contract:** one event per line/record — e.g. `agent_type: 'scanner'` + `subtype: 'c2_checkin'`, or `pivot` for a new route. +- **Local cost:** continuous file-follow (inotify/poll) — low but standing; the + exposure window is the live tail, matching §2's "ambient = opt-in." +- **Install:** config the log path per tool; `manualSteps` document where each + framework writes its log. +- **Test seam:** the adapter's line→event mapping is pure; test over recorded log + fixtures. +- **AI-era instance:** **Gap 2** — a bundled `c2-tailers` pack. + +### 4. Tee 🟢 — record a channel by sitting in-band + +- **Layer:** an in-band channel between two *external* parties — the operator's AI + client and a third-party MCP server (HexStrike, PentestMCP). +- **Contribution:** `capture` — an out-of-process proxy the client points at, + which forwards to the real server **and** POSTs each call to RedLog. +- **Tier & why:** 🟢 — the proxy is a separate process; it can't stall RedLog and + can't touch the chain (it POSTs like any client). +- **Event contract:** `agent_type: 'agent'`, `subtype: 'tool_call'` / + `'tool_result'`, with tool name, input, and an output digest. +- **Distinguish from Tool provider (🔴):** a Tee **captures calls the agent makes + to someone else's server**; a Tool provider **exposes RedLog's own tools to the + agent**. Opposite directions — keep them named apart or they conflate. +- **Local cost:** one extra hop per tool-call — negligible interactively. +- **Install:** `manual` — repoint the AI client's MCP server URL/command at the + tee. Note: if the client has a native tool-call hook (Claude Code + `PostToolUse`), those calls are **already captured** — a Tee is only for + hookless clients. +- **Test seam:** the forward-and-tee logic is testable against a mock MCP server; + assert the emitted event equals the forwarded call. +- **AI-era instance:** **Gap 3** — needs a `examples/plugins/mcp-tee` shim (no new + API, but no pattern exists yet). + +### 5. Labeller 🟢/🔴 — annotate captured events with a judgement + +- **Layer:** the *already-captured* `http`/`agent` event stream. Adds + interpretation, not capture. +- **Contribution (two, by detectability):** + - **Regex/heuristic** (known payload markers, canary strings): 🟢 — a + `detectionPatterns` contribution (a **proposed** sibling of `lootPatterns`, or + a `kind` field on it: `lootPatterns` classifies secrets, this classifies + *behaviours*). Emits an inferred detection, not a secret. + - **Semantic** (paraphrased injection, multi-turn goal manipulation): 🔴 — a + `monitors` module that watches the event stream and emits inferred detections. + **`monitors` is reserved, not yet implemented** (`types.ts` L141) — this is + its first real use case. +- **Tier & why:** regex is data (🟢); a semantic monitor runs code and reads the + evidence stream, which is exactly what the 🔴 trust gate exists for. +- **Event contract:** an **inferred `detection` suggestion** (§3) — + confidence-scored, attributable to the detector, **visually distinct and + operator-promotable** (dashed vs solid, one-click promote). Never mutates the + source event; never authoritative. +- **Local cost:** regex per-event (cheap); a semantic monitor may call an LLM + (latency/cost the operator opts into) — runs in the isolated utilityProcess so + it can't block capture. +- **Install:** 🟢 pattern pack auto-loads; 🔴 monitor requires the trust gate. +- **Test seam:** regex over labelled event fixtures (real / not); for the monitor, + a pure `classify(event) → detection|null` behind the LLM call. +- **AI-era instance:** **Gap 4** — prompt-injection / tool-call-hijack labels. + +### 6. Tool provider 🔴 — expose RedLog to an AI agent + +- **Layer:** RedLog itself — the plugin makes RedLog an MCP server the operator's + agent can call. +- **Contribution:** `mcpTools` (module path). Runs in the isolated utilityProcess, + reaches RedLog only through the capability-scoped `ctx` RPC + (`read:events`/`write:events`/`read:findings`/`read:config`/`net:outbound`, + `types.ts` L119). +- **Tier & why:** 🔴 — it ships and runs code; gated by content-hash-pinned + consent (`trust.json`); changing code or requested capabilities revokes trust. +- **Event contract:** tools the agent invokes; any events appended are attributed + to the plugin's operator (`write:events`). +- **Local cost:** RPC per call; isolated so it can't stall the host. +- **Install:** trust gate + point the agent at RedLog's MCP endpoint. +- **Test seam:** each tool is a pure function of its args + a mocked `ctx`. +- **AI-era instance:** RedLog-as-tool inside an operator's autonomous pentest agent. + +### 7. Exporter 🔴† — transform the store into a deliverable + +- **Layer:** the whole evidence store. +- **Contribution:** `exporters` — **reserved, not yet implemented** (`types.ts` + L140). +- **Tier & why:** 🔴 — reads the full store (including sensitive bodies) and + produces an external artifact; gated. +- **Event contract:** appends a chained audit event for the export action. +- **Instance:** a scope-sanitized client-deliverable exporter (see + `SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B) — its scope planner is the natural first + Exporter. + +--- + +## API gaps this framework surfaces (so future work is explicit) + +| # | Gap | Role blocked | Fix | +|---|---|---|---| +| 1 | `monitors` reserved, unimplemented | Labeller (semantic) | implement 🔴 `monitors`: isolated read of event stream + append of inferred detections | +| 2 | no `detectionPatterns` contribution | Labeller (regex) | add sibling to `lootPatterns`, or a `kind: 'secret'\|'detection'` field | +| 3 | inferred `detection` event type absent | Labeller output | define the §3 suggestion shape (confidence, detector attribution, promote) + its dashed-vs-solid rendering | +| 4 | user-plugin `tailers` bundled-only | Tailer | land the v0.8.3+ isolation so user tailers can be trust-gated, not blocked | +| 5 | no MCP-tee pattern/example | Tee | ship `examples/plugins/mcp-tee` + a stdio/HTTP-MCP shim | +| 6 | `exporters` reserved, unimplemented | Exporter | implement 🔴 `exporters`, first instance = scope-sanitized bundle | + +**Build order (from `SPEC-AI-ERA-PLUGINS.md`):** Parser + Tailer packs first (zero +core change, roles fully supported today), then Tee (shim + example), then +Labeller (grows the core API — sequence with the §3 promotion UI). Exporter tracks +the lifecycle spec. + +## Cross-references + +- Mechanics + trust gate: `docs/plugin-development.md` +- The four AI-era gaps in detail: `SPEC-AI-ERA-PLUGINS.md` +- Capture taxonomy + build/plugin line: `CAPTURE-SOURCE-TAXONOMY.md` +- Facts vs inferred suggestions: `DESIGN-PRINCIPLES.md` §3 +- Trust-tier rationale: `redlog-plugin-system` memory diff --git a/docs/README.md b/docs/README.md index 64ef4bc4..119ac500 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links - **[Timeline UX deep-dive 2026-08-10](UX-TIMELINE-2026-08.md)** — the F2 deep-dive: why the timeline feels unintuitive (invisible, overloaded, context-dependent gestures), what it is *not* (lanes/data are fine), and the T1–T6 simplification plan. - **[UX backlog — ticket specs](UX-BACKLOG-TICKETS.md)** — every finding (F1–F7, T1–T6) broken out into an independently implementable ticket: problem, proposed solution, acceptance criteria, pure test seam, effort, priority order. - **[Timeline interaction redesign draft](DESIGN-TIMELINE-INTERACTION.md)** — concrete UI proposal with ASCII wireframes for the legend (T1), the wheel-mode decision matrix (T2), and the active-modes row (T3), plus the build order. +- **[Settings IA reorg proposal (F3 structural)](DESIGN-SETTINGS-IA.md)** — proposal (for ratification) to reorganise Settings by necessity tier (Capture·Scope·Evidence·OPSEC·Advanced); two options, migration, acceptance, 4 open questions. - **[Timeline axis spec (Phase C)](SPEC-TIMELINE-AXIS.md)** — executable spec for the flagship refactor: re-axis the timeline to target/phase (per DESIGN-PRINCIPLES §8/§9), the safe toggle-behind incremental strategy, the seams to build on the Phase B foundation, acceptance criteria, regression guards, and three open questions. - **[io_ref sidecar spec](SPEC-IO-SIDECAR.md)** — full (untruncated) request/response bodies in a prunable on-disk sidecar with only the sha256 in the chain — the last unshipped piece of the v0.10.0 I/O-visibility work. Capture/read/export/retention/verify design, acceptance criteria, and build order. - **[Dev requirements — capture onboarding](DEV-REQUIREMENTS-capture-onboarding.md)** — spec + acceptance criteria for the Capture Readiness onboarding, and the red→green→integrate→cover TDD process every UX change should copy. @@ -67,6 +68,22 @@ Drift-signals that make the log honest: `recording_paused` / `recording_resumed` - **[Plugin development](plugin-development.md)** — build a plugin: the manifest format, 🟢 declarative contributions (`lootPatterns`, `redaction`, `commandTags` for stamping MITRE/custom fields onto shell events, `targetExtractors`, `eventTypes`, `capture` integrations) and 🔴 privileged MCP tools, the capability-scoped `ctx` API, and the content-hash-pinned trust gate. RedLog ships with **no** `commandTags` — install per shop or let your SIEM tag downstream. Hot-reload via Settings ▸ Plugins ▸ Reload; drop plugin dirs into `~/.redlog/plugins/` (Open folder button). +## Design frameworks (capture & extension) + +The reusable decompositions behind capture and plugin design — read these before adding a capture source, a plugin, a detector, or a data-management policy. Each closes a subsystem into a small, **provably-complete** set of roles/tiers with a per-item template, so new work is "pick a row, fill the template" instead of a bespoke design each time. + +- **[The decomposition method](DECOMPOSITION-METHOD.md)** — *start here.* The six-step recipe these frameworks share (name the open subsystem → mechanism axis → tier axis → closed role set with an exhaustiveness proof → per-role template → named API gaps), the invariants of a *good* decomposition, and the map of which RedLog subsystems are done, next, or candidates. Read this to decompose a **new** subsystem the same way. +- **[Capture source taxonomy](CAPTURE-SOURCE-TAXONOMY.md)** — the built-in-vs-plugin line drawn once: the 15 substrate capture items (the *medium*: shell / HTTP / agent tool-call / screen / …), the AI-era tool surface mapped onto them, local-execution cost per item, and the **four-gate plugin lifecycle** (present → enabled → trusted → hook-installed). Start here to decide "does this ship built-in or as a plugin." +- **[Plugin roles](PLUGIN-ROLES.md)** — every plugin is one of **7 roles** (Recognizer, Parser, Tailer, Tee, Labeller, Tool provider, Exporter), cut along "what layer does it act on × does it run code inside RedLog." Proven exhaustive over the contribution API, each with a development template and the known API gaps. The lookup table for "what do I build." +- **[AI-era plugin blueprint](SPEC-AI-ERA-PLUGINS.md)** — the four gaps the 2026 tool surface opens (structured scan output, C2 logs, third-party MCP tee, injection labels), each specced as a role instance with contribution, tier, event contract, and build order. +- **[Detector roles](DETECTOR-ROLES.md)** — the same method applied to the inference layer: every detector is one of **4 roles** (Extractor, Classifier, Monitor, Correlator) emitting one uniform §3 suggestion. The built-in detectors and the plugin Recognizer/Labeller roles are one taxonomy seen from two sides (core vs contributed). +- **[Scope-aware lifecycle spec](SPEC-SCOPE-AWARE-LIFECYCLE.md)** — sanitize + retention + rotation unified: the chain stays a small immutable spine; heavy artifacts are refcounted, scope-prioritized, lifecycle-managed (hot → compressed → pruned). The per-capture-type coverage matrix states honestly where scope applies and where it can't. +- **[Control-plane faces](CONTROL-PLANE-FACES.md)** — the method's *one-role-plus-catalog* variant applied to §7: the canonical evidence-relevant op catalog, the single **Adapter** contract every face fills (transport + auth + op-subset, never logic), and the REST/MCP/IPC/CLI/shell/Codex faces classified — so "add an integration surface" is filling a contract and drift is detectable. +- **[Delivery targets](DELIVERY-TARGETS.md)** — how evidence *leaves* RedLog: every path is a **Snapshot** (complete, signed, verifiable artifact) or a **Stream** (real-time per-event feed), declaring a sanitize profile by audience and a shape-appropriate integrity contract — so no delivery path can leak un-sanitized or unverifiable evidence. Owns the delivery ops split out of the §7 control catalog. +- **[Timeline elements](TIMELINE-ELEMENTS.md)** — the method as a *structural lens* on the timeline: **7 encoding channels × 4 interaction modes**, each declaring §3 fact-vs-suggestion, restating the UX-TIMELINE complexity as channel/mode overloading. Defers to `SPEC-TIMELINE-AXIS.md` / `UX-TIMELINE-2026-08.md` / §8 for the design itself. +- **[Event-type vocabulary](EVENT-TYPE-VOCABULARY.md)** — the seam that ties capture, timeline, and §3 together: every `agent_type` classified by **origin** (authored / captured / agent / derived / system / plugin) and **authority**, plus the rule that a capture plugin emitting a new event kind **must** pair with an `eventTypes` contribution to claim its timeline identity (lane/colour/glyph) — or its evidence lands illegible in the "other" lane. +- **[Off-chain content stores](OFF-CHAIN-CONTENT-STORES.md)** — the heavy content kept *off* the chain (`.cast`, io_ref bodies, screenshots) is one pattern in two shapes — **Blob** (content-addressed, dedup, whole/ranged) and **Stream** (time-indexed, sliced) — under one invariant (digest on-chain, bytes off-chain, pruned≠tampered) and one lifecycle engine, replacing today's three ad-hoc stores. + ## Agent skills (drop-in) - **[redlog-pentest](skills/redlog-pentest.md)** — Claude Code skill: hooks record, MCP operates; session start (`whoami` / `status` / `scope`), real-time findings, loot scanning, end-of-session `chain_anchor_now`. Copy to `~/.claude/skills/`. diff --git a/docs/SPEC-AI-ERA-PLUGINS.md b/docs/SPEC-AI-ERA-PLUGINS.md new file mode 100644 index 00000000..3a33b83f --- /dev/null +++ b/docs/SPEC-AI-ERA-PLUGINS.md @@ -0,0 +1,139 @@ +# Spec — AI-Era Capture Gaps as Plugins (blueprint) + +Written 2026-08-13. Companion to `CAPTURE-SOURCE-TAXONOMY.md`, which concluded: +the 15 substrate capture items ship built-in; everything tool-specific is the +long tail → plugin. This blueprint takes the four AI-era gaps that survey named +and maps each onto RedLog's existing plugin contribution surface +(`src/core/plugins/types.ts`), stating for each: **which contribution type, +which trust tier, whether it is expressible today or needs an API extension, +the local-execution cost, and the install/config path.** + +## Contribution surface (what exists to build on) + +🟢 declarative (trust-free, data or out-of-process, can't touch the chain): +`lootPatterns`, `redaction`, `commandTags`, `targetExtractors`, `eventTypes`, +`capture` (hook that POSTs to the local API), `tailers` (long-running file/stream +follow; **user-plugin tailers are trust-gated**, bundled are free — `types.ts` +L144). + +🔴 privileged (isolated utilityProcess, capability-scoped `ctx`, hash-pinned +consent): `mcpTools`; **`monitors` and `exporters` are declared but reserved — +not yet implemented.** + +## Gap 1 — Structured scan output (nmap XML, nuclei JSON, httpx, …) + +- **Today the shell substrate captures the *command* and raw stdout; the + structured artifact (XML/JSON) is not parsed into typed findings.** +- **Contribution:** `capture` (parse-on-complete) **+** `eventTypes` (timeline + identity for `scan_result`) **+** `targetExtractors` (hosts/ports → targets) + **+** optional `lootPatterns` (secrets in banners). All 🟢. +- **Interface:** a `capture` script wraps or post-processes the tool + (`nmap -oX -`, `nuclei -json`), reads the structured output, and POSTs one + event per host/finding with `agent_type: 'scanner'`, `subtype: 'scan_result'`, + and the parsed fields (port, service, template-id, severity). Severity/loot are + **inferred suggestions** (§3), never authoritative. +- **Expressible today:** ✅ no API change. +- **Local cost:** parse runs once at scan completion — cheap; output files can be + large but only the parsed events enter the chain (raw file optionally io-sidecar'd). +- **Install:** `installMethod: manual` (wrap the tool invocation) or a `tailers` + follow of the scan-output directory. Ships as a bundled `scan-parsers` pack. + +## Gap 2 — C2 framework logs (Sliver, Cobalt Strike, Mythic) + +- **C2 beacons are an out-of-band channel RedLog isn't sitting on** — the + substrate items don't see them. Beacon check-ins, task results, and operator + commands live in the C2 server's own log/DB. +- **Contribution:** `tailers` — follow the C2 framework's log file (Sliver's + JSON log, CS Aggressor/beacon logs), emit `agent_type: 'scanner'` / + `subtype: 'c2_checkin'` / `pivot` events per line. 🟢, but **bundled-only + today** — user-plugin tailers need the isolation work slated for v0.8.3+ + (`types.ts` L144); a bundled `c2-tailers` pack ships now, user-authored C2 + tailers wait on that. +- **Interface:** the `TailerLike` adapter (`tailer-registry.ts`) — the plugin + supplies a module that tails a path and yields parsed events; the host wires it + to `tailer-host`. Config the log path per C2 tool. +- **Expressible today:** ✅ via `tailers`; the only friction is the user-plugin + trust gate (by design). +- **Local cost:** file-follow (inotify/poll) — low, continuous; the exposure + window is the standing tail, matching the §2 "ambient = opt-in" rule. +- **Install:** point the tailer at the C2 log path; `manualSteps` document where + each framework writes its log. Ships as a bundled `c2-tailers` pack. + +## Gap 3 — Third-party MCP tool-calls (HexStrike, PentestMCP, …) + +- **Nuance:** when the operator's AI client has a native hook (Claude Code's + `PostToolUse`), MCP tool-calls are **already captured** as `agent` events — + *no gap*. The gap is only clients **without** a hook. +- **Contribution:** a **MCP tee/proxy** the client points at, which forwards to + the real MCP server *and* POSTs each `tool_call`/`tool_result` to RedLog — a 🟢 + `capture` (out-of-process, unprivileged). Emits `agent_type: 'agent'` / + `subtype: 'tool_call'` with the tool name, input, and output digest. +- **Interface gap:** no new contribution *type*, but there is **no documented + MCP-proxy pattern or helper today** — this needs a first-class example + (`examples/plugins/mcp-tee`) and a thin stdio/HTTP-MCP shim the operator drops + between client and server. The shim is the deliverable, not a new API. +- **Distinguish from `mcpTools` (🔴):** `mcpTools` = RedLog *exposes tools to* + an agent (RedLog is the server). This gap is the inverse — capturing calls the + agent makes to *someone else's* MCP server (RedLog is a passive tee). Keep them + named apart in docs or they conflate. +- **Expressible today:** ⚠️ mostly — needs the proxy shim + example, no core change. +- **Local cost:** one extra hop per tool-call through the tee — negligible for + interactive use; the tee is out-of-process so it can't stall RedLog. +- **Install:** `manual` — reconfigure the AI client's MCP server URL/command to + point at the tee. `manualSteps` per client. + +## Gap 4 — Injection / tool-call-hijack semantic labels + +- **When the operator tests an AI *target*** (OWASP LLM/Agentic Top 10, MITRE + ATLAS), the exchange is already captured as `http` (API calls) + `agent` + (attacker transcript). What's missing is the **labelling** — "this request was + a prompt-injection attempt," "this response shows tool-call hijack." +- **Two-part answer, by detectability:** + - **Regex/heuristic subset** (known payload markers, canary strings): 🟢 a + pattern contribution in the `lootPatterns` mould, but emitting a + **`detection` suggestion** (inferred, confidence-scored, operator-promotable + per §3), not a loot/secret. Today `lootPatterns` only classifies secrets — + this needs a sibling `detectionPatterns` contribution or a `kind` field on + the existing one. + - **Semantic detection** (paraphrased injection, multi-turn goal manipulation): + a 🔴 **`monitor`** — watches the `http`/`agent` event stream and emits + inferred `detection` events. **`monitors` is reserved but not implemented** + (`types.ts` L141) — this gap is its first real use case. +- **Interface gap:** ⛔ needs (a) `monitors` implemented as a 🔴 contribution + (isolated, capability-scoped read of the event stream + append of inferred + detections), and (b) an inferred-`detection` event shape that is visually + distinct and operator-promotable (§3 — dashed vs solid, one-click promote). +- **Local cost:** regex subset is per-event and cheap; a semantic monitor may + call an LLM (cost/latency the operator opts into) — runs in the isolated + utilityProcess so it can't block capture. +- **Install:** 🟢 pattern pack loads automatically; 🔴 monitor requires the trust + gate (it runs code and reads the evidence stream — exactly what the gate is for). + +## Summary — build order + +| Gap | Contribution | Tier | Ready today? | Blocker | +|---|---|---|---|---| +| 1 Structured scan | `capture`+`eventTypes`+`targetExtractors` | 🟢 | ✅ | none — ship `scan-parsers` pack | +| 2 C2 logs | `tailers` | 🟢 (gated) | ✅ | none — ship `c2-tailers` pack | +| 3 3rd-party MCP | `capture` (MCP tee) | 🟢 | ⚠️ | needs proxy shim + `examples/plugins/mcp-tee` | +| 4 Injection labels | `detectionPatterns` (🟢) + `monitors` (🔴) | both | ⛔ | implement `monitors`; add inferred-`detection` event shape | + +**Sequence:** Gaps 1 & 2 first (pure packs, zero core change, immediate value). +Gap 3 next (a shim + example). Gap 4 last — it is the only one that grows the +core plugin API (`monitors` + the inferred-detection event type), and it should +land alongside the §3 promotion UI so a detection suggestion has somewhere to go. + +## Non-goals + +- No gap turns into a built-in capture source — the substrate already captures + the medium (shell/HTTP/agent); these are *parsers, tailers, tees, and + labellers* over captured evidence, correctly plugin-scoped. +- `monitors` must not become a back door to unlabelled authoritative assertions — + everything a monitor emits is an inferred suggestion (§3), never fact. + +## Cross-references + +- Capture taxonomy + build/plugin line: `CAPTURE-SOURCE-TAXONOMY.md` +- Trust tiers + contribution mechanics: `docs/plugin-development.md`, + `redlog-plugin-system` memory +- Two-tier facts vs suggestions: `DESIGN-PRINCIPLES.md` §3 diff --git a/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md b/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md new file mode 100644 index 00000000..5f8b2d9f --- /dev/null +++ b/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md @@ -0,0 +1,192 @@ +# Spec — Scope-Aware Sanitize + Retention + Artifact Rotation + +Written 2026-08-13. Unifies three previously-separate storage concerns into one +mechanism: **the hash chain is a small, immutable WORM spine; every heavy capture +artifact is a refcounted, scope-prioritized, lifecycle-managed store.** It extends +`SPEC-IO-SIDECAR.md` (the prunable io sidecar) and `redaction-design.md` (layer-4 +export sanitize) with (a) a scope-aware verdict on both sanitize and retention, +(b) a three-stage artifact lifecycle (hot → warm/compressed → pruned) with +size *and* age triggers, and (c) a per-capture-type coverage matrix that states +honestly where scope does and does not apply. + +## Why (the problem) + +1. **Capture broad, sanitize/prune later** (DESIGN-PRINCIPLES §2) makes broad + capture safe *only if* the detect/sanitize/prune machinery is strong. Today + sanitize is manual per-event and retention is time-only per artifact type. +2. **Client deliverables must not leak out-of-scope / adjacent-client content**, + but the *fact* a target was touched (`scope_violation`) must survive. Sanitize + must be able to key on scope, not just on hand-picked fields. +3. **Disk pressure is real** for long engagements — but the heavy artifacts + (`.cast`, screenshots) are exactly the ones a host-based scope predicate + cannot classify. Retention needs a size ceiling and a compression tier, not + just an age cutoff, and rotation order needs a priority signal. + +## Core invariant (unchanged) + +**The chain is sacred and small.** Only `sha256` digests, previews, and event +metadata (who/when/which host/verdict) enter the hash chain; they are **never** +rotated, compressed away, or pruned. Everything below acts on *artifact bytes* +(`io/*.bin`, `*.cast`, screenshot `*.jpg`) — the prunable payload — and every +lifecycle action **appends** a chained `system.*` audit event, so a rotated or +sanitized store is itself tamper-evident (a missing artifact reads as *pruned*, +never *tampered*; a stripped bundle is detectable). This is the Schneier–Kelsey / +WORM split the prior art converges on (see Prior art). + +## Part A — Per-capture-type coverage matrix + +Scope is a **host/target predicate**. Not every `agent_type` carries a host, so +scope-aware handling is **not** uniform. Three tiers: + +| Tier | agent_types | Has target? | io body? | Scope-aware sanitize | Scope-aware retention | +|---|---|---|---|---|---| +| **1 — target-clean** | `http`, `http_navigation`, `scanner`, `dns`, `browser`, `pivot` | yes (`dest_host`/`url`/`route`) | yes (Tier-1 only) | ✅ verdict via `ScopeMonitor.checkTarget` | ✅ pin=in-scope, GC=out-of-scope first | +| **2 — target-partial** | `shell`/`terminal`, `file_transfer`, `cleanup`, `agent`, `loot`, `marker` | sometimes | no | ⚠️ only when target present; else `unknown` bucket | ⚠️ `unknown` → in-scope window | +| **3 — no host** | `screenshot`, `clipboard`, `process`, `.cast` recordings | no | no | ❌ scope N/A — time/size retention only | ❌ scope N/A — time/size retention only | + +**Consequence stated plainly:** scope-aware retention reclaims Tier-1 io bodies +only, which are the *small* artifacts after dedup + the 2 MB ceiling. The disk +hogs (`.cast`, screenshots) are Tier 3 and must be handled by Part C's +size/compression rotation, **not** by scope. Do not let the UI imply "scope-aware +retention on = disk handled." + +### The `unknown` fallback (Tier 2) — asymmetric by design + +Scope verdict is an *inference* (DESIGN-PRINCIPLES §3), so an absent/ambiguous +target must not silently drive a destructive default. The safe default differs +between the two actions because their failure modes differ: + +- **Sanitize** (removes content from a *deliverable copy* — recoverable): an + `unknown` event is **neither auto-stripped nor auto-passed**; it surfaces in the + dry-run preview flagged for operator decision (default unchecked). Failure = + over/under-redacting a reviewable copy. +- **Retention** (deletes *local bytes* — irreversible): an `unknown` event gets + the **in-scope (longest) window**. Never silently expire unclassified evidence. + Failure = permanent loss, so bias to keep. + +## Part B — Scope-aware sanitize + +Extends layer-4 export sanitize (`src/core/sanitize.ts`). Reuses the existing +`sanitize()` machinery (per-event, `dryRun`, chained `system.sanitized`, +tamper-detectable) — adds a **scope planner** in front of it. + +1. **Planner** walks candidate events; for each, resolves its target + (`target_id`/`dest_host`/`url`) and calls `ScopeMonitor.checkTarget`. + Verdict ∈ {`in_scope`, `out_of_scope`, `excluded`, `unknown`}. +2. **Plan** = every `out_of_scope`/`excluded` event's body + sensitive fields + proposed for sanitization; `unknown` flagged; `in_scope` untouched. This is a + `dryRun` result — no writes yet. +3. **Sidecar coverage (critical):** the plan must include the event's io_ref + sidecar body, not just inline event fields. A Tier-1 out-of-scope response + body lives in `io/.bin`; sanitize writes a sanitized replacement body and + the bundle serves that (per `SPEC-IO-SIDECAR.md` "Export/retention"). Missing + this leaks out-of-scope bytes through the side door. +4. **Operator confirm gate**, then `sanitize --confirm` runs, appending one + `system.sanitized` per event with `reason: "scope:"`. +5. **Export profiles** (§9 progressive disclosure): `internal` (full) vs + `client-deliverable` (scope-sanitized). Default deliverable profile runs the + scope planner; full content is the advanced opt-out. + +## Part C — Artifact rotation (the lifecycle) + +RedLog "log rotate" ≠ syslog overwrite. It is a three-stage lifecycle over the +artifact stores, with **age *and* size triggers** (whichever hits first — +logrotate's `daily` vs `size`). + +``` +HOT ──(age > warmDays OR store > sizeCap)──▶ WARM ──(age > pruneDays OR still > sizeCap)──▶ PRUNED +uncompressed, zstd in place, bytes deleted, +instant read read = decompress, sha256 + row kept, + verify = re-hash original system.*_pruned appended +``` + +- **Warm (compress) is pure win and must exist before prune.** io bodies and + `.cast` are text-shaped (JSON/HTML/timing) → ~5–10×. Compression is reversible: + keep the **original** `sha256` (not a compressed-hash) so `redlog-verify` + decompresses and re-hashes to confirm bytes-on-disk match the attested digest. +- **Prune** reuses `retention.ts`; extend it from age-only to age-or-size, and + add an `io/` keep-window (today's config has cast/screenshot/agentTranscript + windows but **no io window** — see gap G1). + +### Refcount-gated deletion (restic/borg lesson) + +`io/.bin` is content-addressed + deduped, so a body may be referenced by +many events. **A body is prunable only when *every* referencing event is past its +window.** Deletion is a mark-and-sweep GC over event→sha refs, not a per-event +`rm`. Getting this wrong either leaks disk (never delete) or deletes a body still +cited by live in-scope evidence. (borg's `prune`-doesn't-`compact` trap: dropping +the reference must actually reclaim the bytes, in one attributable step.) + +### Scope as pin (IPFS lesson) — where A, B, C merge + +Rotation **order** under size pressure is driven by a pin score: + +- **Pinned (evicted last):** `in_scope` bodies; anything referenced by a `loot` or + `marker` event; anything an operator explicitly pinned. +- **Unpinned (evicted first):** `out_of_scope` / `excluded` / `unknown`, unmarked. + +So when `io/` exceeds its cap, GC compresses then prunes out-of-scope/unmarked +bodies first and keeps in-scope evidence longest — the single mechanism that +answers both "how does broad capture not fill the disk" and "where does scope +attach": scope attaches to **rotation priority**, never to capture. + +## Prior art + +- **Schneier–Kelsey hash-chain audit logs / WORM** — retention acts on content, + never on the tamper-evident chain. ([finqub], [emergentmind]) +- **Teleport session recording** — audit events and heavy recordings split across + backends; recordings run an explicit S3 lifecycle, are **not** auto-deleted, + default 1-year retention; rotated keys are decrypt-only. ([teleport-recording], + [teleport-backends]) +- **restic vs borg** — generational retention (keep N daily/weekly/monthly), and + the reclaim gotcha: borg `prune` drops refs but needs `compact`; restic + `forget --prune` repacks in one step. Deletion must actually free space. + ([restic-borg]) +- **IPFS pin + GC** — content-addressed store GCs unpinned blocks; pinning + protects. Maps to scope/marker = pin. ([ipfs-gc]) +- **SIEM hot/warm/cold + WORM** — tier down (compress) before delete; ~85–90% + cost cut; PCI-DSS 10.7: 12-month retention, recent 3 months immediately + available. ([logsentinel], [scality-tiers]) + +## Acceptance criteria + +- **A1** Export `client-deliverable` profile: a Tier-1 out-of-scope response body + (event field *and* io sidecar) is sanitized; the `scope_violation` event and + the touched host remain in the bundle. `in_scope` bodies are untouched. +- **A2** `unknown`-target events are never auto-sanitized; they appear in the + dry-run preview flagged, default unchecked. +- **A3** Retention never expires an `unknown`-scope artifact before the in-scope + window; out-of-scope artifacts expire on the short window. +- **A4** Warm stage: a compressed io body is still retrievable via `io:read`, and + `redlog-verify.py` confirms the decompressed bytes match the chained (original) + `sha256`. +- **A5** Size trigger: with `io/` over its cap, GC compresses then prunes + unpinned (out-of-scope/unmarked) bodies first; a body referenced by any event + still inside its window is never deleted (refcount-gated). +- **A6** Every rotation/sanitize action appends a chained `system.*` event + (`io_pruned`/`cast_pruned`/`screenshot_pruned`/`sanitized`); a pruned artifact + verifies as *pruned*, a sanitized one as *sanitized*, never as *tampered*. +- **A7** Tier-3 artifacts (`.cast`, screenshot, `clipboard`, `process`) are + governed by time/size retention only; no scope verdict is computed or implied + for them in the UI. + +## Gaps to close (implementation order) + +- **G1** Wire an `io` keep-window + size cap into `config.ts` retention block + (today: cast/screenshot/agentTranscript only). Prerequisite for Part C. +- **G2** Extend `retention.ts` from age-only to age-or-size, add the warm + (compress) stage, and make deletion refcount-gated over event→sha refs. +- **G3** Add the scope planner + `unknown` bucket in front of `sanitize.ts`, with + sidecar-body coverage and export profiles. +- **G4** Compute the pin score (scope verdict + marker/loot reference) as the + rotation eviction key. + +## Non-goals + +- **Not** scope-gated *capture*. Capture stays broad (§2); scope only drives + sanitize, retention priority, and export — never a capture blind spot, which + would also disable `scope_violation` recording. +- **Not** rotating, compressing, or pruning the hash chain, event rows, previews, + or `sha256` digests. Only artifact bytes move through the lifecycle. +- **Not** scope-slicing a single `.cast` or screenshot by host (Tier 3 is + time/size only). Visual/stream artifacts are out of scope's reach by construction. diff --git a/docs/TIMELINE-ELEMENTS.md b/docs/TIMELINE-ELEMENTS.md new file mode 100644 index 00000000..718b4053 --- /dev/null +++ b/docs/TIMELINE-ELEMENTS.md @@ -0,0 +1,120 @@ +# Timeline Elements — the reconstruction surface, decomposed + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the timeline as a +**structural lens**, complementing (not replacing) the design docs that own the +timeline: `SPEC-TIMELINE-AXIS.md` (the axis refactor), `UX-TIMELINE-2026-08.md` +(the UX diagnosis + T1–T6), and `DESIGN-PRINCIPLES.md` §8 (timeline = event map, +transcript = I/O reader) / §9 (necessity). Those are the source of truth for +*what to build*; this doc adds the closed **element taxonomy** — so the timeline's +complexity has a structural name (channel/mode overloading) and every element +declares whether it shows a fact or a suggestion. + +## The principle everything derives from + +The timeline is a **visual encoding surface**: it maps data dimensions to visual +variables and exposes interactions over them. Its felt complexity (UX-TIMELINE's +"invisible, overloaded, context-dependent") is **channel/mode overloading** — one +visual channel carrying two data dimensions, or one gesture bound to two actions. +The decomposition's payoff: **one channel = one data dimension; one mode = one +action; every element declares its §3 authority.** When that holds, the surface is +legible by construction. + +> **Implementation status varies by branch.** This is a *structural* taxonomy, not +> a shipped-state guarantee. Some elements are live on `feat/ux-design-and-tickets` +> (lane axis, phase ribbon, wheel/modes/keys resolvers), some only have their +> underlying data on `main` with the visual still proposed (instance-ordinal +> badges, shell bracketed-cast preview). Rows below tag **[shipped]** / **[data +> only]** / **[proposed]** where sessions have confirmed; confirm against git +> before relying on any as done. + +## Two families (mechanism axis) + +``` +Does it map a DATA dimension → a visual variable? → ENCODING CHANNEL +Does it let the operator NAVIGATE or MUTATE? → INTERACTION MODE +``` + +## The authority axis (§3, applies to every element) + +Every element renders either a **fact** (operator marker, primary capture) or an +**inferred suggestion** (ambiguous target, inferred phase) — and the two must be +**visually distinct** (solid vs dashed/ghost) with one-click promotion. This is +already the phase-ribbon design (solid markers vs dashed inference, +`SPEC-TIMELINE-AXIS.md` §11); the taxonomy makes it a rule for *all* elements. + +## Encoding channels (closed set) + +Visual variables are a bounded set (Bertin: position, colour, shape, size/density, +plus the temporal band, and composites of these — e.g. an instance ordinal = +colour + text-value) — so this table is provably exhaustive over what the surface +*can* encode. + +| Channel | Data dimension | Authority | Seam / source | +|---|---|---|---| +| **Position-X** | time (the one fixed anchor) | fact (wall-clock, but see clock-anomaly) | time scale | +| **Lane (Y-group)** | organizing axis — `source` **or** `target`/phase (§8) | fact | `lanesForAxis(axis, events, …)` (implemented: header ⊞ toggle) | +| **Colour** | source-type (kept as encoding even when lane = target) | fact | event `agent_type` | +| **Glyph / shape** | event kind | fact | event registry | +| **Instance ordinal** | which concurrent instance — `instanceOf(event)`: shell = `terminal_id` (ext-shell fallback `pid`), agent = `session_id`; HTTP/scanner `flow_id` **excluded** (per-request, not a persistent instance) | fact (recorded id) | **[data only]** `session_id`/`terminal_id` exist in event data on `main`; the **[proposed]** visual = coloured ordinal badge (dot corner) + detail chip (`term #2`) + cluster-popover `#N`, shown only when a lane has >1 concurrent instance | +| **Density / cluster** | event count in a 14px time bucket | fact | `timelineCluster.ts` | +| **Phase ribbon (band)** | phase segments over time | **fact (solid marker) + suggestion (dashed inference)** | `phaseRibbon.ts` | + +**The §8 fix restated in channel terms:** originally **lane and colour were both +bound to source-type** — redundant, and the lane channel maxed out at 18 groups. +§8 re-binds **lane ← target/phase**, freeing colour to keep encoding source without +the 18-lane overload. One channel, one dimension. + +## Interaction modes (closed set) + +Interaction reduces to a bounded set — navigate, filter, select, mutate — so this +too is exhaustive. + +Seams below are the **implemented** pure resolvers on the PR #8 branch (per the +timeline-axis session), not spec placeholders. + +| Mode | Action | State visibility | Implemented seam | +|---|---|---|---| +| **Navigate** | pan / zoom / scroll the time axis | — (the overloaded one) | `lib/timelineWheel.ts` (`wheelMode` decision matrix) + `lib/timelineKeys.ts` (keyboard resolver) | +| **Filter** | show/hide/solo lanes; source-type demoted to a filter (§8) | T3 active-modes row | `lib/timelineModes.ts` (`activeModes` — which non-default modes are hiding events) | +| **Select / inline-preview → drill-down** | three-part (§8 refined): **axis = glyph** presence **[shipped]** (v0.9.2 `eventTitle` role prefixes `❯ ◂ ⚙ ↩ 💭`); **select = inline short I/O preview** — agent tier-2 **[shipped]** (v0.9.2 `AgentTurnDetail`, `CollapsibleStream startOpen`, *text* body), shell bracketed-cast stdout preview **[proposed]** (`CommandEndDetail` shows stdout/stderr today but not the bracketed-cast visual); **full narrative = transcript** **[proposed]** ("Open transcript" button, like today's "Open sidecar") | selection highlight | detail inline preview + transcript view | +| **Mutate / promote** | inferred → authoritative (dashed phase → solid marker, §3) | the "suggest" affordance | phase-ribbon promote action | + +## Completeness + +- **Channels:** the surface can encode only via position, colour, shape, density, + a temporal band, or a composite of these (instance ordinal = colour+text-value) + — a closed set. Every current visual maps to one row; nothing falls outside. +- **Modes:** an operator can only navigate, filter, select, or mutate — a closed + set. Every current gesture maps to one row. +- **Authority:** every element is fact or suggestion (§3) — no third state. + +So the timeline is fully described by **7 channels × 4 modes × the fact/suggestion +axis**. A new timeline feature is one of these, or it is over-encoding (see gaps). + +## Gaps — the UX-TIMELINE problems, restated structurally + +| # | UX-TIMELINE problem | Structural name | Fix | +|---|---|---|---| +| G1 | "same gesture does different things by invisible state" (wheel) | **one mode bound to two actions** (pan/zoom/scroll on the same wheel, switched by hidden state — the `timelineWheel.ts` `wheelMode` matrix) | T2 — one gesture, one action; make the binding explicit | +| G2 | "every affordance invisible until `?`" | **modes have no state channel** — interaction state isn't encoded on the surface | T1 persistent legend + T3 active-modes row (give modes a visible channel) | +| G3 | "18-colour problem" | **lane + colour both bound to source-type** (redundant, lane overloaded) | §8 axis re-bind: lane ← target/phase, colour ← source | +| G4 | inference vs fact must not blur | **authority axis not uniform across elements** — only phase does solid/dashed today | extend solid-vs-dashed to every inferred element (ambiguous target, inferred tags) per §3 | +| G5 | timeline trying to be the I/O reader | **Select mode scope** — where does I/O content live | §8 refined (three-part): axis = glyph; select = *inline short* preview in the detail; *full* narrative stays in the transcript/exchange view — the map never becomes the full reader | + +## Why this lens pays + +- It gives the timeline's complexity a **structural diagnosis** (overloaded + channel/mode) instead of a list of symptoms — each T-ticket becomes "un-overload + channel X / mode Y." +- It makes "should this go on the timeline?" answerable: which channel or mode is + it? If none, it belongs in a paired surface (transcript/HUD/search, §8), not the + map. +- It carries §3 onto every visual element by contract, not by memory. + +## Cross-references + +- Owns the axis design: `SPEC-TIMELINE-AXIS.md` +- Owns the UX diagnosis + tickets: `UX-TIMELINE-2026-08.md`, `UX-BACKLOG-TICKETS.md` (T1–T6) +- Surface split (map vs reader vs HUD vs search): `DESIGN-PRINCIPLES.md` §8, §9 +- Fact vs inferred suggestion: `DESIGN-PRINCIPLES.md` §3 +- The method + its variants: `DECOMPOSITION-METHOD.md` diff --git a/docs/UX-BACKLOG-TICKETS.md b/docs/UX-BACKLOG-TICKETS.md index 958542b2..c61ad42d 100644 --- a/docs/UX-BACKLOG-TICKETS.md +++ b/docs/UX-BACKLOG-TICKETS.md @@ -50,6 +50,7 @@ The fast, high-impact pairs first, structural work after. | 10 | **T5** — extract timeline pure seams + first interaction tests | Makes every later timeline change safe | M–L | 🟡 | | 11 | **F7** — sidebar reorder affordance | Minor discoverability polish | S | ⚪ | | 12 | **T6** — lane virtualization | Structural; also closes T2 permanently (= ROADMAP V4) | L | ⚪ | +| 13 | **PL1** — plugin lifecycle: one card, Install≠Enable | Removes the two-tab split + the orphaned-hook footgun | M–L | ⚪ | Already shipped in v0.11.6: **F1 (partial)** — Capture Readiness onboarding checklist; **T5 first seam** — the Timeline keyboard resolver. @@ -269,6 +270,68 @@ checklist; **T5 first seam** — the Timeline keyboard resolver. --- +## PL-series — plugin & capture lifecycle + +### PL1 — Unify the plugin lifecycle into one card; disambiguate Install ≠ Enable ⚪ + +- **Persona:** P1 / P3. **Source:** `CAPTURE-SOURCE-TAXONOMY.md` §Part 4 (four + gates); this session's UI review of `Settings.tsx`. +- **Problem:** a plugin's lifecycle is **split across two tabs with overloaded + wording**, and one gate has a silent footgun: + - Enable/trust (gates 2–3) live in **Plugins tab** (`PluginsPanel`); capture-hook + install (gate 4) lives in **Capture tab → Hooks detected** (`HooksPanel`). A + plugin that contributes a capture hook appears in **both**, with no + cross-link — the operator can enable it and never realise its hook isn't wired + ("enabled but not recording" is invisible). + - The word **"Enable/Disable" is overloaded**: in Plugins it means gate 2 + (in-process, reversible, zero external footprint); the Hooks button labelled + `hookEnable`/`hookDisable` (`Settings.tsx` L878) actually means gate 4 — + **writing into `.zshrc` / `~/.claude/settings.json`**, external state that + outlives RedLog. + - **Disable ≠ Uninstall footgun:** `setPluginEnabled(false)` (`plugins/index.ts`) + only flips `state.json`; it does **not** remove the hook line. Disabling or + deleting a plugin **leaves orphaned hook lines** in the operator's shell/agent + config that keep firing whether or not RedLog is open. +- **Proposed (full refactor — optimise for final effect, not minimal diff):** + - **One lifecycle card per plugin** (in Plugins tab) rendering all four gates as + a single progression: `Present → Enabled → (Trusted, 🔴) → Hook installed`, + with the current state and the *one* next action surfaced. A contributed + capture hook shows its install state inline on the same card (no tab hop). + - **Two distinct verbs, never shared:** **Enable/Disable** for gate 2 only; + **Install/Uninstall** for gate 4 (the button that writes external config). + Rename the Hooks button accordingly and restyle it to read as a + system-mutating action (it edits files outside RedLog). + - **"Enabled but not wired" is a first-class, legible state** with a clear + "Install hook to start capturing" CTA — reuse the `captureReadiness` state + machine so this ties into onboarding (F1/F1-b). + - **Kill the orphan:** disabling/deleting a plugin that has installed hooks + prompts to uninstall them (or clearly flags them as still-installed with a + one-click cleanup). Hook uninstall is always its own explicit action. + - The Capture-tab Hooks list may remain as the *system-wide* hook inventory, but + plugin-contributed hooks deep-link to/from their plugin card so the two views + agree. +- **Acceptance:** + - A plugin with a capture hook shows enable-state **and** hook-install-state on + one card; the operator never needs both tabs to get it capturing. + - No control labelled "Enable" writes to `.zshrc`/`.claude/settings.json`; every + such write is labelled "Install" and visually marked as external. + - Disabling or deleting a plugin with an installed hook cannot silently leave an + orphaned hook line — the flow either removes it or surfaces it with a cleanup + action. + - "Enabled but hook not installed" renders a distinct state with a next-step CTA. + - i18n parity: all new keys in `en.json` and `zh-TW.json`, same edit. +- **Seam (house rule — pure function first):** `lib/pluginLifecycle.ts` — + `computePluginLifecycle(plugin, hooks, trust)` → a view model + `{ present, enabled, trusted, hooks: [{id, installed}], nextAction, orphanedHooks }` + merging `PluginView` (from `window.redlog.plugins`) with hook state (from + `window.redlog.hooks` / `capture-health`). Test `test/plugin-lifecycle.test.ts` + before the card renders/dispatches it; reuse `computeCaptureReadiness` for the + wired/active transitions. +- **Effort:** M–L. **Depends on:** none hard; composes with F1-b (guided install) + and the `captureReadiness` seam. + +--- + ## Cross-references - Personas + non-goals: `PRODUCT-POSITIONING.md` From a87aae7128e8cd0bb6667c29d455847fc3c8518e Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 01:34:33 +0800 Subject: [PATCH 23/47] docs: decomposition backlog + README index cleanup - Add DECOMPOSITION-BACKLOG.md: the ~30 gap rows from the nine framework docs, deduplicated into a prioritized backlog (two keystones, zero-core quick wins, unification + contribution-surface work). - README: bump the index version stamp v0.9.4 -> v0.11.7 (closes the AUDIT-2026-08-08 doc-drift item), index three previously-orphaned docs (CLOUD_SHARE_BUNDLE, RELEASE_CHECKLIST, windows-setup), and link the backlog under Design frameworks. Docs only. README also carries index lines other sessions asked to be listed (DESIGN-SYSTEM, DESIGN-TIMELINE-DISCOVERABILITY, UX-AUDIT-2026-08-13), whose docs land in a separate same-branch commit. Excludes all in-progress src/ and Timeline.tsx changes owned by other sessions. Co-Authored-By: Claude Opus 4.8 --- docs/DECOMPOSITION-BACKLOG.md | 134 ++++++++++++++++++++++++++++++++++ docs/README.md | 9 ++- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 docs/DECOMPOSITION-BACKLOG.md diff --git a/docs/DECOMPOSITION-BACKLOG.md b/docs/DECOMPOSITION-BACKLOG.md new file mode 100644 index 00000000..7373b168 --- /dev/null +++ b/docs/DECOMPOSITION-BACKLOG.md @@ -0,0 +1,134 @@ +# Decomposition Backlog — the gaps the framework suite surfaced + +Written 2026-08-13. Consolidates the "Gaps" tables scattered across the nine +decomposition docs (`DECOMPOSITION-METHOD.md` names them all) into one prioritized, +**deduplicated** backlog. The dedup matters: because the frameworks interlock, many +gaps are *the same underlying work seen from different docs* — so ~30 gap rows +collapse into a handful of keystones. Each item notes: what, which docs surfaced +it, code vs doc/example, what it unblocks / depends on, and whether it touches +another session's active territory (coordinate before editing). + +> **Status:** this is a planning doc. Nothing here is started. Items marked +> ⚠️ touch files other sessions are actively editing (`Timeline.tsx`, +> `Settings.tsx`, `retention.ts`, `plugins/types.ts`) — coordinate first. + +## Keystones (do first — each unblocks several other gaps) + +### K1 — The §3 inferred-`detection` primitive ⚠️ (plugins/types.ts, renderer) +The single highest-leverage item. Surfaced **four times**: `PLUGIN-ROLES` gap #3, +`DETECTOR-ROLES` gap #3, `EVENT-TYPE-VOCABULARY` gap #2, `TIMELINE-ELEMENTS` G4. +- Define a shared **inferred-`detection` event shape** — confidence, detector + attribution, one-click promote-to-marker. +- Add **`authority: 'fact' | 'inferred'`** to `EventTypeDef` (`event-registry.ts`). +- Wire the **solid-vs-dashed** rendering to `authority` uniformly across *every* + timeline element, not just phase. +- **Unblocks:** plugin Labeller role, semantic injection labels (K2/K3), timeline + honesty (G4), detector suggestions rendering as promotable. **Code.** + +### K2 — Off-chain content unification ⚠️ (retention.ts, new store module) +Surfaced across `OFF-CHAIN-CONTENT-STORES` (gaps 1–6) + `SPEC-SCOPE-AWARE-LIFECYCLE` +(G1–G2). The keystone inside it: **io_ref is spec-only, never implemented**. +- Implement the **io_ref Blob store** (the keystone — no runtime code exists today). +- Wire an **io keep-window + size cap** into `config.ts` (lifecycle G1). +- Collapse the **three parallel retention branches** (`terminal.castKeepDays` + + `screenshots.keepDays` + `agentTranscripts.keepDays`) into **one loop over a + `contentStore.` registry** (off-chain gap #2 / retention). +- Extract **one shared `isInsideDir()` traversal guard** (3 copies today) + one + shared "bytes-match-digest / pruned≠tampered" verify. +- Route **agent-transcripts** through the Stream adapter. +- **Unblocks:** the entire lifecycle spec (compress/prune/pin needs a real store), + scope-aware retention. **Code.** + +## Quick wins (🟢 zero core change — immediate value) + +### Q1 — `scan-parsers` plugin pack +`SPEC-AI-ERA-PLUGINS` gap 1 / `PLUGIN-ROLES` Parser. Bundled 🟢 pack: parse nmap +`-oX` / nuclei `-json` → typed `scanner`/`scan_result` events + `eventTypes` + +`targetExtractors`. No core change. **Doc/plugin.** + +### Q2 — `mcp-tee` example + shim +`SPEC-AI-ERA-PLUGINS` gap 3 / `PLUGIN-ROLES` Tee. A stdio/HTTP-MCP proxy that +forwards to a third-party MCP server and POSTs each call. No new API — needs +`examples/plugins/mcp-tee`. **Example.** + +### Q3 — pair-emit lint ⚠️ (Settings.tsx, plugin loader) +`EVENT-TYPE-VOCABULARY` gap 1. A capture plugin emitting a new `agent_type` without +a paired `eventTypes` should **warn at load** and surface the undeclared kind in +Settings ▸ Plugins (else it lands in the "other" lane). **Code (small).** + +## Unification / hygiene (real convergence, medium effort) + +### U1 — Scope-aware sanitize planner +`SPEC-SCOPE-AWARE-LIFECYCLE` G3 + `DELIVERY-TARGETS` gap 5. Scope planner + `unknown` +bucket in front of `sanitize.ts`; becomes the third-party Snapshot's default +profile. Depends on scope verdict reuse (`ScopeMonitor`). **Code.** + +### U2 — Named sanitize profiles across delivery +`DELIVERY-TARGETS` gap 1. Unify the three ad-hoc sanitize paths (bundle layer-4 / +cloud-share `RedactionPreview` / deconfliction subtype-filter) into named profiles +`full` / `scope-sanitized` / `filtered`, shared by all delivery targets. Overlaps U1. +**Code.** + +### U3 — Control-plane face hygiene +`CONTROL-PLANE-FACES` gaps 1–5 + `DELIVERY-TARGETS` gap 2. +- Verify MCP dispatches into the **same canonical ops** as REST (not a parallel + `switch`) — the core §7 drift risk. +- **Generate** `codex-tools.json` from the MCP tool registry (stop hand-syncing). +- Move delivery ops (`export/bundle`, `sanitize`, `deconfliction`, cloud-share) + **off** the §7 control catalog onto a Delivery face. +- Declare the CLI's op-subset explicitly. **Code.** + +### U4 — Cloud-share integrity parity +`DELIVERY-TARGETS` gap 3. Verify cloud-share carries the same Ed25519 + OTS as +`bundle-export`; make "verifiable snapshot" uniform, not per-transport. **Code (verify + maybe fix).** + +## Contribution-surface growth (unblock plugin roles) + +### C1 — Implement `monitors` (🔴, reserved) +`PLUGIN-ROLES` gap 1 / `SPEC-AI-ERA-PLUGINS` gap 4. Isolated 🔴 that reads the event +stream + appends inferred detections. **Depends on K1.** Unblocks semantic injection +labels + plugin Monitor path. **Code.** + +### C2 — Add `detectionPatterns` (🟢) +`PLUGIN-ROLES` gap 4 / `DETECTOR-ROLES` gap 2. A 🟢 sibling of `lootPatterns` (or a +`kind` field) emitting inferred detections for the regex-detectable injection +subset. **Depends on K1.** **Code.** + +### C3 — `exporters` (🔴, reserved) + plugin Stream +`PLUGIN-ROLES` gap (Exporter) / `DELIVERY-TARGETS` gap 4. Implement `exporters` (first +instance = scope-sanitized bundle, U1); add a gated plugin Stream for SIEM/blue-team +forwarders. **Code.** + +### C4 — User-plugin `tailers` isolation +`PLUGIN-ROLES` Tailer (bundled-only today) / `CAPTURE-SOURCE-TAXONOMY`. Land the +v0.8.3+ isolation so user-authored tailers (e.g. C2 log packs) work, not just +bundled. Unblocks Q's `c2-tailers` as a user pack. **Code.** + +## Small / polish + +- **S1** Extractors state an explicit `confidence` field (`DETECTOR-ROLES` gap 1) — + §3 "this is inferred" visible, not assumed. **Code (small).** +- **S2** Generalize the Correlator (`DETECTOR-ROLES` gap 5) — declare linker-field + + relation to enable pivot→command / loot→exfil chains. **Code.** + +## Already ticketed / owned elsewhere (cross-ref, don't duplicate) + +- **Timeline UX** `TIMELINE-ELEMENTS` G1–G3, G5 → existing **T1/T2/T3** tickets + + the §8 axis re-bind (redlog-91's active `SPEC-TIMELINE-AXIS` work). G4 = K1 above. +- **Plugin lifecycle UI** → **PL1** (`UX-BACKLOG-TICKETS.md`). +- **Instance-ordinal visual** (`TIMELINE-ELEMENTS` instance channel, `[proposed]`) → + the disputed uncommitted 159-line `Timeline.tsx` change; **authorship unresolved**, + do not touch until the owner commits it. + +## Suggested order + +1. **K1** (unblocks the most, and makes the timeline honest). +2. **Q1 / Q2** in parallel (zero-core, ship value immediately). +3. **K2** (unblocks the whole lifecycle). +4. **U1/U2** then **C1/C2** (need K1) ; **U3/U4** independently. +5. **C3/C4**, then **S1/S2** polish. + +## Cross-references + +- Where each gap came from: the nine docs indexed in `DECOMPOSITION-METHOD.md` +- Existing tickets: `UX-BACKLOG-TICKETS.md` diff --git a/docs/README.md b/docs/README.md index 119ac500..30ebef4f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # RedLog Docs -Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links inside each for cross-references. Downloads on the [releases page](https://github.com/guan4tou2/REDLOG/releases/latest). +Wiki-style index for **v0.11.7**. Every page is self-contained; follow the links inside each for cross-references. Downloads on the [releases page](https://github.com/guan4tou2/REDLOG/releases/latest). ## What RedLog is (and isn't) @@ -20,6 +20,9 @@ Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links - **[Roadmap](ROADMAP.md)** — what ships next and the v1.0 gate. Also states what is deliberately not planned. - **[Audit 2026-08-08](AUDIT-2026-08-08.md)** — standing defect list from a full-tree review: correctness, trust-model gaps, presentation, test coverage, doc drift. Each item tagged verified/reported. - **[UX & complexity audit 2026-08-10](UX-AUDIT-2026-08.md)** — persona-driven review of the renderer: where breadth has outrun the solo operator (Timeline 3,875 lines / Settings 2,681 lines), the first-run friction gap, and a prioritized backlog. Companion to the correctness audit above. +- **[UX audit 2026-08-13](UX-AUDIT-2026-08-13.md)** — per-view operability review (16 views): the resize/proportion gap (only one draggable pane app-wide), discoverability of hidden gestures, and cross-view consistency. Prioritized fix list. Feeds the two design docs below. +- **[Design system — UI/UX language & framework](DESIGN-SYSTEM.md)** — the single normative source for colour (the `soften`/HUD palette + residual bright-accent drift to fix), typography (unifying the two parallel size scales), icons, component patterns, and the resize/layout framework (``, collapsible sidebar, breakpoints). Codifies what's scattered across `tailwind.config.js`, `lib/hud.ts`, and `index.css`. +- **[Timeline discoverability — wheel & axis](DESIGN-TIMELINE-DISCOVERABILITY.md)** — deep-dive extending DESIGN-TIMELINE-INTERACTION §T2: turns the wheel-mode hint from reactive-flash into a persistent, always-true indicator (`wheelModeLabel` seam), and adds the axis-switching track A1–A3 (labelled source/target control, preserving lane filters under the target axis, the transition chip). - **[Timeline UX deep-dive 2026-08-10](UX-TIMELINE-2026-08.md)** — the F2 deep-dive: why the timeline feels unintuitive (invisible, overloaded, context-dependent gestures), what it is *not* (lanes/data are fine), and the T1–T6 simplification plan. - **[UX backlog — ticket specs](UX-BACKLOG-TICKETS.md)** — every finding (F1–F7, T1–T6) broken out into an independently implementable ticket: problem, proposed solution, acceptance criteria, pure test seam, effort, priority order. - **[Timeline interaction redesign draft](DESIGN-TIMELINE-INTERACTION.md)** — concrete UI proposal with ASCII wireframes for the legend (T1), the wheel-mode decision matrix (T2), and the active-modes row (T3), plus the build order. @@ -63,6 +66,7 @@ Drift-signals that make the log honest: `recording_paused` / `recording_resumed` - **[Audit trail](audit-trail.md)** — SHA-256 event chain + hourly OpenTimestamps anchoring + full re-walk verify (with wall-vs-monotonic clock-anomaly detection) + `.ots` bundle export + four-layer redaction. Threat model, what's detected, what isn't. - **[Event schema](event-schema.md)** — standard `agent_type` values and data keys (Ghostwriter-compatible). Read before designing a new event source. - **[Redaction design](redaction-design.md)** — design note (proposed, not yet implemented). Four-layer model: capture full → detect spans → mask in UI → sanitize on export. Why capture-time redaction is the wrong default for an evidence tool, and how the append-only chain makes deferred sanitization safe. Tracks [#6](https://github.com/guan4tou2/REDLOG/issues/6). +- **[Cloud share bundle](CLOUD_SHARE_BUNDLE.md)** — the shareable-bundle format: manifest shape, the redaction gate that runs before upload, sanitize counts, expiry, and the Worker's independent size cap. The remote-upload Snapshot in the delivery path. ## Extending RedLog @@ -83,6 +87,7 @@ The reusable decompositions behind capture and plugin design — read these befo - **[Timeline elements](TIMELINE-ELEMENTS.md)** — the method as a *structural lens* on the timeline: **7 encoding channels × 4 interaction modes**, each declaring §3 fact-vs-suggestion, restating the UX-TIMELINE complexity as channel/mode overloading. Defers to `SPEC-TIMELINE-AXIS.md` / `UX-TIMELINE-2026-08.md` / §8 for the design itself. - **[Event-type vocabulary](EVENT-TYPE-VOCABULARY.md)** — the seam that ties capture, timeline, and §3 together: every `agent_type` classified by **origin** (authored / captured / agent / derived / system / plugin) and **authority**, plus the rule that a capture plugin emitting a new event kind **must** pair with an `eventTypes` contribution to claim its timeline identity (lane/colour/glyph) — or its evidence lands illegible in the "other" lane. - **[Off-chain content stores](OFF-CHAIN-CONTENT-STORES.md)** — the heavy content kept *off* the chain (`.cast`, io_ref bodies, screenshots) is one pattern in two shapes — **Blob** (content-addressed, dedup, whole/ranged) and **Stream** (time-indexed, sliced) — under one invariant (digest on-chain, bytes off-chain, pruned≠tampered) and one lifecycle engine, replacing today's three ad-hoc stores. +- **[Decomposition backlog](DECOMPOSITION-BACKLOG.md)** — the ~30 "Gaps" rows from the nine framework docs, **deduplicated** into a prioritized backlog: two keystones (the §3 inferred-`detection` primitive; off-chain store unification), zero-core quick wins (`scan-parsers` pack, `mcp-tee` example), and the unification/contribution-surface work — each tagged code/doc and cross-referenced to the doc that surfaced it. ## Agent skills (drop-in) @@ -91,6 +96,8 @@ The reusable decompositions behind capture and plugin design — read these befo ## Project ops - **[Releasing](releasing.md)** — two-phase cross-platform release via GitHub Actions (build matrix → single release job), plus the local `npm rebuild better-sqlite3` gotcha after packaging. +- **[Release checklist](RELEASE_CHECKLIST.md)** — the pre-release verification gates: what `npx playwright test` automates (§0 build + smoke, as of v0.9.4) and the manual checks that remain. Companion to Releasing. +- **[Windows setup](windows-setup.md)** — platform-specific install/build steps and gotchas for Windows; pairs with the [Windows compatibility audit](WINDOWS_COMPAT_AUDIT.md). ## Machine-readable From f7d39164f2d7e834ec268c7cf152ebe34695a6cc Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 01:34:40 +0800 Subject: [PATCH 24/47] feat(timeline): instance ordinals + inline stdout preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-facing features that were left implemented-but-uncommitted in the working tree; reviewed and verified here. Authorship was ambiguous across the concurrent sessions, so this lands them under one honest commit. 1. Instance ordinals — when a lane has more than one concurrent instance (two terminals, two agent sessions) each gets a stable 1-based ordinal + colour, surfaced on the dot (top-left badge), the overflow list row (#N), and the detail panel chip. A single terminal/session stays unadorned. HTTP/scanner flows are deliberately excluded (each request is its own flow_id, not a persistent instance). instanceOf/INSTANCE_COLORS + instanceInfo/instanceMark derivations, all guarded to null. 2. Inline stdout preview — a small, bracketed builtin-terminal command slice (io.len present, <=512KB) auto-loads its output inline (8-line preview + "show full" expander); large slices and whole-session replays stay one deliberate click away, so selecting an event never pulls a giant dump into the renderer by itself. castSpanBytes reads the same io:{off,len} shape api-server/TranscriptView already use. i18n keys landed earlier in b19dd99. Verified: typecheck 0, i18n 888/888, build, e2e smoke + command-io + timeline-presentation (9 passed). Excludes an unrelated in-flight palette sweep on the same file (text-2xs/3xs), which rides with its tailwind.config definition in a separate design-system commit. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/components/Timeline.tsx | 172 +++++++++++++++++++++-- 1 file changed, 159 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 748dfcac..ef4d3bbd 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -300,6 +300,35 @@ function toLane(agentType: string, subtype?: string, pluginTypes?: PluginEventTy return 'system' } +// A concurrent *instance* within a lane — the thing an operator needs to tell +// apart when several of the same source run at once: two open terminals, two +// agent sessions. Generalized so the axis distinction isn't hardcoded to +// terminals; any source with a persistent per-session identity can join by +// returning a stable key here. HTTP/scanner flows are deliberately excluded — +// each request is its own flow_id, so they aren't a persistent instance and +// would badge every dot. Returns null when the event has no such identity. +function instanceOf(e: RedLogEvent): { lane: string; key: string; kind: string } | null { + const d = e.data ?? {} + if (e.agentType === 'shell') { + const tid = d.terminalId ?? d.terminal_id + if (typeof tid === 'string' && tid) return { lane: 'shell', key: `t:${tid}`, kind: 'term' } + // External (hook) shells carry no terminalId; the shell process pid is a + // stable per-window identity, so concurrent external shells still separate. + if (typeof d.pid === 'number' || (typeof d.pid === 'string' && d.pid)) return { lane: 'shell', key: `p:${d.pid}`, kind: 'sh' } + return null + } + if (e.agentType === 'agent') { + const sid = d.session_id + if (typeof sid === 'string' && sid) return { lane: 'agent', key: `s:${sid}`, kind: 'session' } + return null + } + return null +} + +// Stable colours for instance ordinals (#1, #2, …). Chosen to read against the +// dark track and to not collide with the lane hues they sit on top of. +const INSTANCE_COLORS = ['#38bdf8', '#f472b6', '#a3e635', '#fbbf24', '#c084fc', '#fb7185', '#2dd4bf', '#f97316'] + interface PluginEventType { agentType: string label: string @@ -732,6 +761,35 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { if (!collapseAgentTurns) return 0 return rawEvents.filter(isCollapsibleAgentTurn).length }, [rawEvents, collapseAgentTurns]) + // Multi-instance distinction: assign each distinct instance (terminal / agent + // session) a stable 1-based ordinal within its lane, and remember which lanes + // have more than one active instance. Dots and the detail panel only surface + // the ordinal for those lanes, so a single terminal / session stays unadorned. + const instanceInfo = useMemo(() => { + const perLane = new Map() + for (const e of events) { + const inst = instanceOf(e) + if (!inst) continue + const keys = perLane.get(inst.lane) ?? [] + if (!keys.includes(inst.key)) { keys.push(inst.key); perLane.set(inst.lane, keys) } + } + const ordinal = new Map() + const multiLanes = new Set() + for (const [lane, keys] of perLane) { + if (keys.length > 1) multiLanes.add(lane) + keys.forEach((k, i) => ordinal.set(`${lane}::${k}`, i + 1)) + } + return { ordinal, multiLanes } + }, [events]) + // The instance ordinal + colour for one event, or null when its lane has no + // concurrency worth distinguishing (or the event has no instance identity). + const instanceMark = useCallback((e: RedLogEvent): { ord: number; kind: string; color: string } | null => { + const inst = instanceOf(e) + if (!inst || !instanceInfo.multiLanes.has(inst.lane)) return null + const ord = instanceInfo.ordinal.get(`${inst.lane}::${inst.key}`) + if (!ord) return null + return { ord, kind: inst.kind, color: INSTANCE_COLORS[(ord - 1) % INSTANCE_COLORS.length] } + }, [instanceInfo]) const [selectedEvent, setSelectedEvent] = useState(null) const [allLoaded, setAllLoaded] = useState(false) const [loading, setLoading] = useState(true) @@ -3347,6 +3405,22 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { ) })()} + {/* Multi-instance ordinal (top-left): only shows when the + event's lane has more than one concurrent instance — + two terminals, two agent sessions — so a single + terminal keeps a clean dot. Colour + number match the + chip in the detail panel. */} + {single && (() => { + const im = instanceMark(evt) + if (!im) return null + return ( + {im.ord} + ) + })()} {/* Feature 3: single-badge bubble at top-right. Only the first badge shows here to keep the dot readable; the rest surface in the tooltip and in the detail panel. */} @@ -3421,7 +3495,18 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { > {formatTs(evt.timestamp, tz, projectTz, 'timeSec')} - {eventTitle(evt)} + {eventTitle(evt)} + {(() => { + const im = instanceMark(evt) + if (!im) return null + return ( + #{im.ord} + ) + })()} ))} {overflow > 0 && ( @@ -3513,6 +3598,16 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {operatorLabel(selectedEvent.operatorId)} + {(() => { + const im = instanceMark(selectedEvent) + if (!im) return null + return ( + {im.kind} #{im.ord} + ) + })()}
{(() => { @@ -3733,7 +3828,11 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { && selectedEvent.data?.subtype === 'command_end' && selectedEvent.data?.source === 'builtin-terminal' && ( - + )} + /> )} {/* Session-level replay: for session_start / session_end, replays the ENTIRE pty session. Critical when the operator ssh'd into @@ -3776,6 +3875,14 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB` } +// The size of the .cast span a builtin-terminal command produced, when the +// command was bracketed (io.len present and not `unbracketed`). Used to decide +// whether ReplayCommand can cheaply auto-load the output for an inline preview. +function castSpanBytes(data: Record | undefined): number | undefined { + const io = data?.io as { len?: number; unbracketed?: boolean } | undefined + return io && typeof io.len === 'number' && !io.unbracketed ? io.len : undefined +} + // Structured detail body for a shell command_end event. Renders separate // stdout / stderr collapsible sections when the wrapper populated them, // falls back to a "mixed" section for the legacy `output` field, and @@ -4175,16 +4282,27 @@ function MetadataGrid({ entries }: { entries: Array<[string, unknown]> }): JSX.E ) } +// Command slices at or below this size auto-load so the operator sees the +// output inline without a click — the "see I/O on the timeline" affordance. +// Larger slices (and the heavy session player) stay behind the manual button, +// so selecting an event never pulls a giant dump into the renderer by itself. +const REPLAY_AUTOLOAD_LIMIT = 512 * 1024 +// Lines of stdout shown before the "show full output" expander. +const REPLAY_PREVIEW_LINES = 8 + // Pulled from the session's asciinema .cast on disk — not from the event // row. Rendering here keeps the chain event clean (command + exit + duration -// only) while still letting the operator see what actually printed. -function ReplayCommand({ eventId, mode = 'command' }: { eventId: string; mode?: 'command' | 'session' }): JSX.Element { +// only) while still letting the operator see what actually printed. For a +// bracketed command whose slice is small, the output previews automatically; +// large slices and whole-session replays stay one deliberate click away. +function ReplayCommand({ eventId, mode = 'command', castBytes }: { eventId: string; mode?: 'command' | 'session'; castBytes?: number }): JSX.Element { const [text, setText] = useState(null) const [events, setEvents] = useState | null>(null) const [truncated, setTruncated] = useState(false) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) - const [expanded, setExpanded] = useState(false) + const [loaded, setLoaded] = useState(false) + const [showFull, setShowFull] = useState(false) const { t } = useI18n() const load = async (): Promise => { setLoading(true) @@ -4202,21 +4320,36 @@ function ReplayCommand({ eventId, mode = 'command' }: { eventId: string; mode?: const evs = (r as { events?: Array<[number, 'o', string]> }).events if (evs && evs.length > 0) setEvents(evs) setTruncated(Boolean((r as { truncated?: boolean }).truncated)) - setExpanded(true) + setLoaded(true) } finally { setLoading(false) } } + + // A small, bracketed command slice is cheap to read, so auto-load it. The + // detail panel is not remounted per selection, so reset state when eventId + // changes (otherwise the previous command's output would leak into the new + // panel) and re-trigger the auto-load. + const autoEligible = mode === 'command' && typeof castBytes === 'number' && castBytes > 0 && castBytes <= REPLAY_AUTOLOAD_LIMIT + useEffect(() => { + setText(null); setEvents(null); setError(null); setTruncated(false); setShowFull(false); setLoaded(false) + if (autoEligible) void load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventId]) + const btnLabel = mode === 'session' ? 'timeline.replay.sessionButton' : 'timeline.replay.button' - if (!expanded) { + if (!loaded && !loading) { return ( + >{t(btnLabel)} ) } + if (!loaded && loading) { + return

{t('timeline.replay.loading')}

+ } if (error) return

{t('timeline.replay.failed', { error })}

// Session replays get the full player (xterm + scrubber + speed). If the // slice happened to be empty of frames — legacy casts, empty session — we @@ -4224,11 +4357,24 @@ function ReplayCommand({ eventId, mode = 'command' }: { eventId: string; mode?: if (mode === 'session' && events && events.length > 0) { return } - const heightCls = mode === 'session' ? 'max-h-[400px]' : 'max-h-[200px]' + const full = text ?? '' + const lines = full.split('\n') + const isLong = lines.length > REPLAY_PREVIEW_LINES + 1 || full.length > 800 + const shown = showFull || !isLong ? full : lines.slice(0, REPLAY_PREVIEW_LINES).join('\n') + const heightCls = mode === 'session' || showFull ? 'max-h-[400px]' : 'max-h-[200px]' return ( -
-      {text || t('timeline.replay.empty')}
-    
+
+
+        {shown || t('timeline.replay.empty')}
+      
+ {isLong && ( + + )} + {truncated &&

{t('timeline.replay.truncated')}

} +
) } From a71c3001ccecfae7cc17c27923ad71af10413e5f Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 10:14:54 +0800 Subject: [PATCH 25/47] docs(ux): UX audit + design-system spec + timeline discoverability Per-view operability audit (16 views) with the resize/proportion gap, discoverability findings, and a prioritised fix list; a normative design system consolidating colour/type/size/icon/component/layout tokens and the Apple HIG alignment (with honest deviations); and a deep-dive redesign for the Timeline wheel-mode + axis-switch discoverability. Co-Authored-By: Claude Opus 4.8 --- docs/DESIGN-SYSTEM.md | 383 ++++++++++++++++++++++++ docs/DESIGN-TIMELINE-DISCOVERABILITY.md | 199 ++++++++++++ docs/UX-AUDIT-2026-08-13.md | 156 ++++++++++ 3 files changed, 738 insertions(+) create mode 100644 docs/DESIGN-SYSTEM.md create mode 100644 docs/DESIGN-TIMELINE-DISCOVERABILITY.md create mode 100644 docs/UX-AUDIT-2026-08-13.md diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md new file mode 100644 index 00000000..b24e5ac7 --- /dev/null +++ b/docs/DESIGN-SYSTEM.md @@ -0,0 +1,383 @@ +# RedLog 設計系統 — UI/UX 語言與框架 + +以 **v0.11.7** 現況為基準,把散落在 `tailwind.config.js`、`src/renderer/src/lib/hud.ts`、 +`src/renderer/src/styles/index.css` 三處的設計決策**收斂成單一權威來源**,並標出目前的不一致。 + +這份文件是「規範(normative)」:新元件依此建;既有元件逐步對齊。它回答四個問題 — +**用什麼顏色、什麼字、什麼大小、什麼 icon**,以及**怎麼組成一致的版面與互動**。 + +配套:[`UX-AUDIT-2026-08-13.md`](UX-AUDIT-2026-08-13.md)(現況評估)、 +[`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md)(Timeline 細部)。 + +外部基礎:Apple [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines) +與 [UI Design Do's and Don'ts](https://developer.apple.com/design/tips/)。RedLog 是 macOS 優先的 +Electron 桌面 app,以 HIG **原則**為地基;但它是**高密度的專業/紅隊工具**,不是消費級 iOS app, +故對 HIG 的觸控級預設採「桌面校準」而非照搬(見 §0.1 的對齊與偏離表)。 + +--- + +## 0. 設計原則(本 app 的 UI/UX 語言) + +1. **證據優先,冷靜呈現**:這是長時間注視的稽核工具。accent 一律用**去飽和**版本,避免在暗底上「震動」 + (已在 `soften` map 建立,見 §1)。紅色是狀態語彙的一部分(danger/exposed/recording),不是裝飾。 +2. **一個手勢只做一件事**:不做「結果取決於看不見狀態」的互動;必須依狀態改變時,把狀態顯示出來 + (見 [`DESIGN-TIMELINE-INTERACTION.md`](DESIGN-TIMELINE-INTERACTION.md) 原則 2)。 +3. **空狀態一律自我說明**:沒東西時要嘛引導下一步(CTA),要嘛說明「為什麼是空的」。 +4. **漸進揭露**:主控制常駐;長尾收在 `?`/`Collapsible`/「進階」之後。 +5. **色彩不是唯一訊號**(a11y):狀態同時用顏色+形狀/文字(如 REC 用紅點+"REC" 字)。 + +上述五條與 Apple HIG 的三大支柱同源:**Clarity(清晰)**(原則 5 對比+形狀)、 +**Deference(讓位於內容)**(原則 1 冷靜、暗底、內容優先)、**Depth(層次)**(HUD 的框/光暈/模糊建立層級)。 + +--- + +## 0.1 與 Apple HIG 的對齊與偏離 + +HIG 是**桌面級**基礎;RedLog 是高密度紅隊工具,部分 HIG 觸控預設**刻意偏離**。下表對每條給 +「對齊 / 偏離」判定與處置。判定為「偏離—待修」者,已回填到 [`UX-AUDIT-2026-08-13.md`](UX-AUDIT-2026-08-13.md) 的清單。 + +| HIG 準則 | 具體值 | RedLog 現況 | 判定 | +|---|---|---|---| +| **點擊目標大小** | 觸控 44×44pt(指標裝置可較小,但仍需舒適) | `w-5 h-5`(20px)鈕、詳情面板 4px 拖曳把手、截圖刪除 `×` 等原本遠低於此 | 🔧 **偏離—已實作 hit-slop**:桌面滑鼠可小於 44pt。規範:**互動控制的可點區 ≥ 28×28px**、拖曳把手命中區 ≥ 8px,用 `.hit-target`/`.hit-target-v` 撐命中區、**視覺仍小**(見 §0.1a) | +| **最小文字尺寸** | ≥ 11pt(≈14.7px @96dpi)供免縮放閱讀 | `text-sm`≈14.9px(≈11pt) 合格;`text-xs`≈12.8px 為本專案桌面校準下限;`text-2xs`/`text-3xs` 更小 | 🔧 **偏離—已實作 C9**:17px 基準+可調 `--app-zoom`(0.9–1.5)為緩解。規範已落實:`text-2xs`/`text-3xs` **只給非必要裝飾性 meta**(計數、freshness、角標、chip),**必要可讀內容一律 ≥ `text-xs`**(見 §2.3 遷移狀態)。`text-3xs` 進一步收窄為**僅角標/寬度受限軸標** | +| **不需水平捲動/縮放即見主內容** | 版面貼合視窗 | 全樹僅 1 處 `md:` 斷點、`grid-cols-3/4` 寫死,800px 最小寬會擠 | ⚠ **偏離—待修**:見 §6.4 響應式斷點 | +| **控制貼近其作用的內容** | — | 多數符合(chip 在資料旁、詳情面板貼事件) | ✅ 對齊 | +| **對齊揭示資訊關係** | 文字/圖/鈕對齊 | 卡片格線、標籤欄對齊良好 | ✅ 對齊 | +| **充足對比** | 字色與底色對比足夠 | `soften` 去飽和但維持對比;中性文字 `zinc-400/500` 於 `#0a0a0a` 上足夠 | ✅ 大致對齊(`text-zinc-600/700` 用於極次要 meta 時接近下限,勿用於必要文字) | +| **高解析度資產/正確長寬比** | @2x/@3x、不變形 | 截圖 `object-cover`/`object-contain` 正確;icon 為向量字元/SVG | ✅ 對齊 | +| **一致性(平台慣例)** | 一致的控制與導覽 | 本設計系統即為此(§4 icon、§5 元件、§2 字級收斂) | ✅ 對齊中 | +| **尊重使用者偏好** | Dark Mode、動效、Dynamic Type | `prefers-reduced-motion` 已全域處理(§7);深色為預設;UI zoom 近似 Dynamic Type | ✅ 對齊 | +| **可及性(VoiceOver 等)** | 標籤、focus、語意 | `aria-label`/`focus-visible` 多數具備;缺 modal focus-trap、Settings tab a11y、`aria-live`(§8) | ⚠ **部分**:見 §8 待補 | + +**淨結論**:RedLog 在 Clarity/Deference/Depth、對比、一致性、動效偏好上與 HIG 對齊良好; +主要偏離是**點擊目標過小**與**必要文字偏小**——兩者都源自「高密度」取捨。處置不是全面放大(會犧牲密度), +而是**設下限並區分層級**:命中區 ≥28px、必要文字 ≥`text-xs`、微字級只給裝飾性 meta。 + +### 0.1a hit-slop utility(C8 實作,2026-08-13) + +在 `styles/index.css` 定義兩個 utility,以**隱形 `::before` overlay** 擴大命中區、**不改變視覺大小** +(符合「視覺仍小」): + +| Utility | 作用 | 適用 | +|---|---|---| +| `.hit-target` | `position:relative` + `::before { inset:-6px }`:16px 控制→28px、20px→32px | 一般靜態 icon-only 小鈕 | +| `.hit-target-v` | 只在上下擴 8px(`inset:-8px 0`):4px 把手→20px 抓取帶 | 細長全寬拖曳把手 | + +**已套用(14 處)**:Timeline 詳情面板拖曳把手(`hit-target-v`)、Timeline 標頭 zoom −/reset/+、 +help/filter/views/active-modes 的 `×` 與 skip-idle 等小鈕;Terminal 分頁關閉 `×`、搜尋列 ↑/↓/`×`; +截圖刪除 `×`。 + +**使用規範**: +- 元件若已是 `absolute`/`relative` 定位,**勿**用 `.hit-target`(它會把 position 設回 relative);改用只加 `::before` + 的變體或手動加大。 +- 兩個 hit-target 控制至少相隔 12px,slop 才只在「間隙」相接、不蓋到鄰居的字形。 +- **刻意未套用**:Timeline 內嵌於 filter input 的 `absolute` 清除 `×`(slop 會蓋到輸入區、竊取點擊); + Settings/ProjectPicker 的 `ListField` 移除 `×`(低頻、位於表單)——留待增量套用或改用 `absolute` 變體。 + +--- + +## 1. 色彩系統 + +### 1.1 表面(surface)階梯 — `redlog.*`(`tailwind.config.js`) + +| Token | Hex | 用途 | +|---|---|---| +| `redlog-bg` | `#0a0a0a` | 最底層背景、標題列、Sidebar | +| `redlog-surface` | `#141414` | 卡片、面板 | +| `redlog-elevated` | `#1a1a1a` | 疊在 surface 上的區塊、hover | +| `redlog-border` | `#262626` | 主要邊框 | +| `redlog-border-subtle` | `#1e1e1e` | 次要分隔線 | + +文字:`redlog-text #e5e5e5`(主)、`redlog-text-dim #a1a1aa`(次)、`redlog-muted #71717a`(標籤)。 +慣例上 zinc 階(`zinc-200/300/400/500/600`)已大量用於文字層級 — **視為與上表同義**,新程式優先用具語意的 `redlog-*`。 + +### 1.2 狀態/accent — `soften` map(`tailwind.config.js`)+ HUD(`lib/hud.ts`) + +**關鍵**:全 app 的 `red/green/amber/cyan` 已被 `soften` 重新對映,且**必須與 HUD 逐字相同**, +讓主視窗與懸浮 HUD 呈現同一組 accent。 + +| 語意 | Token(400 階) | Hex | 對應 HUD | +|---|---|---|---| +| danger / exposed / recording / 主 accent | `red-400` / `redlog-accent` | `#d75f63` | `HUD.red` | +| safe / active / healthy | `green-400` / `emerald-400` | `#5ecf9c` | `HUD.green` | +| unknown / idle / partial / warning | `amber-400` / `yellow-400` | `#d4ac5a` | `HUD.amber` | +| system / live / pivot / 資訊 | `cyan-400` / `redlog-cyan` | `#3fc7d6` | `HUD.cyan` | + +每個語意色的 300(亮,用於 hover 文字)與 500(深,用於填底)階見 `soften`。 +HUD 專屬中性色:`HUD.muted #5f7a82`(標籤)、`HUD.value #cfe8ee`(帶青的白,主要數值)。 + +### 1.3 語意色使用規則 + +- **紅**只給「危險或需要注意」:scope 違規、exposed IP、recording、刪除。不要當一般強調。 +- **青**給「系統態與導覽」:LIVE、pivot、連線、可點連結、選取態。 +- **綠**只給「確認安全/健康/成功」。**琥珀**給「未知/閒置/部分」。 +- **中性**(zinc/redlog-text-*)給絕大多數文字。一個畫面同時出現三種以上 accent = 過載,重新分層。 + +### 1.4 陰影與光暈(`boxShadow`) + +`card`(`0 1px 3px rgba(0,0,0,.4)`)、`card-hover`。光暈 `glow-red-*`/`glow-cyan-*` 用於強調態。 + +### 1.5 chrome accent drift — ✅ 已收斂(2026-08-13) + +以下 chrome 硬編值原本**未跟上 `soften` 遷移**(仍是舊的搶眼亮色),本輪已修正: + +| 位置 | 原值 | 現值 | 狀態 | +|---|---|---|---| +| `tailwind.config.js` `glow-red`/`glow-red-sm` | `rgba(239,68,68,…)` | `rgba(215,95,99,…)`(`#d75f63`) | ✅ | +| `tailwind.config.js` `glow-cyan`/`glow-cyan-sm` | `rgba(34,211,238,…)` | `rgba(63,199,214,…)`(`#3fc7d6`) | ✅ | +| `styles/index.css` `.drag-over` | `#ef4444` | `#d75f63` | ✅ | +| `OverlayApp.tsx` inline ×4(hair/iconBtn/scanline) | `rgba(34,211,238,…)` | `hexA(CYAN, …)` | ✅ | + +**規範**:renderer 的 UI chrome 內不得再出現 raw hex accent — 一律走 tailwind class 或 `HUD.*` token。 + +### 1.5b 分類色盤與狀態色(**刻意保留**,另立議題) + +以下三處也是硬編亮色,但它們**不是** chrome accent drift,而是各自成套的**分類/狀態色盤**, +單獨 soften 其中一色會破壞整組的可辨識性,故本輪不動,留待「統一分類色盤」的獨立設計: + +| 位置 | 性質 | 為何保留 | +|---|---|---| +| `TerminalView.tsx` `red/brightCyan/cursor/selection` | 終端機 ANSI 調色盤 | 終端內容的標準鮮色,特殊表面;ANSI 全套一致鮮豔 | +| `TranscriptView.tsx` `KIND_COLOR.marker #ef4444` | 分類色盤 | 與 `#22c55e/#84cc16/#8b5cf6/#f97316` 並存,需整組一起定 | +| `Timeline.tsx:3389` fail 狀態環 `#ef4444` | 語意狀態色 | 需與 Timeline 其餘 lane/status 色一起收斂 | + +**待辦(獨立 ticket)**:定義一套「lane/kind 分類色盤」與「語意狀態色」token(含 §1.2 的 danger red 對映), +再讓上述三處引用,而非各自硬編。 + +--- + +## 2. 字型系統 + +### 2.1 字族(family) + +| 角色 | Stack | 位置 | +|---|---|---| +| UI(介面文字) | `-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei UI', system-ui, sans-serif` | `styles/index.css` body | +| 等寬(數值/ID/指令/時間戳) | `ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', Consolas, 'Microsoft JhengHei UI', monospace` | 多處 `font-mono` | +| 終端機 | `'MesloLGS NF', 'FiraCode Nerd Font', 'JetBrainsMono Nerd Font', 'Hack Nerd Font', 'SF Mono', Menlo, …` | `TerminalView.tsx:314` | + +**規範**:所有「機器產物」(IP、hash、event id、port、timestamp、指令)用 `font-mono` + `tabular-nums`; +散文用 UI 字族。`Microsoft JhengHei UI` 已納入兩條 stack,確保 Windows 上中文不掉字。 + +### 2.2 字級(size)— 目前的問題 + +現況掃描(renderer 內)顯示**兩套並行的字級尺標**,且分布碎裂: + +``` +Tailwind rem 制:text-xs ×350 text-sm ×34 text-base ×2 text-lg ×6 text-xl ×3 +硬編 px 制: text-[11px] ×138 text-[10px] ×33 text-[13px] ×7 text-[9px] ×4 text-[8px] ×2 text-[12px] ×1 +``` + +問題有二: +1. **兩套尺標語意重疊**:`text-xs`(=0.75rem,在 17px 基準下 ≈12.75px)與 `text-[11px]`/`text-[13px]` + 互相接近卻不成階,維護時無從判斷該用哪個。 +2. **縮放行為不一致**:rem 制會跟著 `html { font-size: 17px }` 放大;硬編 px **只跟 `--app-zoom` 放大** + (`styles/index.css` 註解已載明此權宜)。兩者在使用者調字級時步調不同。 + +### 2.3 規範字級尺標(type scale) + +收斂成 7 級,一律用 Tailwind 具名 class(rem 制,隨基準縮放)。`text-2xs`/`text-3xs` +已於 `tailwind.config.js` `fontSize` 定義(string 形式=只設 font-size,行為與被取代的 arbitrary 值一致): + +| 級別 | class | ≈px @17基準 | 用途 | +|---|---|---|---| +| nano | `text-3xs`(`0.5rem`) | ~8.5 | 最密的徽章/角標(取代 `text-[8px]`/`text-[9px]`) | +| micro | `text-2xs`(`0.625rem`) | ~10.6 | chip 計數、freshness 標籤、極次要 meta(取代 `text-[10px]`) | +| caption | `text-xs` | ~12.8 | **預設次要文字**(絕大多數 label、hint) | +| body | `text-sm` | ~14.9 | 主要可讀內文、表單值 | +| title | `text-base` | ~17 | 卡片/區塊標題 | +| heading | `text-lg` | ~19 | 頁面標題 | +| display | `text-xl` | ~21 | 大數字(Dashboard 統計) | + +**遷移狀態**: +- ✅ 已完成(2026-08-13):`text-[10px]` → `text-2xs`(33 處)、`text-[9px]`/`text-[8px]` → `text-3xs`(6 處)。 + 這一批視覺位移極小(10→10.6px、8/9→8.5px),但字級**從此隨 17px 基準縮放**(原本只隨 `--app-zoom`)。 +- ✅ C9 必要文字下限(2026-08-13):把**必要可讀但過小**的文字提到 `text-xs` — Timeline 互動圖例(T1 legend, + pan/zoom/filter/shortcuts)與滾輪模式提示(wheel-mode hint),兩者都是「核心手勢絕不是祕密」的導覽文字, + 8pt 太小。其餘微字級(計數、freshness、chip、footer、角標)經審視確認為裝飾性 meta,維持不變。 + `text-3xs` 僅保留給 instance 角標/count 徽章與寬度受限的軸標(3418/3429/3504/3206/3228);palette 分類標由 `text-3xs`→`text-2xs`。 +- ⏳ 待做(**非低風險**,另立):`text-[11px]`(138 處)→ `text-xs`、`text-[13px]`(7 處)→ `text-sm`。 + 這兩批會放大 ~15%(11→12.8、13→14.9),需搭配逐頁視覺 pass,故不併入 token 收斂批次。 +- 例外:終端機字級(`TerminalView` 的 `⌘±`)與 HUD `scale` 是使用者可調的獨立系統,維持 px 動態計算。 + +### 2.4 字重與字距 + +- 區塊標題慣例:`text-[11px] font-semibold uppercase tracking-[0.15em] text-zinc-500`(見 Dashboard、Settings)。 + → 收斂為 utility `.section-heading`(見 §5)。 +- 數值:`font-mono font-semibold tabular-nums`。 +- 一般文字:`font-medium` 或預設。避免 `font-bold` 大面積使用。 + +--- + +## 3. 間距、尺寸與圓角 + +- **間距單位**:沿用 Tailwind 4px 網格。區塊間 `space-y-3`/`gap-3`;卡片內距 `p-4`;緊湊列 `gap-2`。 +- **圓角**:卡片 `rounded-lg`;chip/按鈕 `rounded`/`rounded-md`;徽章 `rounded-full`。 + HUD 專屬斜角 `CHAMFER`/`CHAMFER_SM`(`lib/hud.ts`)僅限 overlay,勿用於主視窗。 +- **控制高度**:導覽項 `h-8`;工具列鈕 `py-1`;狀態列 `h-7`;標題列 `h-10`。收斂見 §5 按鈕。 +- **邊框**:一律 `border-redlog-border`;hover 態可 `border-zinc-600`。 + +--- + +## 4. Icon 系統 + +### 4.1 現況:ASCII glyph,無統一集 + +目前 icon 是散落的 Unicode 幾何字元,來源不一、隱喻不一致、辨識度低: + +``` +Sidebar: ◉ dashboard ▸ terminal ═ timeline ☰ transcript ◻ screens + ⊕ targets ⊘ scope ◆ loot ⚑ marks ⚙ settings +其他: ⌕ search ⚓ anchor ⊞ 軸切換 ▤ phase ribbon ⚡ 快速標記 ↗ 跳轉 ⠿ 拖曳把手 +``` + +問題:`═`(timeline)、`☰`(transcript)、`◻`(screens)之間沒有共同視覺語言; +`◉/◆/◻` 都是「實心/空心幾何」但語意無關;字型 fallback 不同平台粗細不一。 + +### 4.2 規範:兩條路線(擇一,建議 A) + +**路線 A(建議)— 導入單一 stroke icon set** +採用一套開源、線性、可設 `currentColor` 的 SVG icon(如 Lucide 風格),統一 `1.5px` stroke、24×24 grid。 +每個 sidebar 項給明確隱喻:dashboard=gauge、terminal=terminal、timeline=activity/waveform、 +transcript=list、screens=image、targets=crosshair、scope=shield、loot=key、marks=flag、settings=settings。 +以 inline SVG 或本地 sprite 引入(Electron 無 CSP 外連問題,但沿用專案「自帶資產」慣例)。 + +**路線 B(低成本過渡)— 收斂現有 glyph 為一致集** — ✅ 已實作(2026-08-13) +- 建立 `src/renderer/src/lib/icons.ts`,匯出**單一來源** `ICON` map(nav 實體 + 跨切面 affordance), + 以 Sidebar 為 canonical 定義。8 個檔(Sidebar/App/StatusBar/TerminalView/LootPanel/ScopeStatus/ + TranscriptView/FindingsView)改引 `ICON.*`,非 Timeline 檔已無裸語意 glyph。 +- **順帶修掉兩處實體漂移**:TranscriptView 空狀態 `▤`→`ICON.transcript`(`☰`,與 nav 一致、且解除與 + phaseRibbon 的撞號);FindingsView 空狀態 `◈`→`ICON.marks`(`⚑`,與 nav 一致)。 + loot `◆` 原散在 3 檔(Sidebar/StatusBar/LootPanel),現同引一個 token。 +- 規範:每個 glyph render 時 `aria-hidden` 且旁邊恆有文字 label/`aria-label`;大小顏色走型別/色彩 token, + `icons.ts` 只帶 glyph。 +- ⏳ 待收尾:Timeline.tsx 內 3 個單站 glyph(空狀態 `═`、軸 `⊞`、phaseRibbon `▤`)留待該檔其他 + in-flight 變更落地後再引 `ICON.*`(避免與並行工作衝突);generic affordance(`✕`/`✎`/`+` 等)可日後增量遷入。 +- 未來切路線 A 時,只需替換 `icons.ts` 這一個 map 的值,呼叫端不動。 + +### 4.3 通則 + +- icon 恆搭配文字 label 或 `aria-label`(純 icon 鈕如標題列 search `⌕` 已有 `aria-label`,維持)。 +- icon 尺寸跟隨字級(`w-4 h-4` 對 `text-sm`)。 +- 狀態用色遵 §1.2;不可只靠 icon 形狀傳達狀態。 + +--- + +## 5. 元件模式(component patterns) + +把重複出現的 Tailwind 組合收斂為具名 pattern(可做成 `@layer components` utility 或 React 元件)。 + +### 5.1 卡片 Card +``` +rounded-lg bg-redlog-surface border border-redlog-border p-4 shadow-card +(可 hover:shadow-card-hover) +``` +頂部狀態條:``(見 `StatCard`、`CaptureHealthCard`)。 + +### 5.2 區塊標題 `.section-heading` +``` +text-xs font-semibold uppercase tracking-[0.15em] text-zinc-500 +``` +(統一目前 `text-[11px]`/`text-[10px]` 混用;見 Dashboard vs Settings 的細微差異。) + +### 5.3 Chip(篩選/狀態標籤) +- 未選:`bg-zinc-800 text-zinc-500 hover:text-zinc-300` +- 選中:`bg-red-500/20 text-red-300`(或對應語意色) +- 停用:`opacity-25 cursor-not-allowed` +- 恆帶 `focus-visible:ring-1 focus-visible:ring-red-500/40`。 +**規範**:篩選 chip 的「出現閾值」統一(見 audit C4)— 不論資料量恆顯示,或恆用同一門檻,別各頁不同。 + +### 5.4 按鈕 Button(三級) +| 級別 | 樣式 | 用途 | +|---|---|---| +| primary | `bg-red-500/10 text-red-400 border border-red-500/15 hover:bg-red-500/20` | 主行動(Mark、儲存) | +| secondary | `bg-zinc-800 text-zinc-300 hover:bg-zinc-700` | 一般行動(Export) | +| ghost | `text-zinc-500 hover:text-zinc-300 hover:bg-white/[0.03]` | 低權重/導覽 | +| danger | 同 secondary,但 hover 轉 `text-red-400`;**破壞性一律走 `confirm(..., danger=true)`** | 刪除/revoke | + +### 5.5 徽章 Badge、Toast、ConfirmDialog、EmptyState、LoadingSpinner +- 已有共用元件(`Toast.tsx`、`ConfirmDialog.tsx`、`Feedback.tsx` 的 `EmptyState`/`LoadingSpinner`)。 + **規範**:所有空狀態走 `EmptyState` + `emptyStateFor()`;所有破壞性確認走 `confirmDialog`。 +- Modal 一律加 **focus trap**(目前 `EventMarker` 只有 autofocus,缺 trap — audit C6)。 + +--- + +## 6. 版面與縮放框架(回應「畫面佔比可調」) + +### 6.1 目前結構 +`App.tsx`:標題列(`h-10`) → 主體(`Sidebar` 固定 140px + `view-root flex-1`) → `StatusBar`(`h-7`)。 +全 app 唯一可拖曳縮放是 Timeline 詳情面板(`Timeline.tsx:3571`,存 localStorage)。 + +### 6.2 規範:共用 `` — ✅ 已實作(2026-08-13) + +可重用的分割元件(`components/SplitPane.tsx`),把 Timeline 詳情面板已驗證的模式一般化: + +```tsx + + {/* 可調整大小的第一欄;px 大小存 localStorage redlog-split-{id} */} + {/* flex-1 填滿其餘 */} + +``` +- 拖曳把手:1px 視覺 + `.hit-target` 命中區(§0.1a),`cursor-col/row-resize`、`hover:bg-redlog-accent/50`。 +- 雙擊把手 → 重設並清 localStorage;`←/→`(或 `↑/↓`)鍵每步 16px(a11y,`role="separator"`)。 +- `ResizeObserver` 在容器縮小時重新 clamp,避免面板寬過頭。 +- 純函式縫:`splitPaneClamp(px, min, max, containerPx, otherMin)`(`lib/splitPane.ts`, + `test/split-pane.test.ts` 7 例)。 +- **已套用**(2026-08-13):FindingsView 左右欄(原 `w-80` 硬編 → 可拖曳);**Loot** 清單|詳情(詳情面板顯示 + 完整值 + 複製鈕,順帶修掉 audit C1 的「preview 截斷、不能複製」);**Transcript** feed|詳情(詳情面板顯示 + 選定交換的完整 input/output + 各自複製鈕,補足 feed 無複製、output 截斷的缺口);**Terminal 分割窗** + (`⌘D`/◫ 按鈕把一個 tab 分成兩個並排的 shell,每個獨立 pty,中間可拖曳調整佔比;`defaultSize` 支援分數 0.5=五五分)。 +- **`defaultSize` 支援分數**:值在 (0,1] 視為容器比例(如 0.5=一半),首次量到容器時解析;持久化的一律是 px。 + +### 6.3 規範:Sidebar 可收合 — ✅ 已實作(2026-08-13) +- 底部 toggle(`«`/`»`)或 **`⌘/Ctrl+B`**(App 派發 `redlog:toggle-sidebar`,Sidebar 監聽)在 + icon-only(52px)↔ 展開(140px)間切換,狀態存 localStorage `redlog-sidebar-collapsed`,`transition-[width]`。 +- 收合時只剩 icon(tooltip 帶 label);count badge 改為 icon 角落小圓點;drag 重排把手僅展開時顯示。 + +### 6.4 規範:響應式斷點 +- 目前全樹僅 1 處 `md:`。關鍵格線(Dashboard `grid-cols-3/4`、Screenshots `grid-cols-3`) + 補 `sm:`/`md:`:如 `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`。 +- 最小視窗 800×500 必須無水平溢出、無擠壓。 + +### 6.5 全域縮放(維持) +`--app-zoom`(Chromium `zoom`,0.9–1.5,`main.tsx:11`)+ `html { font-size: 17px }` 兩段式,維持現狀; +§2.3 字級收斂後,px 硬編減少,兩段式落差自然縮小。 + +--- + +## 7. 動效(motion) + +- 已定義:`pulse-slow`、`toast-in`、`blink-rec`、`spin-slow`(`tailwind.config.js`)+ HUD `alarm`。 +- **規範**:轉場一律 `transition-colors`/`transition-opacity`,時長 `duration-150`;避免大位移動畫。 +- **a11y**:`prefers-reduced-motion: reduce` 已在 `styles/index.css` 全域處理(凍結動效但保留顏色資訊)。 + 新動效**必須**能被此 media query 凍結 — 不可用動畫傳達唯一資訊。 + +--- + +## 8. 可及性(a11y)基線 + +- 互動元素恆有 `focus-visible:ring`(多數已具備)。 +- 純 icon 控制恆有 `aria-label`。 +- 狀態不只靠顏色(§0 原則 5)。 +- Modal:`role="dialog"` `aria-modal` + **focus trap** + Escape + backdrop 關閉(EventMarker 待補 trap)。 +- Settings tab bar 待補 `role="tablist"`/`role="tab"`/`aria-selected`/方向鍵導覽。 +- 動態結果清單(搜尋)待補 `aria-live`。 + +--- + +## 9. 採用路線(roadmap) + +**Phase 1(token 收斂,低風險)** — 部分完成(2026-08-13): +- ✅ 修 §1.5 chrome 亮色殘留(glow×4、`.drag-over`、OverlayApp inline×4)。 +- ✅ `tailwind.config.js` 定義 `text-2xs`/`text-3xs`,並遷移 sub-`text-xs` 硬編(39 處)。 +- ⏳ §1.5b 分類/狀態色盤(另立 ticket);§2.3 的 `text-[11px]`/`text-[13px]` 遷移(需視覺 pass); + §5.2 `.section-heading` utility 化。 + +**Phase 2(框架化)**:抽 ``(§6.2)+ Sidebar 收合(§6.3)+響應式斷點(§6.4)。 +每項先落純函式縫再接 UI。 + +**Phase 3(icon 與元件對齊)**:依 §4 擇路線導入/收斂 icon;元件逐步遷到 §5 pattern;補 §8 a11y。 + +每個 Phase 都遵本 repo 的 red→green→integrate→cover TDD 流程 +(見 [`DEV-REQUIREMENTS-capture-onboarding.md`](DEV-REQUIREMENTS-capture-onboarding.md))。 diff --git a/docs/DESIGN-TIMELINE-DISCOVERABILITY.md b/docs/DESIGN-TIMELINE-DISCOVERABILITY.md new file mode 100644 index 00000000..58caccb8 --- /dev/null +++ b/docs/DESIGN-TIMELINE-DISCOVERABILITY.md @@ -0,0 +1,199 @@ +# Timeline 發現性細部重設計 — 滾輪與軸切換 + +以 **v0.11.7** 為基準。這份文件**延伸** [`DESIGN-TIMELINE-INTERACTION.md`](DESIGN-TIMELINE-INTERACTION.md): +它把該文的 **T2(wheel-mode)** 從「事後閃現提示」深化為「常駐、恆真的模式指示」, +並**新增**該文完全未涵蓋的一整軌 —— **軸切換(source ↔ target)發現性 A1–A3**。 + +問題來源:[`UX-AUDIT-2026-08-13.md` §2.4](UX-AUDIT-2026-08-13.md)。 +設計原則沿用既有文件:**核心手勢絕不是祕密**、**一個手勢只做一件事,狀態必須可見**。 + +前置縫(已存在,不重造):`lib/timelineWheel.ts` 的 `wheelMode(ctx)`、 +`lib/timelineModes.ts` 的 `activeModes(state)`、`lib/timelineAxis.ts`、`lib/targetGrouping.ts`。 + +--- + +# 第一部:滾輪發現性(延伸 T2) + +## 1.1 為什麼 T2 的閃現提示還不夠 + +T2 已把決策抽成純函式 `wheelMode(ctx)`(`zoom` / `pan-x` / `scroll-y`),並在 `overflow` 為真時 +於軌道右緣**閃現**一則提示。這解決了「決策不可測」,但**發現性仍是被動的**: + +1. 提示只在 `overflow` 且**已經滾動之後**出現、幾秒後淡出 —— 操作者是**滾錯方向後**才被告知。 +2. `overflow` 本身看不見:它隨視窗高度、開了幾條泳道、有沒有開詳情面板而動態改變 + (`Timeline.tsx` 依 `laneStack.scrollHeight > clientHeight + 1` 判定)。使用者無法預判「這一滾會是平移還是捲動」。 +3. T1 常駐圖例寫死「`⌘-scroll to zoom`」,**從不提 `scroll-y` 或 `⇧` 逃生口**,與實際行為不一致。 + +> 目標:把「這一滾會做什麼、按住修飾鍵又會做什麼」變成**滾之前就看得到**的常駐資訊, +> 而不是滾之後才補救。 + +## 1.2 W1 — 常駐 wheel-mode 指示(affordance) + +在軌道右上角放一個**低權重、恆常存在**的小指示,反映**當前語境下**無修飾鍵滾動的結果, +並在 hover/按下修飾鍵時預覽其他兩種結果。它是 T1 圖例中「zoom」那一格的動態化。 + +``` +┌─ track ────────────────────────────────────────────────┐ +│ ┌────────────┐ │ +│ shell ● ● ● ● ●●● │ ⇔ 平移時間 │ │ ← 未溢出:滾動 = pan-x +│ agent ● ● ●●● ● └────────────┘ │ +└────────────────────────────────────────────────────────┘ + + 溢出時(泳道被裁切在下方): + ┌────────────┐ + │ ⇅ 捲動泳道 │ ← 未按修飾鍵:滾動 = scroll-y + │ ⇧ 改平移 │ 並提示逃生口 + └────────────┘ +``` + +規則: +- **恆常顯示**(不淡出),但視覺極輕(`text-2xs text-zinc-600`,見 `DESIGN-SYSTEM.md §2.3`)。 +- 內容由**當前 live 語境**決定:主行程每次 `overflow` 變化即更新,所以它**先於**滾動就說出結果。 +- 溢出時多顯示一行 `⇧ 改平移`(逃生口),對齊 `wheelMode` 表中 `shift → pan-x` 那列。 +- `⌘/Ctrl` 一律 = zoom,這點在 T1 常駐圖例已有,指示不重複,但 hover 時可預覽(見 W2)。 + +## 1.3 W2 — 修飾鍵預覽(按下即顯,放開即還原) + +當操作者**按住**修飾鍵(尚未滾動)時,指示即時切換到該修飾鍵的結果,形成「按住看預覽 → 滾動執行」的閉環: + +| 當前按住 | 指示顯示 | 對應 `wheelMode` | +|---|---|---| +| 無 | `⇔ 平移時間`(未溢出)/`⇅ 捲動泳道`(溢出) | `pan-x` / `scroll-y` | +| `⇧` | `⇔ 平移時間`(把 scroll-y 換回 pan-x) | `pan-x` | +| `⌘`/`Ctrl` | `⤢ 縮放(游標錨點)` | `zoom` | + +這讓三種模式**在同一個位置、同一個當下**被看見,取代「靠記憶+事後提示」。 + +## 1.4 W3 — 純函式縫 `wheelModeLabel(ctx)` + +在既有 `wheelMode(ctx)` 旁新增一個純函式,把模式對映到顯示標籤與逃生口提示,供 W1/W2 與 T1 圖例共用: + +```ts +// lib/timelineWheel.ts(延伸既有檔) +export interface WheelModeView { + mode: WheelMode // 'zoom' | 'pan-x' | 'scroll-y' + labelKey: string // i18n:⇔ 平移時間 / ⇅ 捲動泳道 / ⤢ 縮放 + escapeHintKey?: string // 僅 scroll-y:'⇧ 改平移' +} + +export function wheelModeLabel(ctx: WheelContext): WheelModeView +``` + +- 由 `wheelMode(ctx)` 推導,**單一事實來源**:T1 圖例、W1 指示、W2 預覽、T2 閃現提示(若保留) + 全部讀這一個函式,杜絕「圖例說 zoom、實際 scroll-y」的漂移。 +- 測試 `test/timeline-wheel.test.ts` 於既有 4 列矩陣上,斷言每列對應的 `labelKey`/`escapeHintKey`。 +- **a11y**:指示是文字(非純動畫),`prefers-reduced-motion` 下 W2 的切換以即時替換呈現、不做過場。 + +## 1.5 與 T2/T6 的關係 + +- W1–W3 **取代** T2(b) 的「閃現」為「常駐」;`wheelMode` 純函式(T2a)維持不動,是共同地基。 +- 長期 **T6(lane 虛擬化)消除 `overflow`**:泳道永遠塞得下、`scroll-y` 不再發生,屆時 W1 溢出分支 + 與逃生口提示一併移除,指示退化為單純的「`⇔ 平移/⌘ 縮放`」。W1–W3 是 T6 前的正解,也向後相容 T6。 + +--- + +# 第二部:軸切換發現性(新增 A1–A3) + +> 既有 `DESIGN-TIMELINE-INTERACTION.md` **未涵蓋**軸切換。這一軌是新的。 + +## 2.1 問題 + +Timeline 有兩種讀法,由標頭一顆 `⊞` 按鈕切換(`Timeline.tsx:2659`),狀態存 +`localStorage['redlog-timeline-lane-axis']`(`Timeline.tsx:1230/1233`),並可由側邊 "Targets" 深連結 +(`redlog-timeline-set-axis` 事件): + +- **source 軸**:依「事件來源類型」分泳道(shell / agent / http / scanner…)——「錄到了什麼」的視角。 +- **target 軸**:依「目標」分泳道(per-host / per-target)——「對每個目標做了什麼」的重建視角。 + +三個發現性問題: + +1. **`⊞` 是個謎樣 icon**:不點下去不知道它切換什麼、也看不出當前在哪個軸。 +2. **切到 target 軸會靜默讓整排 lane 篩選 chip 消失**:條件式 `laneAxis === 'source' &&` + (`Timeline.tsx:2924`)讓 target 軸下所有泳道 chip 不渲染。操作者按一下 `⊞`,標頭右側整區突然清空, + 零說明。 +3. **隱藏狀態變孤兒**:若切換前已隱藏某些泳道,`hiddenLanes` 仍存在於 state 與 localStorage, + 但**能把它們叫回來的個別 chip 沒了**("Show all" 全部還原鈕在 `Timeline.tsx:2917`,條件 `hiddenLanes.size > 0` + 仍在,但細粒度控制消失)。 + +## 2.2 A1 — 讓軸變成有標籤的明確選擇(分段控制取代謎樣 icon) + +把 `⊞` 換成一個**兩段式分段控制**,當前軸恆可見: + +``` +標頭: … [ 來源 | 目標 ] … ← 選中段高亮(青,見 DESIGN-SYSTEM §1.2) + ▔▔▔▔ +hover 「目標」段 tooltip:依目標主機分組,回答「對每個 target 做了什麼」 +``` + +- 兩段各帶簡短標籤(`來源` / `目標`)+ hover 說明各軸意義。 +- 選中段用 §1.2 的青色選中態;未選段 ghost。 +- 純函式縫沿用既有 `lib/timelineAxis.ts`;此步僅換 UI 呈現,行為不變。 +- 深連結("Targets" → target 軸)維持,但落地時分段控制已明確顯示在「目標」段,操作者知道自己在哪。 + +## 2.3 A2 — 保住篩選能力(target 軸不該讓人失去控制) + +target 軸下不能只是「拿掉 source 泳道 chip 就算了」。二選一(建議走 (a)): + +**(a) 提供對等的 target 篩選 chip(建議)** +target 軸下,把「來源泳道 chip」換成「**目標泳道 chip**」(per-host),沿用同一套 chip 元件與 +solo/hidden 語意(`lib/laneVisibility.ts` 已是泛化的可見性推導)。操作者在兩個軸都有一致的篩選手感, +`hiddenLanes` 不再變孤兒 —— 它按當前軸的泳道集合運作。 + +**(b) 若暫不做對等 chip**:至少在 chip 區顯示一行說明,而非靜默清空: +``` +目標軸:泳道依主機分組,來源篩選暫不適用 · [切回來源軸] +``` + +無論 (a)/(b),**"Show all" 還原鈕維持**,確保永遠有一條把隱藏泳道叫回來的路。 + +## 2.4 A3 — 切換當下的轉場說明(用既有 active-modes 機制) + +軸切換是「改變畫面意義」的操作,理應像 T3 的其他 sticky mode 一樣**自我說明**。當軸為非預設(target)時, +於 T3「Active:」列加一枚 chip(沿用 `lib/timelineModes.ts` 的 `activeModes` 縫): + +``` +│ Active: [⊞ 目標軸 ✕] [solo: 10.0.0.5 ✕] clear all │ +``` + +- chip 的 `✕` = 切回 source 軸(`clearAction: 'axis:source'`)。 +- 這把「我現在為什麼看起來不一樣」寫在畫面上,與 wheel(第一部)同一套「狀態必須可見」原則。 +- 純函式擴充:`TimelineModeState` 加 `laneAxis: 'source' | 'target'` 欄位;`activeModes` 在 + `laneAxis === 'target'` 時輸出一枚 chip;`source`(預設)時不輸出,維持「預設無列」不變式。 + +## 2.5 相位軸附註(phase ribbon `▤`) + +同一標頭還有 phase ribbon 切換(`Timeline.tsx:2669`,`▤`)與 source/target **正交**。它同樣是謎樣 icon, +建議一併給文字標籤(`相位`)並納入 A1 的處理慣例,但其開關已有 T3 表格涵蓋(`⇘`/焦點等),此處不重複規範。 + +--- + +# 建置順序與縫(皆純函式先行、可獨立出貨) + +| 步 | 項目 | 純函式縫 | 測試斷言 | +|---|---|---|---| +| 1 | W3 | `lib/timelineWheel.ts` `wheelModeLabel(ctx)` | 4 列矩陣各對應正確 label/escape hint | +| 2 | W1 | (UI,讀 W3;live `overflow`) | 未溢出顯示 pan-x、溢出顯示 scroll-y+逃生口 | +| 3 | W2 | (UI,讀 W3;監聽 keydown/keyup 修飾鍵) | 按 ⇧/⌘ 時預覽切換、放開還原 | +| 4 | A1 | `lib/timelineAxis.ts`(既有) | 分段控制反映當前軸;點擊切換 | +| 5 | A2 | `lib/laneVisibility.ts`(既有,泛化到 target 泳道) | target 軸有對等 chip;hidden 不成孤兒 | +| 6 | A3 | `lib/timelineModes.ts` 擴 `laneAxis` 欄 | target→一枚 chip;source→`[]` 不變式維持 | + +每步遵本 repo 的 red→green→integrate→cover 流程 +([`DEV-REQUIREMENTS-capture-onboarding.md`](DEV-REQUIREMENTS-capture-onboarding.md)), +不觸及擷取管線或證據鏈。 + +# 驗收準則(acceptance) + +- **W**:任一時刻,無修飾鍵滾動的結果**在滾動前**就顯示於軌道;按住 `⇧`/`⌘` 即時預覽對應結果; + 圖例、指示、預覽三處文案由 `wheelModeLabel` 單一來源產生,永不相互矛盾。 +- **A**:當前軸**恆可見且有文字標籤**;切到 target 軸後仍有對等篩選能力,且必有還原路徑; + 非預設軸在「Active:」列有可一鍵清除的 chip;切回 source 後 chip 消失、篩選 chip 復原。 +- **不變式**:全部預設(source 軸、無 sticky mode)時,「Active:」列不渲染(延續 T3)。 + +# 給維護者的開放問題 + +1. **W1 指示位置**:軌道右上角(上方 ASCII)vs. 併入 T1 底部圖例動態化?後者更集中但離游標較遠。 +2. **A2 走 (a) 對等 chip vs (b) 說明**:per-host 泳道在目標很多的交戰中可能爆量, + 是否需要對 target 泳道 chip 先做 top-N + 「更多」收合(呼應 cluster 的 50 筆上限慣例)? +3. **phase ribbon**:是否值得在本輪一併把 `▤`/`⊞`/tz 等標頭 icon 全數文字化(呼應 `DESIGN-SYSTEM §4` icon 收斂), + 或留待 T4「⋯ View」選單重構時一起處理? diff --git a/docs/UX-AUDIT-2026-08-13.md b/docs/UX-AUDIT-2026-08-13.md new file mode 100644 index 00000000..b18192a0 --- /dev/null +++ b/docs/UX-AUDIT-2026-08-13.md @@ -0,0 +1,156 @@ +# UX 全面評估紀錄 — 2026-08-13 + +以 **v0.11.7** 為基準,對整個 renderer 逐頁(16 個視圖)做操作動線評估。切入問題: +「從每個功能與互動往下操作,是否能簡單直覺地完成、並取得需要的資訊?畫面佔比是否可調?」 + +本文是 [`UX-AUDIT-2026-08.md`](UX-AUDIT-2026-08.md)(2026-08-10 複雜度稽核)與 [`UX-TIMELINE-2026-08.md`](UX-TIMELINE-2026-08.md) 的續作,聚焦 +**佔比/縮放(resize)**、**發現性(discoverability)** 與 **跨視圖一致性** 三條軸。 +所有結論皆對照當前已提交程式碼親自核實;行號以核實當下為準。 + +配套的兩份設計文件: +- [`DESIGN-SYSTEM.md`](DESIGN-SYSTEM.md) — 統一色彩/字型/字級/icon/元件/版面框架 +- [`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md) — 滾輪/軸切換發現性細部重設計 + +--- + +## 0. 總體判斷 + +RedLog 的視覺語言統一、資訊密度控制良好、鍵盤化程度高(`⌘1–9` 導覽、`⌘K` palette、`⌘.` +暫停錄製、HUD 角落吸附)。對「證據鏈稽核」這個核心任務,資訊呈現是稱職的。 + +兩個系統性弱點: + +1. **畫面佔比幾乎全部寫死、不可調整**(§1)。 +2. **重要操作藏在看不見的手勢/修飾鍵裡**,發現性差,靠原生 `title` tooltip 補救(§3、§4)。 + +Dashboard 與 HUD Overlay 是全 app 設計最成熟的兩處,可作為其他視圖的標竿。 + +--- + +## 1. 佔比/縮放(resize)— 最明顯的結構性缺口 + +| 機制 | 現況 | 位置 | +|---|---|---| +| 主視窗大小 | ✅ 可調、會記住(預設 1100×700,最小 800×500) | `src/main/windows.ts:10-15` | +| 全域 UI 縮放 | ✅ 有(Chromium `zoom` 0.9–1.5,Settings ▸ Advanced) | `src/renderer/src/main.tsx:11` | +| Sidebar 寬度 | ❌ 固定 140px,不可收合/拉寬 | `Sidebar.tsx:120` | +| 主內容區 vs Sidebar 佔比 | ❌ 純 `flex-1`,無可拖曳分割線 | `App.tsx:248` | +| Timeline 詳情面板高度 | ✅ **全 app 唯一**可拖曳縮放(上下),雙擊重設,存 localStorage | `Timeline.tsx:3571` | +| Timeline lane 標籤欄/minimap/事件列表 | ❌ 固定寬高(`LABEL_W`、`h-9`、18/22vh 寫死) | `Timeline.tsx:3530` | +| FindingsView 左欄 | ❌ 硬編 `w-80` | `FindingsView.tsx:98` | +| EventMarker 彈窗 | ❌ 硬編 `w-[420px]` | `EventMarker.tsx:72` | +| Terminal 分割窗(並排) | ❌ 只有分頁,不能左右/上下分割 | `TerminalView.tsx` | +| 跨視圖並排(如 Timeline + Terminal) | ❌ 一次只能顯示一個 view | `App.tsx:251-280` | +| 響應式佈局 | ❌ 全 renderer 僅 **1 處** `md:` 斷點;`grid-cols-3/4` 全寫死 | — | + +**影響**:長 URL/note/loot preview 只能靠 `truncate`,操作者無法把某欄拉寬讀全值; +800px 最小寬度下 `grid-cols-3`(Dashboard 統計卡、Screenshots 網格)會擠壓。 + +**建議(依投報比)**: +1. **Sidebar 可收合**(icon-only ↔ 展開)+記住狀態 — 成本最低、對小螢幕效益最大。 +2. **共用 ``**:Transcript 問答塊、FindingsView 左右欄、Loot 清單皆適用;佔比存 localStorage + (Timeline 詳情面板已證明此模式在本專案可行)。 +3. **Terminal 分割窗**:滿足「一邊跑指令一邊看時間軸」的高頻紅隊需求。 +4. 關鍵格線補 `sm:`/`md:` 斷點。 + +框架化細節見 [`DESIGN-SYSTEM.md` §6 版面與縮放框架](DESIGN-SYSTEM.md)。 + +--- + +## 2. 逐視圖操作動線與合理性 + +### 2.1 完整視圖清單(16) +ProjectPicker、Dashboard、Terminal、Timeline、Transcript、Screenshots、Scope、Loot、 +Marks(QuickMarks)、Settings、Search、Sidebar、StatusBar、Overlay HUD、EventMarker、IPStatusCard。 +("Targets" 側邊項不是獨立頁,是深連結到 Timeline 的 target 軸。) + +### 2.2 標竿頁 + +**Dashboard** — IA 全 app 最佳。擷取健康卡用「例外報告」邏輯(只顯示壞掉的來源)、 +dark/partial/healthy 三態、onboarding checklist + 單一主 CTA,回答「時間軸空的、然後呢」。 +`App.tsx:373-593`。 + +**HUD Overlay** — glance 操作的典範:可拖曳、角落鍵盤吸附、`⚡` 快速標記(不搶焦點、inline `✓` 確認)、 +pass-through 半透明但外部 IP 保持不透明、exposed 時整框紅色告警、內容自動 `autosize`。`OverlayApp.tsx`。 + +### 2.3 稱職但有小缺點 + +- **ProjectPicker**:最近專案一鍵開、進階設定收摺疊區不淹沒新手。**刪除專案無二次確認**。 +- **Terminal**:多分頁、`⌘T/⌘W/⌘⇧[]`、字級 `⌘±`、per-pane 搜尋、關閉有確認、分頁顯示 cwd+exit code。 + 唯一大缺點是**無分割窗**(§1)。 +- **Transcript**:事件折成問答直讀、filter+kind chips+Export MD+跳 Timeline、標示「為何無輸出」四態。設計好,但不能調欄寬。 +- **Settings**:五分頁(Capture/Scope/Evidence/OPSEC/Advanced)**均正常運作**,Advanced 用 5 個 + `Collapsible` 做漸進揭露,有設定內搜尋、auto-save。真實缺點見 §2.5。 + +### 2.4 Timeline — 痛點最集中 + +資料與泳道設計本身沒問題,問題在**互動發現性**。完整細節與重設計見 +[`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md)。摘要: + +- **滾輪行為取決於看不見的狀態**:無修飾鍵滾動時,泳道「有無溢出」決定它是平移時間軸還是垂直捲動; + 溢出與否隨視窗高度/泳道數動態改變(`lib/timelineWheel.ts:41`)。補救提示只在滾動當下閃 2.5 秒(事後通知)。 +- **切到 target 軸會靜默讓整排 lane 篩選 chip 消失**(`Timeline.tsx:2924` 的 `laneAxis === 'source' &&` 條件)。 +- **無 fit-to-window**:`100% ↺` 在 <2000px 寬視窗按了仍看不到全部(`baseTrackW` 下限 2000)。 +- 雙擊只在 4px 把手有意義;Alt-click solo 泳道只寫在 tooltip;`↑↓` 走訪事件有「須先開詳情面板」的隱形前提。 +- `⌘K` 開本地 palette、`⌘/` 卻跳全域 Search,分歧只寫在 `App.tsx:136` 註解。 + +### 2.5 Settings 的真實可改善點(已更正) +> 註:先前一份自動盤點誤讀舊版檔案,宣稱「三分頁空白/typecheck 紅/死碼」。經 `npx tsc --noEmit -p tsconfig.web.json` +> 實測乾淨、render 條件為新五分頁、i18n key 齊全,**該說法不成立**,不予採納。 + +- **auto-save + 手動「儲存」按鈕 + 「會自動儲存」提示三者並存**,訊號矛盾(`Settings.tsx:738-755`); + 且儲存失敗被 `.catch(() => {})` 靜默吞掉(`:160`)。 +- **搜尋涵蓋率低**:35 個設定群組有 16 個 `labelKeys: []`(約 46%),只能搜群組標題; + 搜「proxy/port/token/regex」等實際存在的欄位是零結果。 +- **危險操作確認不一致**:只有「刪除操作員」有 `confirmDialog`(`:1789`); + **token rotate、revoke、匯入 profile(整包覆蓋設定,`:465-467`,無 diff 預覽)都沒有確認**。 + +--- + +## 3. 跨視圖共同痛點 + +| # | 痛點 | 具體位置 | +|---|---|---| +| C1 | **資訊只能看不能取用** — 🔧 **Loot 已修**(2026-08-13):Loot 改 SplitPane 清單|詳情,詳情面板顯示完整值 + 複製鈕(Transcript 詳情亦有 input/output 複製)。**待做**:IP/mark URL 點擊複製 | `LootPanel.tsx` `LootDetail`、`TranscriptView.tsx` `TranscriptDetail` | +| C2 | **跳轉 Timeline 靜默失效**:`onOpenInTimeline` 為 optional,父層沒傳時按鈕 disabled 但仍佔位 | `SearchPanel.tsx:122`、`ScopeStatus.tsx:95`、`LootPanel.tsx:169` | +| C3 | **空狀態不一致**:Transcript/Loot/Findings 有 CTA;**ScopeStatus 未設 scope 只有圖示+文字、無 CTA**(死路);Timeline「被篩到 0 筆」與「真的沒事件」畫面相同,前者無「清除篩選?」 | `ScopeStatus.tsx:69`、`Timeline.tsx:2442` | +| C4 | **篩選入口出現閾值不一**:Findings 搜尋框要 marks≥5、Loot chips 要型別≥2、Search chips 要型別>1 — 資料少時找不到本以為存在的篩選 | `FindingsView.tsx:136`、`LootPanel.tsx:121` | +| C5 | **幾乎全靠原生 `title` tooltip** 傳達關鍵手勢(~1 秒延遲、無法設樣式、觸控/鍵盤拿不到) | 全域 | +| C6 | **Modal 無 focus trap**,Tab 會跑出對話框 | `EventMarker.tsx:31` | +| C7 | **持久化路徑分裂**:Timeline 記近 20 個 localStorage key,Settings 幾乎不記 UI 狀態(tab/搜尋字串每次重置),Overlay 偏好走 config 而非 localStorage | 見各檔 | +| C8 | **點擊目標過小**(Apple HIG 偏離)— 🔧 **已實作 hit-slop**(2026-08-13):`.hit-target`/`.hit-target-v` 套用 14 處(Timeline 拖曳把手+標頭小鈕、Terminal 關閉/搜尋鈕、截圖刪除)。剩 `absolute` 定位的 filter 清除 × 與 Settings ListField × 待增量 | `styles/index.css` `.hit-target`、`Timeline.tsx:3571`、`App.tsx:1086` | +| C9 | **必要文字偏小**(Apple HIG 偏離)— 🔧 **已實作**(2026-08-13):Timeline 互動圖例與滾輪提示(必要導覽文字)由 `text-2xs`→`text-xs`;palette 分類標 `text-3xs`→`text-2xs`。其餘微字級經審視為裝飾性 meta,維持 | `Timeline.tsx:3023`(legend)、`Timeline.tsx:3064`(wheel hint) | + +--- + +## 4. 優先改善清單 + +**P0 — 結構性(直接回應「佔比可調」)** +1. ✅ **已實作**(2026-08-13):Sidebar 可收合(`⌘B`/toggle)+ 共用 ``(`lib/splitPane.ts` 純函式 + `components/SplitPane.tsx` + 7 例測試),已套用 FindingsView 左右欄。→ `DESIGN-SYSTEM.md §6.2/6.3` +2. ✅ **Terminal 分割窗已實作**(2026-08-13):`⌘D`/◫ 把一個 tab 分成兩個並排 shell(各自 pty),中間 SplitPane 可拖曳調整佔比;每 pane 可獨立關閉/重啟。(跨視圖 Timeline+Terminal 並排仍未做,需 app-shell 級變更。) +3. 🔧 Loot 已修(詳情面板完整值 + 複製,C1);IP/mark 點擊複製待做。 + +**P1 — 正確性/一致性** +4. Settings:「儲存」鈕與「自動儲存」文案二擇一;儲存失敗改為可見錯誤(C 見 §2.5)。 +5. Settings:token rotate/revoke、匯入 profile 補二次確認 + diff 預覽。 +6. Settings 搜尋補齊 `labelKeys`(讓 proxy/port/token 搜得到)。 +7. ScopeStatus 空狀態補 CTA;Timeline 加「0 筆結果/清除篩選」空狀態(C3)。 +8. 設計 token 收斂:修正殘留亮色 accent、統一字級(→ `DESIGN-SYSTEM.md §1–3`)。 +9. Apple HIG 對齊:✅ C8 命中區下限已實作(`.hit-target`/`.hit-target-v`,≥28px/把手 ≥8px);✅ C9 必要文字 ≥`text-xs`(圖例/滾輪提示已提級,微字級收窄為裝飾性 meta)(→ `DESIGN-SYSTEM.md §0.1/§2.3`)。 + +**P2 — 發現性打磨** +9. Timeline 滾輪模式改常駐視覺指示;軸切換對消失的 lane chip 給說明。→ `DESIGN-TIMELINE-DISCOVERABILITY.md` +10. Timeline 加真正的 fit-to-window。 +11. 關鍵手勢說明從原生 tooltip 升級為自訂 tooltip/圖例常駐文字(C5)。 +12. Modal focus trap(C6);跳轉按鈕在無 handler 時隱藏而非 disabled 佔位(C2)。 + +--- + +## 5. 純函式測試縫(沿用本 repo TDD 慣例) + +每項 UI 變更都應先落在一個純函式上,再接 UI(見 [`DEV-REQUIREMENTS-capture-onboarding.md`](DEV-REQUIREMENTS-capture-onboarding.md))。本輪建議的縫: + +- `splitPaneClamp(px, min, max, containerPx)` — 分割線位置夾擠(複用 Timeline 詳情面板既有邏輯)。 +- `settingsSearchIndex(groups)` — 由 group 定義+i18n 產出可搜尋 term(含 hint),取代手維護的 `labelKeys`。 +- `emptyStateFor(view, ctx)` 擴充 `filtered-empty` 分支(C3)。 +- `wheelMode(ctx)` 已存在(`lib/timelineWheel.ts`);發現性重設計在其之上加 `wheelModeLabel(ctx)`。 From d585b69b5b2c93b612e45b17b1aeb2ad19912996 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 10:15:06 +0800 Subject: [PATCH 26/47] style(ux): consolidate design tokens + HIG hit-targets & text sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token pass (DESIGN-SYSTEM §1-3, §0.1): - Soften residual bright accents (glow-red/cyan, .drag-over, overlay cyan) to the `soften` palette so nothing "vibrates" on the dark surface. - Add text-2xs/text-3xs fontSize tokens; migrate the sub-text-xs hardcoded px sizes (Timeline, Settings) so they scale with the 17px base, not only --app-zoom. - C8: `.hit-target`/`.hit-target-v` hit-slop utilities; apply to Timeline's detail-panel drag handle and small icon buttons (≥28px target, HIG). - C9: lift essential-but-tiny guidance text (Timeline legend + wheel-mode hint) to text-xs; reserve text-3xs for corner/count badges. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/OverlayApp.tsx | 8 +-- src/renderer/src/components/Settings.tsx | 4 +- src/renderer/src/components/Timeline.tsx | 70 ++++++++++++------------ src/renderer/src/styles/index.css | 25 ++++++++- tailwind.config.js | 20 +++++-- 5 files changed, 81 insertions(+), 46 deletions(-) diff --git a/src/renderer/src/OverlayApp.tsx b/src/renderer/src/OverlayApp.tsx index 20bc0092..f7bde035 100644 --- a/src/renderer/src/OverlayApp.tsx +++ b/src/renderer/src/OverlayApp.tsx @@ -131,7 +131,7 @@ export default function OverlayApp(): JSX.Element { // same factor. Without this the middle gap stays fixed while text shrinks — // small scale looks loose, large scale looks cramped. const px = (n: number): number => Math.round(n * s) - const hair = + const hair = const safety = status?.ipSafety ?? 'unknown' const link = status?.link @@ -153,8 +153,8 @@ export default function OverlayApp(): JSX.Element { const iconBtn: React.CSSProperties = { color: CYAN, fontSize: fs(10), cursor: 'pointer', width: 18, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', - background: interactive ? 'rgba(34,211,238,0.10)' : 'transparent', - border: `1px solid ${interactive ? 'rgba(34,211,238,0.35)' : 'transparent'}`, + background: interactive ? hexA(CYAN, 0.10) : 'transparent', + border: `1px solid ${interactive ? hexA(CYAN, 0.35) : 'transparent'}`, transition: 'background 0.12s, border-color 0.12s' } const latestPivot = pivots[0] @@ -184,7 +184,7 @@ export default function OverlayApp(): JSX.Element { }} > {/* scanlines */} -
+
{/* buttons (top-right): expand + hide only. The pin toggle used to live here too, but the operator asked to move it into the diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index d7bec761..4f7ddab7 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -237,7 +237,7 @@ export default function Settings(): JSX.Element { className="w-full flex items-center justify-between gap-3 px-3 py-2 text-left bg-zinc-900 border border-redlog-border rounded hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40" > {g.title} - {tabLabel(g.tab)} + {tabLabel(g.tab)} )) ) @@ -2636,7 +2636,7 @@ function MarketplacePanel({ t }: { t: (key: string, vars?: Record ( -

+

{(k as { fingerprint?: string }).fingerprint ?? k.publicKey.slice(0, 16)}{k.label ? ` · ${k.label}` : ''}

))} diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index ef4d3bbd..4d885ba7 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -2521,16 +2521,16 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { onClick={() => activatePaletteItem(item)} className={`w-full text-left px-3 py-1.5 flex items-center gap-2 ${isSel ? 'bg-white/10' : 'hover:bg-white/5'}`} > - + {t(groupKey)} {item.label} - {item.sub} + {item.sub} ) })}
-
+
{t('timeline.palette.footer')}
@@ -2552,7 +2552,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {t('timeline.help.title')} @@ -2575,7 +2575,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { ))}
-
+
{t('timeline.help.footer')}
@@ -2595,7 +2595,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { @@ -2620,7 +2620,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { makes the affordance visible; the modal itself lists everything. */} @@ -2629,7 +2629,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: {
@@ -2642,7 +2642,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { >{Math.round(zoom * 100)}% ↺ @@ -2684,7 +2684,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {collapseAgentTurns ? '⇘' : '⇗'} {t('timeline.collapseAgent.label')} {collapseAgentTurns && hiddenAgentTurnCount > 0 && ( - −{hiddenAgentTurnCount} + −{hiddenAgentTurnCount} )} @@ -2710,7 +2710,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { ⋯ {t('timeline.compressGaps.label')} {compressGaps && timeMap.gaps.length > 0 && ( - {timeMap.gaps.length} + {timeMap.gaps.length} )} ) : null} @@ -2766,7 +2766,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { setFollowMode(true) }} title={isLive ? t('timeline.follow.jumpToNow') : t('timeline.follow.jumpToNow')} - className={`whitespace-nowrap text-[10px] font-mono px-1.5 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 ${ + className={`whitespace-nowrap text-2xs font-mono px-1.5 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 ${ isLive ? 'text-emerald-100 bg-emerald-600/40 ring-1 ring-emerald-500/40' : 'text-amber-300 bg-amber-500/10 hover:bg-amber-500/20' @@ -2776,7 +2776,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { onClick={() => setFollowMode((v) => !v)} title={followMode ? t('timeline.follow.pauseHint') : t('timeline.follow.resumeHint')} aria-label={followMode ? t('timeline.follow.pauseHint') : t('timeline.follow.resumeHint')} - className="w-5 h-5 flex items-center justify-center text-[10px] text-zinc-500 hover:text-zinc-200 bg-zinc-800/60 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-500" + className="w-5 h-5 flex items-center justify-center text-2xs text-zinc-500 hover:text-zinc-200 bg-zinc-800/60 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-500 hit-target" >{followMode ? '⏸' : '▶'}
) @@ -2803,7 +2803,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {viewsOpen && (
-
{t('timeline.views.saveNew')}
+
{t('timeline.views.saveNew')}
setViewsName(e.target.value)} @@ -2830,14 +2830,14 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { className="flex-1 text-left text-xs font-mono text-zinc-200 truncate" title={v.name} >{v.name} - + {formatTs(v.createdAt, tz, projectTz, 'time')}
))} @@ -2966,7 +2966,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { data-testid="timeline-active-modes" className="flex items-center gap-1.5 px-4 py-1.5 border-b border-zinc-800/80 bg-zinc-900/40 shrink-0 overflow-x-auto" > - + {t('timeline.mode.active')} {modeChips.map((chip) => ( @@ -2985,7 +2985,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { ))}
@@ -3020,7 +3020,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { the full ? cheatsheet — so pan, zoom and filter are never a secret. The {mod} token resolves once via modKey(platform), shared with the F5 registry. Collapsible; the choice persists in legendCollapsed. */} -
+
{!legendCollapsed && (
{t('timeline.legend.pan')} @@ -3044,7 +3044,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { )} )} - {truncated &&

{t('timeline.replay.truncated')}

} + {truncated &&

{t('timeline.replay.truncated')}

}
) } diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css index 6bac813a..278a5962 100644 --- a/src/renderer/src/styles/index.css +++ b/src/renderer/src/styles/index.css @@ -54,7 +54,30 @@ body { } .drag-over { - border-top: 2px solid #ef4444 !important; + border-top: 2px solid #d75f63 !important; /* soften map red-400 (DESIGN-SYSTEM §1.5) */ +} + +/* Hit-slop: enlarge a small control's pointer/click target WITHOUT growing its + * visual box, via a transparent ::before overlay. Apple HIG asks for 44pt touch + * targets; RedLog is a dense pointer-driven desktop tool, so we set a desktop + * floor of ~28px (see docs/DESIGN-SYSTEM.md §0.1, audit C8). The overlay claims + * the surrounding dead space, so keep two hit-target controls ≥12px apart — the + * slops then meet only in the gap between them, never over a neighbour's glyph. + * The control must be able to position the overlay: `.hit-target` sets + * position:relative for you (a button is static by default). */ +.hit-target { position: relative; } +.hit-target::before { + content: ''; + position: absolute; + inset: -0.375rem; /* -6px each side: a 16px control → 28px, a 20px control → 32px */ +} +/* Vertical-only slop for thin, full-width drag handles (e.g. the 4px detail- + * panel grip) — grows the grab band without widening it into neighbours. */ +.hit-target-v { position: relative; } +.hit-target-v::before { + content: ''; + position: absolute; + inset: -0.5rem 0; /* -8px top/bottom only: a 4px handle → 20px grab height */ } .timeline-grabbing { diff --git a/tailwind.config.js b/tailwind.config.js index 0cd9ceaf..f844f1e6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -37,11 +37,23 @@ module.exports = { 'text-dim': '#a1a1aa' } }, + // Two named micro tiers so the sub-`text-xs` labels stop being hardcoded + // `text-[10px]`/`text-[9px]`/`text-[8px]` px (which only scaled with + // --app-zoom, not the 17px base). String form = font-size only, matching + // the arbitrary-value behaviour these replace. See docs/DESIGN-SYSTEM.md §2.3. + fontSize: { + '2xs': '0.625rem', // ~10.6px @17px base — chip counts, freshness, minor meta + '3xs': '0.5rem' // ~8.5px @17px base — the densest badges/labels + }, boxShadow: { - 'glow-red': '0 0 12px 0 rgba(239, 68, 68, 0.15)', - 'glow-red-sm': '0 0 6px 0 rgba(239, 68, 68, 0.1)', - 'glow-cyan': '0 0 14px 0 rgba(34, 211, 238, 0.18)', - 'glow-cyan-sm': '0 0 6px 0 rgba(34, 211, 238, 0.25)', + // Softened to match the `soften` accent map (red-400 #d75f63 = rgb(215,95,99), + // cyan-400 #3fc7d6 = rgb(63,199,214)) so glows no longer render the pre-soften + // bright #ef4444 / #22d3ee that "vibrates" against the dark surface. See + // docs/DESIGN-SYSTEM.md §1.5. + 'glow-red': '0 0 12px 0 rgba(215, 95, 99, 0.15)', + 'glow-red-sm': '0 0 6px 0 rgba(215, 95, 99, 0.1)', + 'glow-cyan': '0 0 14px 0 rgba(63, 199, 214, 0.18)', + 'glow-cyan-sm': '0 0 6px 0 rgba(63, 199, 214, 0.25)', 'card': '0 1px 3px 0 rgba(0, 0, 0, 0.4)', 'card-hover': '0 4px 12px 0 rgba(0, 0, 0, 0.5)' }, From 2b1b3360122edd76374718d69a8c2ad7ad23d564 Mon Sep 17 00:00:00 2001 From: guan4tou2 Date: Thu, 13 Aug 2026 10:15:23 +0800 Subject: [PATCH 27/47] feat(ux): resizable SplitPane, collapsible sidebar, terminal split + icon tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct-manipulation layout (DESIGN-SYSTEM §6) — the operator can now set panel proportions, the thing the UX audit flagged as missing app-wide: - (pure splitPaneClamp seam + 7 tests): draggable divider, double-click reset, keyboard resize, persisted per id, fractional default. - FindingsView, Loot, Transcript adopt list|detail via SplitPane. Loot and Transcript detail panes show the full value with copy buttons — closes audit C1 (Loot preview was truncated + uncopyable). - Sidebar collapses to icon-only (52px) via ⌘B / toggle, persisted; badges become corner dots. - Terminal split panes: ⌘D / ◫ splits a tab into two side-by-side shells (each its own pty), resizable between them; per-pane close/restart. Tab model refactored to Tab { panes: Pane[] }. Also consolidate entity glyphs into lib/icons.ts (single source; fixes transcript/marks empty-state drift) across the touched views + i18n keys. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/App.tsx | 28 +- src/renderer/src/components/FindingsView.tsx | 12 +- src/renderer/src/components/LootPanel.tsx | 256 +++++++++------ src/renderer/src/components/ScopeStatus.tsx | 3 +- src/renderer/src/components/Sidebar.tsx | 91 +++-- src/renderer/src/components/SplitPane.tsx | 155 +++++++++ src/renderer/src/components/StatusBar.tsx | 3 +- src/renderer/src/components/TerminalView.tsx | 310 ++++++++++++------ .../src/components/TranscriptView.tsx | 99 +++++- src/renderer/src/i18n/en.json | 17 + src/renderer/src/i18n/zh-TW.json | 17 + src/renderer/src/lib/icons.ts | 41 +++ src/renderer/src/lib/splitPane.ts | 23 ++ test/split-pane.test.ts | 41 +++ 14 files changed, 851 insertions(+), 245 deletions(-) create mode 100644 src/renderer/src/components/SplitPane.tsx create mode 100644 src/renderer/src/lib/icons.ts create mode 100644 src/renderer/src/lib/splitPane.ts create mode 100644 test/split-pane.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6ba2e547..c1ac7a35 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -22,6 +22,7 @@ import { emptyStateFor } from './lib/emptyState' import { shortcutsForScope, MOD_TOKEN } from './lib/shortcuts' import { useI18n } from './i18n' import { loadSidebarOrder, onSidebarOrderChanged, type SidebarViewId } from './lib/sidebarOrder' +import { ICON } from './lib/icons' import logoUrl from './assets/logo.svg' type View = SidebarViewId | 'settings' | 'search' @@ -149,6 +150,15 @@ export default function App(): JSX.Element { }).catch(() => {}) return } + // ⌘/Ctrl+B toggles the sidebar collapse (VS Code convention). The Sidebar + // owns the state + persistence; we just fire the event it listens for. + if (cmd && !e.shiftKey && !e.altKey && (e.key === 'b' || e.key === 'B')) { + const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase() + if (tag === 'input' || tag === 'textarea' || (e.target as HTMLElement | null)?.isContentEditable) return + e.preventDefault() + window.dispatchEvent(new CustomEvent('redlog:toggle-sidebar')) + return + } // ⌘⇧⌥ + arrow snaps the HUD to a corner of the display it's currently // on (audit finding #53). Was ⌘⌥ initially but macOS Sequoia's built-in // window tiling grabs that combo before the app sees it, so add Shift @@ -231,7 +241,7 @@ export default function App(): JSX.Element { title={t('app.search')} aria-label={t('app.search')} > - ⌕ + {ICON.search} diff --git a/src/renderer/src/components/FindingsView.tsx b/src/renderer/src/components/FindingsView.tsx index f8fc89a5..aed21f81 100644 --- a/src/renderer/src/components/FindingsView.tsx +++ b/src/renderer/src/components/FindingsView.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' import { EmptyState } from './Feedback' +import { ICON } from '../lib/icons' +import { SplitPane } from './SplitPane' import { emptyStateFor } from '../lib/emptyState' import { confirm } from './ConfirmDialog' import { toast } from './Toast' @@ -94,8 +96,8 @@ export function QuickMarksView({ onOpenInTimeline, onEmptyAction }: { } return ( -
-
+ +
@@ -177,7 +179,7 @@ export function QuickMarksView({ onOpenInTimeline, onEmptyAction }: { const es = emptyStateFor('marks', { captureDark: false }) return (
-
+
{creating && ( )}
-
+ ) } diff --git a/src/renderer/src/components/LootPanel.tsx b/src/renderer/src/components/LootPanel.tsx index 78727633..fc6663bc 100644 --- a/src/renderer/src/components/LootPanel.tsx +++ b/src/renderer/src/components/LootPanel.tsx @@ -1,23 +1,31 @@ import { useState, useEffect, useMemo } from 'react' import { useI18n } from '../i18n' import { LoadingSpinner, EmptyState } from './Feedback' +import { ICON } from '../lib/icons' import { emptyStateFor } from '../lib/emptyState' import { toast } from './Toast' +import { SplitPane } from './SplitPane' + +interface LootEvent { + id: string + timestamp: number + targetId: string | null + source: string | null + matches: Array<{ type: string; confidence: string; preview: string }> +} export function LootPanel({ onOpenInTimeline, onEmptyAction }: { onOpenInTimeline?: (eventId: string, ts: number) => void onEmptyAction?: (target: string) => void }): JSX.Element { - const [lootEvents, setLootEvents] = useState - }>>([]) + const [lootEvents, setLootEvents] = useState([]) const [loading, setLoading] = useState(true) // Filter by loot type; null = show all. Chips appear at the top with counts. const [typeFilter, setTypeFilter] = useState(null) + // Selected event → shown in the detail pane (full, copyable values). null = + // nothing picked yet. Addresses audit C1: the list preview is truncated, so + // the detail pane is where the operator reads and copies the whole value. + const [selectedId, setSelectedId] = useState(null) // Dedup toggle: same (type, preview) captured from two commands used to // appear twice — audit finding #18. Default on; a chip toggles it off if // the operator wants the raw stream (e.g. verifying detection cadence). @@ -96,11 +104,32 @@ export function LootPanel({ onOpenInTimeline, onEmptyAction }: { ) } + // Empty state fills the whole view — nothing to split yet. + if (lootEvents.length === 0) { + const es = emptyStateFor('loot', { captureDark: false }) + return ( +
+ onEmptyAction?.(es.action!.target) } + : undefined} + /> +
+ ) + } + + const selected = visibleList.find((le) => le.id === selectedId) ?? null + return ( -
-
-

{t('loot.title', { count: visibleMatchCount })}

- {lootEvents.length > 0 && ( + + {/* Left: the loot list. Cards now SELECT (→ detail pane) rather than jump; + the jump moved into the detail pane alongside the full, copyable value. */} +
+
+

{t('loot.title', { count: visibleMatchCount })}

- )} -
+
- {/* Filter + dedup chips (only when there's enough loot to matter) */} - {lootEvents.length > 0 && (() => { - const typeCounts = new Map() - for (const le of lootEvents) for (const m of le.matches) typeCounts.set(m.type, (typeCounts.get(m.type) ?? 0) + 1) - const types = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]) - if (types.length < 2 && lootEvents.length < 5) return null - return ( -
- {types.length > 1 && types.map(([type, count]) => ( - - ))} - - - -
- ) - })()} + {/* Filter + dedup chips (only when there's enough loot to matter) */} + {(() => { + const typeCounts = new Map() + for (const le of lootEvents) for (const m of le.matches) typeCounts.set(m.type, (typeCounts.get(m.type) ?? 0) + 1) + const types = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]) + if (types.length < 2 && lootEvents.length < 5) return null + return ( +
+ {types.length > 1 && types.map(([type, count]) => ( + + ))} + + + +
+ ) + })()} - {lootEvents.length === 0 ? (() => { - const es = emptyStateFor('loot', { captureDark: false }) - return ( - onEmptyAction?.(es.action!.target) } - : undefined} - /> - ) - })() : ( - // v0.7.1 P1: rendering uses the same `visibleList` that feeds the - // header count, so what you see and what the header says can never - // disagree. Each source event keeps its own grouping so the "click - // card → jump to timeline" flow still lands on a real event id.
{visibleList.length === 0 && (

{t('loot.noMatches')}

)} - {visibleList.map((le, i) => ( -
onOpenInTimeline?.(le.id, le.timestamp)} - onKeyDown={onOpenInTimeline ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenInTimeline(le.id, le.timestamp) } } : undefined} - className={`bg-zinc-900 border border-zinc-800 rounded-lg p-3 ${onOpenInTimeline ? 'cursor-pointer hover:border-cyan-500/40 hover:bg-zinc-900/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 transition-colors' : ''}`} - title={onOpenInTimeline ? t('loot.openInTimelineHint') : undefined} - > -
-
+ {visibleList.map((le, i) => { + const isSel = selectedId === le.id + return ( +
setSelectedId(le.id)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(le.id) } }} + className={`bg-zinc-900 border rounded-lg p-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 transition-colors ${ + isSel ? 'border-cyan-500/60 bg-zinc-900/60' : 'border-zinc-800 hover:border-cyan-500/40 hover:bg-zinc-900/60' + }`} + title={t('loot.selectHint')} + > +
{new Date(le.timestamp).toLocaleString()} {le.source && ( · {t('loot.from')} {le.source} )} - {le.targetId && ( - · {t('loot.target')} {le.targetId} - )} · {t('loot.items', { count: le.matches.length })}
- {onOpenInTimeline && ( - - {t('loot.openInTimeline')} → - - )} -
- {le.matches.map((m, j) => ( -
-
- - {m.type.replace(/_/g, ' ')} - - - {m.confidence} - + {le.matches.map((m, j) => ( +
+
+ + {m.type.replace(/_/g, ' ')} + + + {m.confidence} + +
+
{m.preview}
-
{m.preview}
-
- ))} -
- ))} + ))} +
+ ) + })} +
+
+ + + + ) +} + +// The detail pane: the full, un-truncated, copyable value for the selected loot +// event (audit C1 — the list preview is `truncate` and had no copy affordance). +function LootDetail({ le, typeColor, onOpenInTimeline }: { + le: LootEvent | null + typeColor: Record + onOpenInTimeline?: (eventId: string, ts: number) => void +}): JSX.Element { + const { t } = useI18n() + if (!le) { + return ( +
+ {ICON.loot} + {t('loot.selectPrompt')} +
+ ) + } + const copy = (value: string): void => { + navigator.clipboard.writeText(value) + .then(() => toast(t('loot.copied'), 'success')) + .catch(() => toast(t('loot.copyFailed'), 'error')) + } + return ( +
+
+
+
{new Date(le.timestamp).toLocaleString()}
+ {le.source &&
{t('loot.from')} {le.source}
} + {le.targetId &&
{t('loot.target')} {le.targetId}
} +
+ {onOpenInTimeline && ( + + )} +
+ {le.matches.map((m, j) => ( +
+
+ {m.type.replace(/_/g, ' ')} + {m.confidence} + +
+ {/* Full value — selectable + wrapped, no truncation. */} +
{m.preview}
- )} + ))}
) } diff --git a/src/renderer/src/components/ScopeStatus.tsx b/src/renderer/src/components/ScopeStatus.tsx index 5cc93c2f..70f30166 100644 --- a/src/renderer/src/components/ScopeStatus.tsx +++ b/src/renderer/src/components/ScopeStatus.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useI18n } from '../i18n' import { LoadingSpinner } from './Feedback' +import { ICON } from '../lib/icons' import { toast } from './Toast' export function ScopeStatus({ onOpenInTimeline }: { onOpenInTimeline?: (ts: number) => void } = {}): JSX.Element { @@ -69,7 +70,7 @@ export function ScopeStatus({ onOpenInTimeline }: { onOpenInTimeline?: (ts: numb {!configured && (
- ⊘ + {ICON.scope}

{t('scope.hint')} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 65dec983..79ca78f8 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useI18n } from '../i18n' +import { ICON } from '../lib/icons' interface SidebarProps { active: string @@ -43,6 +44,24 @@ export default function Sidebar({ active, onNavigate }: SidebarProps): JSX.Eleme const didDrag = useRef(false) const { t } = useI18n() + // Collapsible sidebar (DESIGN-SYSTEM §6.3): icon-only ↔ labelled, persisted. + // Toggled by the chevron below or ⌘B (App dispatches redlog:toggle-sidebar). + const [collapsed, setCollapsed] = useState(() => { + try { return localStorage.getItem('redlog-sidebar-collapsed') === '1' } catch { return false } + }) + const toggleCollapsed = useCallback(() => { + setCollapsed((c) => { + const next = !c + try { localStorage.setItem('redlog-sidebar-collapsed', next ? '1' : '0') } catch { /* ignore */ } + return next + }) + }, []) + useEffect(() => { + const h = (): void => toggleCollapsed() + window.addEventListener('redlog:toggle-sidebar', h) + return () => window.removeEventListener('redlog:toggle-sidebar', h) + }, [toggleCollapsed]) + useEffect(() => { window.redlog.loot.getCount().then(setLootCount) window.redlog.scope.getViolationCount().then(setScopeViolations) @@ -61,15 +80,15 @@ export default function Sidebar({ active, onNavigate }: SidebarProps): JSX.Eleme ) : null const itemMap: Record = { - dashboard: { id: 'dashboard', label: t('sidebar.dashboard'), icon: '◉' }, - terminal: { id: 'terminal', label: t('sidebar.terminal'), icon: '▸' }, - timeline: { id: 'timeline', label: t('sidebar.timeline'), icon: '═' }, - transcript: { id: 'transcript', label: t('sidebar.transcript'), icon: '☰' }, - screenshots: { id: 'screenshots', label: t('sidebar.screens'), icon: '◻' }, - targets: { id: 'targets', label: t('sidebar.targets'), icon: '⊕' }, - scope: { id: 'scope', label: t('sidebar.scope'), icon: '⊘', badge: scopeViolations, badgeColor: 'bg-red-500' }, - loot: { id: 'loot', label: t('sidebar.loot'), icon: '◆', badge: lootCount, badgeColor: 'bg-amber-500' }, - marks: { id: 'marks', label: t('sidebar.marks'), icon: '⚑' } + dashboard: { id: 'dashboard', label: t('sidebar.dashboard'), icon: ICON.dashboard }, + terminal: { id: 'terminal', label: t('sidebar.terminal'), icon: ICON.terminal }, + timeline: { id: 'timeline', label: t('sidebar.timeline'), icon: ICON.timeline }, + transcript: { id: 'transcript', label: t('sidebar.transcript'), icon: ICON.transcript }, + screenshots: { id: 'screenshots', label: t('sidebar.screens'), icon: ICON.screenshots }, + targets: { id: 'targets', label: t('sidebar.targets'), icon: ICON.targets }, + scope: { id: 'scope', label: t('sidebar.scope'), icon: ICON.scope, badge: scopeViolations, badgeColor: 'bg-red-500' }, + loot: { id: 'loot', label: t('sidebar.loot'), icon: ICON.loot, badge: lootCount, badgeColor: 'bg-amber-500' }, + marks: { id: 'marks', label: t('sidebar.marks'), icon: ICON.marks } } const items = order.map((id) => itemMap[id]).filter(Boolean) @@ -117,7 +136,7 @@ export default function Sidebar({ active, onNavigate }: SidebarProps): JSX.Eleme }, [onNavigate]) return ( -