Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"e2e": "playwright test",
"pree2e:ui": "electron-rebuild -f -o better-sqlite3",
"e2e:ui": "playwright test --ui",
"gen:settings-search": "node scripts/gen-settings-search.mjs",
"verify:architecture": "node scripts/verify-architecture.mjs"
},
"dependencies": {
Expand Down
26 changes: 26 additions & 0 deletions scripts/gen-settings-search.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env node
// Regenerate src/renderer/src/lib/settingsSearchIndex.ts from the Settings
// page sources. Run after adding or removing a setting; the settings-search
// test fails until you do.
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { PAGE_SOURCES, pageKeys } from './settings-search-sources.mjs'

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const read = (f) => fs.readFileSync(path.join(root, 'src/renderer/src/components', f), 'utf8')
let out = `// GENERATED by \`npm run gen:settings-search\` from the Settings page sources
// (scripts/settings-search-sources.mjs). test/settings-search.test.ts fails
// when a page renders a key this index does not have — regenerate.

import type { SettingsPage } from '../components/Settings'

export const SETTINGS_SEARCH_KEYS: Record<SettingsPage, readonly string[]> = {
`
const pages = Object.keys(PAGE_SOURCES)
pages.forEach((p, i) => {
out += ` ${p}: [\n${pageKeys(p, read).map((k) => ` '${k}'`).join(',\n')}\n ]${i < pages.length - 1 ? ',' : ''}\n`
})
out += '}\n'
fs.writeFileSync(path.join(root, 'src/renderer/src/lib/settingsSearchIndex.ts'), out)
console.log('settingsSearchIndex.ts regenerated')
38 changes: 38 additions & 0 deletions scripts/settings-search-sources.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Which source files make up each Settings page, for the field-search index
// (src/renderer/src/lib/settingsSearchIndex.ts) and the test that keeps that
// index in step with the pages. `after` takes the file from that marker on;
// `before` up to it — AgentsPanel.tsx holds two panels shown on two pages.
export const PAGE_SOURCES = {
hooks: [
{ file: 'settings/HooksPanel.tsx' },
{ file: 'WslPanel.tsx' },
{ file: 'settings/AgentsPanel.tsx', after: 'export function HookWatchPathsPanel' }
],
agents: [{ file: 'settings/AgentsPanel.tsx', before: 'export function HookWatchPathsPanel' }],
captureControl: [{ file: 'settings/CaptureControlPage.tsx' }, { file: 'settings/LootRulesGroup.tsx' }],
scope: [{ file: 'settings/ScopePage.tsx' }],
network: [{ file: 'settings/NetworkPage.tsx' }],
integrity: [{ file: 'settings/IntegrityPanel.tsx' }],
plugins: [{ file: 'settings/PluginsPanel.tsx' }],
general: [{ file: 'settings/GeneralPage.tsx' }],
hud: [{ file: 'settings/HudPage.tsx' }]
}

const KEY = /\bt\(\s*['"]([a-zA-Z]+\.[A-Za-z0-9_.]+)['"]/g

/** The i18n keys a page renders, in first-seen order. `read` returns a
* component file's source by its path under src/renderer/src/components. */
export function pageKeys(page, read) {
const keys = []
for (const { file, after, before } of PAGE_SOURCES[page]) {
let src = read(file)
if (after) src = src.slice(src.indexOf(after))
if (before) src = src.slice(0, src.indexOf(before))
for (const m of src.matchAll(KEY)) {
// Toasts and shared button words are not something you look a setting up by.
if (/^(toast|common)\./.test(m[1])) continue
if (!keys.includes(m[1])) keys.push(m[1])
}
}
return keys
}
6 changes: 6 additions & 0 deletions specs/034-operator-words/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Requirements Checklist: Settings Search, Type Names, Clock Meaning

- [x] Each FR has an observable, tested outcome.
- [x] Empty and no-match states specified.
- [x] The clock decision records the prior audit decision it keeps.
- [x] The label change is reconciled with #143's earlier relabel.
27 changes: 27 additions & 0 deletions specs/034-operator-words/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Implementation Plan: Settings Search, Type Names, Clock Meaning

## Canonical module interface

- `lib/settingsSearch.ts` `searchSettings(query, t)` over
`lib/settingsSearchIndex.ts` (generated by `scripts/gen-settings-search.mjs`
from `scripts/settings-search-sources.mjs`).
- `lib/timelineDomain.ts` `LANE_LABEL_KEYS`, `agentTypeLabel(type, t)` —
the Timeline and the filter bar both read them.

## Constitution Check

- **Surface Truthfulness**: the search label is tied to a wired search; the
clock states what it counts; types have names an operator uses.
- **Architectural Restraint**: no new settings pages or rearrangement; one
shared name table instead of a second one.
- **Risk-Based Test-First**: copy-level changes, tested by render and source
guards; the index guard prevents silent drift.

## Design

1. Index: per page, the i18n keys its source renders (toast/common excluded);
`AgentsPanel.tsx` is split at `HookWatchPathsPanel`, which renders on Hooks.
2. Settings: results under the page list; choosing one switches page and
scrolls the first element containing the text into view with a brief ring.
3. Filter bar and Timeline read `LANE_LABEL_KEYS`.
4. Status bar keeps its project-creation clock; adds tooltip and aria-label.
8 changes: 8 additions & 0 deletions specs/034-operator-words/quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Quickstart: Settings Search, Type Names, Clock Meaning

1. Settings → type `retention` (or `保存`) → pick the result → Capture control
opens at "Retention and disk budgets".
2. Timeline → Filter → Type: options read Shell, HTTP, Proxy…; hover shows the
stored type.
3. Hover the status-bar clock: "Time since this project was created (…), not
this session".
9 changes: 9 additions & 0 deletions specs/034-operator-words/research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Research: Settings Search, Type Names, Clock Meaning

- **Why generated keys, not a hand list**: a hand list drifts silently; a
generated list with a test that compares it to the source cannot.
- **Why not a runtime DOM crawl of all pages**: pages render only when open,
and some fetch data first; the i18n keys are static and complete.
- **Clock**: `StatusBar.tsx` documents audit P1 #33 — the counter is
engagement-scoped by decision. The operator-feedback item offered "session
start, or say project-creation"; the second keeps that decision.
62 changes: 62 additions & 0 deletions specs/034-operator-words/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Feature Specification: Settings Search, Type Names, Clock Meaning

**Feature Branch**: `feat/settings-field-search`
**Created**: 2026-09-23
**Status**: Verified
**Input**: Three places where the app speaks in its own terms instead of the operator's: Settings search only knew page names, the filter bar named types by storage name, and the status-bar clock did not say what it counts.

## Clarifications

### Session 2026-09-23

- Q: What does Settings search match? → A: the text each page renders —
group titles, field labels and hints — in the displayed language; page names
still filter the page list.
- Q: How is the index kept current? → A: generated from each page's source
(`npm run gen:settings-search`); a test fails when a page renders a key the
index lacks.
- Q: What names do types get? → A: the Timeline's lane names, from one shared
table; a plugin type with no lane keeps its stored name; the stored name is
the tooltip.
- Q: Should the clock count this session instead? → A: No. It runs from the
project's creation on purpose (audit P1 #33: reopening the app reset a
session clock mid-engagement). It now says so.
- Q: The box was relabelled "Filter categories…" in #143. → A: it searches
settings again, so it is "Search settings…" again; a test ties the label to
the search being wired.

## User Scenarios & Testing

### User Story 1 - Find a setting by what it is called (Priority: P1)

**Independent Test**: Typing "loot detection" lists the Loot detection group;
choosing it opens Capture control with the group on screen.

### User Story 2 - Filter by a type's name (Priority: P2)

**Independent Test**: The type picker and chip say "HTTP", not
`http_navigation`; the stored name is the tooltip.

### User Story 3 - Know what the clock counts (Priority: P2)

**Independent Test**: The status-bar clock's tooltip says it runs from the
project's creation date, not this session.

### Edge Cases

- A query matching nothing says "No setting matches …".
- A result whose text is truncated keeps its full text in a tooltip.

## Requirements

- **FR-001**: Settings search MUST match rendered setting text per page and
open the page of a chosen result, bringing the text into view.
- **FR-002**: The search index MUST fail a test when it falls behind a page.
- **FR-003**: Type names in the filter bar MUST come from the Timeline's lane
name table.
- **FR-004**: The clock MUST state that it counts from project creation.

## Success Criteria

- **SC-001**: `settings-search`, `agent-type-label`, the new renderer-smoke
cases and `settings-ia` pass.
8 changes: 8 additions & 0 deletions specs/034-operator-words/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Tasks: Settings Search, Type Names, Clock Meaning

- [x] T001 Page-source map and generator; generate the index.
- [x] T002 `searchSettings` with index-drift, language and empty-query tests.
- [x] T003 Settings UI: results, navigation, scroll/highlight, labels; IA test rewritten for the wired search.
- [x] T004 Shared lane name table; filter bar chip and options use it; tests.
- [x] T005 Status-bar clock tooltip and aria-label; render test.
- [x] T006 Full suite, typecheck, build; record verification and gates.
28 changes: 28 additions & 0 deletions specs/034-operator-words/verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Verification: Settings Search, Type Names, Clock Meaning

## RED

Not required as a failing-first run for the copy/labeling parts (Constitution
VIII, low-risk copy); the search module and its tests were written together.
The one guard that changed meaning — `settings-ia` asserted the box said
"Filter categories…" because it matched page names only — was rewritten to
require the search to be wired whenever the label says "Search settings…".

## GREEN

- `settings-search` 4, `agent-type-label` 3, `settings-ia`, and renderer-smoke
(21, including "search finds a field and opens its page" and the clock
tooltip) pass.
- The truncation guard caught a truncated result without its own tooltip;
fixed.
- Full suite outside the sandbox: 214 files pass (one run failed only on the
truncation guard above, before the fix). Typecheck and production build pass.

## Gates

| Gate | Result | Date |
|------|--------|------|
| Clarify | 5 answered — in spec.md | 2026-09-23 |
| Checklist | `checklists/requirements.md`: 4 items, all pass | 2026-09-23 |
| Analyze | 1 finding fixed: the plan (task list) called for a session clock, which conflicts with audit P1 #33's documented decision; FR-004 labels the existing clock instead | 2026-09-23 |
| Converge | 1 finding fixed: truncation guard (result text without its own tooltip) | 2026-09-23 |
17 changes: 11 additions & 6 deletions src/renderer/src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Filter, X, ChevronDown } from 'lucide-react'
import { useSharedFilter } from '../lib/FilterContext'
import { useI18n } from '../i18n'
import { formatTime } from '../lib/time'
import { agentTypeLabel } from '../lib/timelineDomain'

export function FilterBar(): JSX.Element | null {
const { filter, setTargetId, setAgentType, setTimeRange, setInScopeOnly, setHidePersonal, clearAll,
Expand Down Expand Up @@ -41,7 +42,11 @@ export function FilterBar(): JSX.Element | null {
<Chip label={`${t('filter.target')}: ${filter.targetId}`} onClear={() => setTargetId(null)} />
)}
{filter.agentType && (
<Chip label={`${t('filter.type')}: ${filter.agentType}`} onClear={() => setAgentType(null)} />
<Chip
label={`${t('filter.type')}: ${agentTypeLabel(filter.agentType, t)}`}
title={filter.agentType}
onClear={() => setAgentType(null)}
/>
)}
{filter.timeRange && (
<Chip
Expand Down Expand Up @@ -102,7 +107,7 @@ export function FilterBar(): JSX.Element | null {
label={t('filter.type')}
value={filter.agentType}
onChange={setAgentType}
options={knownAgentTypes.map((at) => ({ value: at, label: at }))}
options={knownAgentTypes.map((at) => ({ value: at, label: agentTypeLabel(at, t), title: at }))}
placeholder={t('filter.allTypes')}
/>

Expand Down Expand Up @@ -144,9 +149,9 @@ export function FilterBar(): JSX.Element | null {
)
}

function Chip({ label, onClear }: { label: string; onClear: () => void }): JSX.Element {
function Chip({ label, title, onClear }: { label: string; title?: string; onClear: () => void }): JSX.Element {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-redlog-elevated text-xs text-redlog-text-dim border border-redlog-border">
<span title={title} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-redlog-elevated text-xs text-redlog-text-dim border border-redlog-border">
{label}
<button onClick={onClear} className="hover:text-red-400 transition-colors" aria-label="Clear">
<X size={10} />
Expand All @@ -159,7 +164,7 @@ function FilterSelect({ label, value, onChange, options, placeholder }: {
label: string
value: string | null
onChange: (v: string | null) => void
options: Array<{ value: string; label: string }>
options: Array<{ value: string; label: string; title?: string }>
placeholder: string
}): JSX.Element {
return (
Expand All @@ -172,7 +177,7 @@ function FilterSelect({ label, value, onChange, options, placeholder }: {
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
<option key={o.value} value={o.value} title={o.title}>{o.label}</option>
))}
</select>
</div>
Expand Down
44 changes: 39 additions & 5 deletions src/renderer/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import HooksPanel from './settings/HooksPanel'
import PluginsPanel from './settings/PluginsPanel'
import IntegrityPanel from './settings/IntegrityPanel'
import AgentsPanel, { HookWatchPathsPanel } from './settings/AgentsPanel'
import { searchSettings } from '../lib/settingsSearch'

// The thirteen pages §10 asks for. Declared as a union so a typo in a route
// is a compile error rather than a page that silently never renders.
Expand All @@ -30,6 +31,20 @@ export default function Settings({ request = null }: { request?: { page: Setting
const [tab, setTab] = useState<SettingsPage>(request?.page ?? 'hooks')
useEffect(() => { if (request) setTab(request.page) }, [request])
const [pageQuery, setPageQuery] = useState('')
// The setting a search result points at: after its page renders, the text is
// scrolled into view and marked briefly.
const [findText, setFindText] = useState<string | null>(null)
const pane = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!findText || !pane.current) return
const els = Array.from(pane.current.querySelectorAll<HTMLElement>('h3, label, p, span, button'))
const el = els.find((e) => e.textContent?.includes(findText))
setFindText(null)
if (!el) return
el.scrollIntoView?.({ block: 'center' })
el.classList.add('ring-1', 'ring-redlog-accent', 'rounded')
setTimeout(() => el.classList.remove('ring-1', 'ring-redlog-accent', 'rounded'), 1600)
}, [findText, tab])
const [saved, setSaved] = useState(false)
const [hooks, setHooks] = useState<HookInfo[]>([])
const [hookLoading, setHookLoading] = useState<string | null>(null)
Expand Down Expand Up @@ -105,6 +120,8 @@ export default function Settings({ request = null }: { request?: { page: Setting
const visible = groups
.map((g) => ({ ...g, pages: g.pages.filter((pg) => !q || pg.label.toLowerCase().includes(q)) }))
.filter((g) => g.pages.length > 0)
const pageLabel = new Map(groups.flatMap((g) => g.pages.map((pg) => [pg.id, pg.label] as const)))
const hits = searchSettings(pageQuery, t)

return (
<div className="flex h-full">
Expand All @@ -116,15 +133,15 @@ export default function Settings({ request = null }: { request?: { page: Setting
<input
value={pageQuery}
onChange={(e) => setPageQuery(e.target.value)}
placeholder={t('settings.filterPages')}
aria-label={t('settings.filterPages')}
placeholder={t('settings.searchSettings')}
aria-label={t('settings.searchSettings')}
className="w-full px-2 py-1.5 bg-redlog-elevated border border-redlog-border rounded text-xs text-redlog-text placeholder-redlog-muted outline-none focus:border-redlog-accent/60"
/>
</div>
<div className="flex-1 overflow-y-auto py-2">
{visible.length === 0 && (
{visible.length === 0 && hits.length === 0 && (
<p className="px-3 py-4 text-xs text-redlog-text-faint text-center">
{t('settings.noPageMatches', { query: pageQuery })}
{t('settings.noSettingMatches', { query: pageQuery })}
</p>
)}
{visible.map((g) => (
Expand All @@ -149,6 +166,23 @@ export default function Settings({ request = null }: { request?: { page: Setting
))}
</div>
))}
{hits.length > 0 && (
<div className="mb-2" data-testid="settings-search-results">
<p className="px-3 pt-1 pb-1 text-xs font-semibold text-redlog-text-faint uppercase tracking-wider">
{t('settings.searchResults')}
</p>
{hits.map((h) => (
<button
key={`${h.page}:${h.text}`}
onClick={() => { setTab(h.page); setFindText(h.text) }}
className="w-full text-left px-3 py-1 text-xs rounded-md text-redlog-text-dim hover:text-redlog-text hover:bg-white/[0.03]"
>
<span className="block truncate" title={h.text}>{h.text}</span>
<span className="block text-redlog-text-faint">{pageLabel.get(h.page)}</span>
</button>
))}
</div>
)}
</div>
{saved && (
<p className="px-3 py-2 text-xs text-emerald-400 border-t border-redlog-border">
Expand All @@ -159,7 +193,7 @@ export default function Settings({ request = null }: { request?: { page: Setting

{/* The right pane is a column: content scrolls, the save bar stays put. */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 overflow-y-auto p-4 space-y-4 max-w-[900px]">
<div ref={pane} className="flex-1 overflow-y-auto p-4 space-y-4 max-w-[900px]">
{tab === 'general' && <GeneralPage config={config} setConfig={setConfig} />}
{tab === 'hud' && <HudPage config={config} setConfig={setConfig} t={t} />}
{tab === 'network' && <NetworkPage config={config} setConfig={setConfig} t={t} />}
Expand Down
Loading
Loading